@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.
- package/openapi.platform.json +101 -0
- package/package.json +2 -2
- package/prisma/client/browser.ts +5 -0
- package/prisma/client/client.ts +5 -0
- package/prisma/client/internal/class.ts +14 -4
- package/prisma/client/internal/prismaNamespace.ts +93 -1
- package/prisma/client/internal/prismaNamespaceBrowser.ts +17 -0
- package/prisma/client/models/AgentCronJob.ts +107 -1
- package/prisma/client/models/AgentCronRunRequest.ts +1233 -0
- package/prisma/client/models.ts +1 -0
- package/prisma/migrations/20261028000000_add_cron_run_now/migration.sql +24 -0
- package/prisma/schema/schema.prisma +24 -0
- package/src/admin-boot.ts +8 -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 +17 -0
- package/src/api-auth.ts +1 -0
- package/src/api.smoke.test.ts +12 -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/openapi-schemas.ts +14 -0
- package/src/openapi-schemas.unit.test.ts +4 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/cron-run-now.smoke.test.ts
|
|
3
|
+
*
|
|
4
|
+
* POST /agents/{id}/crons/{cronId}/run-now (COS-42.1) over the real admin
|
|
5
|
+
* route table and auth middleware, with an in-memory store and a fixed clock.
|
|
6
|
+
* Uses app.request() — no real server, no real DB.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
type AdminDeps,
|
|
12
|
+
createAdminApp,
|
|
13
|
+
parseAdminApiKeys,
|
|
14
|
+
} from "./agents-api.ts";
|
|
15
|
+
import { PLATFORM_OPS_ROUTES } from "./api-auth.ts";
|
|
16
|
+
import type {
|
|
17
|
+
CronRunNowStore,
|
|
18
|
+
RunRequestRow,
|
|
19
|
+
RunNowCron,
|
|
20
|
+
} from "./cron-run-now.ts";
|
|
21
|
+
import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
|
|
22
|
+
|
|
23
|
+
const AGENT = "agent-a";
|
|
24
|
+
const CRON = "cron-1";
|
|
25
|
+
const LOCKED = "cron-locked";
|
|
26
|
+
const SECRET_PROMPT = "super secret prompt text";
|
|
27
|
+
|
|
28
|
+
const ADMIN = "admin-key";
|
|
29
|
+
const READ = "platform-read-token";
|
|
30
|
+
const OPS = "platform-ops-token";
|
|
31
|
+
const OWN_KEY = "own-agent-key";
|
|
32
|
+
const OTHER_KEY = "other-agent-key";
|
|
33
|
+
|
|
34
|
+
const NOW = new Date("2026-10-26T12:00:00.000Z");
|
|
35
|
+
|
|
36
|
+
function memoryStore() {
|
|
37
|
+
const crons = new Map<string, RunNowCron>([
|
|
38
|
+
[CRON, { id: CRON, agentId: AGENT, lockdownDisabledAt: null }],
|
|
39
|
+
[LOCKED, { id: LOCKED, agentId: AGENT, lockdownDisabledAt: NOW }],
|
|
40
|
+
]);
|
|
41
|
+
const requests: RunRequestRow[] = [];
|
|
42
|
+
const events: string[] = [];
|
|
43
|
+
const store: CronRunNowStore = {
|
|
44
|
+
async findCron(agentId, cronId) {
|
|
45
|
+
const cron = crons.get(cronId);
|
|
46
|
+
return cron && cron.agentId === agentId ? cron : null;
|
|
47
|
+
},
|
|
48
|
+
async recordRequest(input) {
|
|
49
|
+
events.push("record");
|
|
50
|
+
const row: RunRequestRow = {
|
|
51
|
+
id: `req-${requests.length + 1}`,
|
|
52
|
+
status: "requested",
|
|
53
|
+
honouredRunId: null,
|
|
54
|
+
...input,
|
|
55
|
+
};
|
|
56
|
+
requests.push(row);
|
|
57
|
+
return row;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
return { store, requests, events };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeApp(opts: { enabled?: boolean } = {}) {
|
|
64
|
+
const mem = memoryStore();
|
|
65
|
+
const deps = {
|
|
66
|
+
agentService: { exists: async () => true },
|
|
67
|
+
agentTokenService: { validate: async () => null },
|
|
68
|
+
agentMemberService: { listByEmail: async () => [] },
|
|
69
|
+
adminApiKeys: parseAdminApiKeys(
|
|
70
|
+
`root:${ADMIN}:*,reader:${READ}:platform.read,operator:${OPS}:platform.ops,own:${OWN_KEY}:${AGENT},other:${OTHER_KEY}:agent-other`,
|
|
71
|
+
),
|
|
72
|
+
sessionSecret: "s".repeat(32),
|
|
73
|
+
cronRunNow: {
|
|
74
|
+
config: parseCronRunNowConfig({
|
|
75
|
+
SHIPWRIGHT_CRON_RUN_NOW_ENABLED:
|
|
76
|
+
opts.enabled === false ? undefined : "enabled",
|
|
77
|
+
}),
|
|
78
|
+
store: mem.store,
|
|
79
|
+
clock: { now: () => NOW },
|
|
80
|
+
},
|
|
81
|
+
} as unknown as AdminDeps;
|
|
82
|
+
return { app: createAdminApp(deps), ...mem };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function runNow(
|
|
86
|
+
app: ReturnType<typeof createAdminApp>,
|
|
87
|
+
token: string,
|
|
88
|
+
init: { body?: string; cron?: string; contentType?: string } = {},
|
|
89
|
+
) {
|
|
90
|
+
return app.request(`/agents/${AGENT}/crons/${init.cron ?? CRON}/run-now`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${token}`,
|
|
94
|
+
...(init.contentType ? { "Content-Type": init.contentType } : {}),
|
|
95
|
+
},
|
|
96
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("POST /agents/{id}/crons/{cronId}/run-now", () => {
|
|
101
|
+
let logs: string[];
|
|
102
|
+
const spies: Array<{ mockRestore: () => void }> = [];
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
logs = [];
|
|
105
|
+
for (const m of ["log", "info", "warn", "error"] as const) {
|
|
106
|
+
spies.push(
|
|
107
|
+
spyOn(console, m).mockImplementation((...a: unknown[]) => {
|
|
108
|
+
logs.push(a.map(String).join(" "));
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
for (const s of spies.splice(0)) s.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("flag off -> 404 for every caller, nothing written", async () => {
|
|
118
|
+
const { app, requests } = makeApp({ enabled: false });
|
|
119
|
+
for (const token of [ADMIN, OPS, OWN_KEY]) {
|
|
120
|
+
const res = await runNow(app, token);
|
|
121
|
+
expect(res.status).toBe(404);
|
|
122
|
+
expect(await res.json()).toEqual({ error: "Not found" });
|
|
123
|
+
}
|
|
124
|
+
expect(requests).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("any body -> 400, nothing written", async () => {
|
|
128
|
+
const { app, requests } = makeApp();
|
|
129
|
+
for (const init of [
|
|
130
|
+
{
|
|
131
|
+
body: JSON.stringify({ prompt: SECRET_PROMPT }),
|
|
132
|
+
contentType: "application/json",
|
|
133
|
+
},
|
|
134
|
+
{ body: "{}", contentType: "application/json" },
|
|
135
|
+
{ body: "x", contentType: "text/plain" },
|
|
136
|
+
]) {
|
|
137
|
+
expect((await runNow(app, ADMIN, init)).status).toBe(400);
|
|
138
|
+
}
|
|
139
|
+
expect(requests).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("platform-read -> 403; other agent's key -> 403", async () => {
|
|
143
|
+
const { app, requests } = makeApp();
|
|
144
|
+
expect((await runNow(app, READ)).status).toBe(403);
|
|
145
|
+
expect((await runNow(app, OTHER_KEY)).status).toBe(403);
|
|
146
|
+
expect(requests).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("no credentials -> 401", async () => {
|
|
150
|
+
const { app } = makeApp();
|
|
151
|
+
const res = await app.request(`/agents/${AGENT}/crons/${CRON}/run-now`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
});
|
|
154
|
+
expect(res.status).toBe(401);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("admin, platform-ops and the agent's own key -> 202 with an audit row", async () => {
|
|
158
|
+
const { app, requests } = makeApp();
|
|
159
|
+
for (const token of [ADMIN, OPS, OWN_KEY]) {
|
|
160
|
+
const res = await runNow(app, token);
|
|
161
|
+
expect(res.status).toBe(202);
|
|
162
|
+
expect(await res.json()).toEqual({ requestedAt: NOW.toISOString() });
|
|
163
|
+
}
|
|
164
|
+
expect(requests).toHaveLength(3);
|
|
165
|
+
for (const row of requests) {
|
|
166
|
+
expect(row).toMatchObject({
|
|
167
|
+
cronId: CRON,
|
|
168
|
+
agentId: AGENT,
|
|
169
|
+
requestedAt: NOW,
|
|
170
|
+
status: "requested",
|
|
171
|
+
});
|
|
172
|
+
expect(row.requestedBy.length).toBeGreaterThan(0);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("writes the audit row before responding", async () => {
|
|
177
|
+
const { app, events } = makeApp();
|
|
178
|
+
const res = await runNow(app, ADMIN);
|
|
179
|
+
expect(res.status).toBe(202);
|
|
180
|
+
expect(events).toEqual(["record"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("lockdown-disabled cron -> 409", async () => {
|
|
184
|
+
const { app, requests } = makeApp();
|
|
185
|
+
const res = await runNow(app, ADMIN, { cron: LOCKED });
|
|
186
|
+
expect(res.status).toBe(409);
|
|
187
|
+
expect(requests).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("unknown cron -> 404", async () => {
|
|
191
|
+
const { app } = makeApp();
|
|
192
|
+
expect((await runNow(app, ADMIN, { cron: "nope" })).status).toBe(404);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("logs one structured cron-run-now line and never the prompt", async () => {
|
|
196
|
+
const { app } = makeApp();
|
|
197
|
+
await runNow(app, ADMIN, {
|
|
198
|
+
body: JSON.stringify({ prompt: SECRET_PROMPT }),
|
|
199
|
+
contentType: "application/json",
|
|
200
|
+
});
|
|
201
|
+
await runNow(app, OPS);
|
|
202
|
+
const lines = logs.filter((l) => l.includes('"cron-run-now"'));
|
|
203
|
+
expect(lines).toHaveLength(1);
|
|
204
|
+
const parsed = JSON.parse(lines[0] as string);
|
|
205
|
+
expect(parsed).toMatchObject({
|
|
206
|
+
event: "cron-run-now",
|
|
207
|
+
agentId: AGENT,
|
|
208
|
+
cronId: CRON,
|
|
209
|
+
});
|
|
210
|
+
expect(logs.join("\n")).not.toContain(SECRET_PROMPT);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("is on PLATFORM_OPS_ROUTES", () => {
|
|
214
|
+
expect(PLATFORM_OPS_ROUTES.map((r) => `${r.method} ${r.path}`)).toContain(
|
|
215
|
+
"POST /agents/{id}/crons/{cronId}/run-now",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/cron-run-now.ts
|
|
3
|
+
*
|
|
4
|
+
* Cron run-now (COS-42.1): POST /agents/{id}/crons/{cronId}/run-now asks the
|
|
5
|
+
* agent's runtime to fire one cron job immediately. The route takes NO body
|
|
6
|
+
* and never reads or logs a prompt: it only stamps `runRequestedAt` /
|
|
7
|
+
* `runRequestedBy` on the stored job and writes an AgentCronRunRequest audit
|
|
8
|
+
* row (before the 202). The runtime's cron sync honours the stamp once, while
|
|
9
|
+
* it is fresh, running the job's own stored prompt.
|
|
10
|
+
*
|
|
11
|
+
* Gates, in order: flag (SHIPWRIGHT_CRON_RUN_NOW_ENABLED, 404 when off) →
|
|
12
|
+
* caller (`*`, platform.ops, or the agent's own key; 403) → empty body (400)
|
|
13
|
+
* → cron exists for the agent (404) → not lockdown-disabled (409).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { callerLabel } from "@app-vitals/agent-lib/request-context";
|
|
17
|
+
import { createRoute, type OpenAPIHono } from "@hono/zod-openapi";
|
|
18
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
19
|
+
import type { AdminAuthEnv, AdminScope } from "./api-auth.ts";
|
|
20
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
21
|
+
import {
|
|
22
|
+
BadRequestError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
ForbiddenError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
} from "./errors.ts";
|
|
27
|
+
import type { CronRunNowConfig } from "./cron-run-now-config.ts";
|
|
28
|
+
import {
|
|
29
|
+
CronIdParamSchema,
|
|
30
|
+
CronRunNowResponseSchema,
|
|
31
|
+
ErrorSchema,
|
|
32
|
+
} from "./openapi-schemas.ts";
|
|
33
|
+
|
|
34
|
+
// ─── Store ────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** The cron fields the route needs. Never the prompt. */
|
|
37
|
+
export interface RunNowCron {
|
|
38
|
+
id: string;
|
|
39
|
+
agentId: string;
|
|
40
|
+
lockdownDisabledAt: Date | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One AgentCronRunRequest audit row. No prompt text, by design. */
|
|
44
|
+
export interface RunRequestRow {
|
|
45
|
+
id: string;
|
|
46
|
+
cronId: string;
|
|
47
|
+
agentId: string;
|
|
48
|
+
requestedBy: string;
|
|
49
|
+
requestedAt: Date;
|
|
50
|
+
status: string;
|
|
51
|
+
honouredRunId: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CronRunNowStore {
|
|
55
|
+
findCron(agentId: string, cronId: string): Promise<RunNowCron | null>;
|
|
56
|
+
/** Stamp the job and write the audit row, atomically. */
|
|
57
|
+
recordRequest(input: {
|
|
58
|
+
cronId: string;
|
|
59
|
+
agentId: string;
|
|
60
|
+
requestedBy: string;
|
|
61
|
+
requestedAt: Date;
|
|
62
|
+
}): Promise<RunRequestRow>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Prisma-backed run-now store. */
|
|
66
|
+
export class CronRunNowService implements CronRunNowStore {
|
|
67
|
+
constructor(
|
|
68
|
+
private prisma: Pick<
|
|
69
|
+
PrismaClient,
|
|
70
|
+
"agentCronJob" | "agentCronRunRequest" | "$transaction"
|
|
71
|
+
>,
|
|
72
|
+
) {}
|
|
73
|
+
|
|
74
|
+
findCron(agentId: string, cronId: string): Promise<RunNowCron | null> {
|
|
75
|
+
return this.prisma.agentCronJob.findFirst({
|
|
76
|
+
where: { id: cronId, agentId },
|
|
77
|
+
select: { id: true, agentId: true, lockdownDisabledAt: true },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
recordRequest(input: {
|
|
82
|
+
cronId: string;
|
|
83
|
+
agentId: string;
|
|
84
|
+
requestedBy: string;
|
|
85
|
+
requestedAt: Date;
|
|
86
|
+
}): Promise<RunRequestRow> {
|
|
87
|
+
return this.prisma.$transaction(async (tx) => {
|
|
88
|
+
await tx.agentCronJob.update({
|
|
89
|
+
where: { id: input.cronId },
|
|
90
|
+
data: {
|
|
91
|
+
runRequestedAt: input.requestedAt,
|
|
92
|
+
runRequestedBy: input.requestedBy,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
// A newer stamp overwrites the job's, so any request still pending can
|
|
96
|
+
// never be honoured: mark it expired.
|
|
97
|
+
await tx.agentCronRunRequest.updateMany({
|
|
98
|
+
where: { cronId: input.cronId, status: "requested" },
|
|
99
|
+
data: { status: "expired" },
|
|
100
|
+
});
|
|
101
|
+
return tx.agentCronRunRequest.create({
|
|
102
|
+
data: { ...input, status: "requested" },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Deps ─────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface CronRunNowDeps {
|
|
111
|
+
config: CronRunNowConfig;
|
|
112
|
+
store: CronRunNowStore;
|
|
113
|
+
/** Injected for deterministic timestamps; defaults to the system clock. */
|
|
114
|
+
clock?: Clock;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Route ────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const jsonError = {
|
|
120
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const runNowRoute = createRoute({
|
|
124
|
+
method: "post",
|
|
125
|
+
path: "/agents/{id}/crons/{cronId}/run-now",
|
|
126
|
+
summary: "Request an immediate run of a cron job",
|
|
127
|
+
description:
|
|
128
|
+
"Asks the agent's runtime to run the cron job once, now, with the job's own stored prompt. Takes no request body (any body is 400). Stamps `runRequestedAt` on the job and writes an AgentCronRunRequest audit row before returning 202; the runtime honours a fresh request (under 15 minutes old) once on its next cron sync. Callers: `*`, `platform.ops`, or the agent's own key. 404 unless SHIPWRIGHT_CRON_RUN_NOW_ENABLED is `enabled`; 409 when the trial-expiry lockdown disabled the cron.",
|
|
129
|
+
request: { params: CronIdParamSchema },
|
|
130
|
+
responses: {
|
|
131
|
+
202: {
|
|
132
|
+
description: "Run requested",
|
|
133
|
+
content: { "application/json": { schema: CronRunNowResponseSchema } },
|
|
134
|
+
},
|
|
135
|
+
400: { description: "A request body was sent", ...jsonError },
|
|
136
|
+
403: { description: "Caller not allowed", ...jsonError },
|
|
137
|
+
404: {
|
|
138
|
+
description: "Cron not found, or cron run-now disabled",
|
|
139
|
+
...jsonError,
|
|
140
|
+
},
|
|
141
|
+
409: { description: "Cron disabled by lockdown", ...jsonError },
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Register the run-now route on the admin app. Must run after the /agents/*
|
|
147
|
+
* auth middleware is mounted.
|
|
148
|
+
*/
|
|
149
|
+
export function registerCronRunNowRoutes(
|
|
150
|
+
app: OpenAPIHono<AdminAuthEnv>,
|
|
151
|
+
deps: { cronRunNow?: CronRunNowDeps },
|
|
152
|
+
): void {
|
|
153
|
+
const { cronRunNow } = deps;
|
|
154
|
+
const enabled = cronRunNow?.config.enabled === true;
|
|
155
|
+
|
|
156
|
+
// Flag gate ahead of everything else, so a disabled route is a plain 404.
|
|
157
|
+
app.use("/agents/:id/crons/:cronId/run-now", async (c, next) => {
|
|
158
|
+
if (!enabled) return c.json({ error: "Not found" }, 404);
|
|
159
|
+
await next();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
app.openapi(runNowRoute, async (c) => {
|
|
163
|
+
if (!cronRunNow) throw new NotFoundError();
|
|
164
|
+
const { id: agentId, cronId } = c.req.valid("param");
|
|
165
|
+
|
|
166
|
+
const scope = c.get("scope") as AdminScope | undefined;
|
|
167
|
+
const allowedCaller =
|
|
168
|
+
c.get("isAdmin") === true ||
|
|
169
|
+
scope?.kind === "platform-ops" ||
|
|
170
|
+
(scope?.kind === "agent" && scope.agentId === agentId);
|
|
171
|
+
if (!allowedCaller) {
|
|
172
|
+
throw new ForbiddenError(
|
|
173
|
+
"Only *, platform.ops or the agent's own key can request a run",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The prompt is never taken from the request: reject any body.
|
|
178
|
+
if ((await c.req.text()).length > 0) {
|
|
179
|
+
throw new BadRequestError("run-now takes no request body");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const cron = await cronRunNow.store.findCron(agentId, cronId);
|
|
183
|
+
if (!cron) throw new NotFoundError(`cron job ${cronId} not found`);
|
|
184
|
+
if (cron.lockdownDisabledAt) {
|
|
185
|
+
throw new ConflictError("cron job is disabled by lockdown");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const requestedAt = (cronRunNow.clock ?? SystemClock()).now();
|
|
189
|
+
const requestedBy = callerLabel(c.get("caller"), scope?.kind);
|
|
190
|
+
await cronRunNow.store.recordRequest({
|
|
191
|
+
cronId,
|
|
192
|
+
agentId,
|
|
193
|
+
requestedBy,
|
|
194
|
+
requestedAt,
|
|
195
|
+
});
|
|
196
|
+
console.log(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
event: "cron-run-now",
|
|
199
|
+
agentId,
|
|
200
|
+
cronId,
|
|
201
|
+
requestedAt: requestedAt.toISOString(),
|
|
202
|
+
callerLabel: requestedBy,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
return c.json({ requestedAt: requestedAt.toISOString() }, 202);
|
|
206
|
+
});
|
|
207
|
+
}
|
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()
|
|
@@ -774,6 +779,15 @@ export const AgentAskBodySchema = z
|
|
|
774
779
|
})
|
|
775
780
|
.openapi("AgentAskBody");
|
|
776
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
|
+
|
|
777
791
|
export const AgentAskResponseSchema = z
|
|
778
792
|
.object({
|
|
779
793
|
askId: z.string().openapi({ example: "clx1111111111" }),
|
|
@@ -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
|
};
|