@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
package/src/api.smoke.test.ts
CHANGED
|
@@ -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
|
};
|
|
@@ -236,6 +238,7 @@ function buildApp(opts?: {
|
|
|
236
238
|
maxFindings?: number;
|
|
237
239
|
cleanupMergedWorktrees?: boolean;
|
|
238
240
|
cleanupAfterDays?: number;
|
|
241
|
+
transcriptUploadEnabled?: boolean;
|
|
239
242
|
}) {
|
|
240
243
|
const hasAgent = opts?.hasAgent ?? true;
|
|
241
244
|
const bundle: AgentEnvBundle | null =
|
|
@@ -308,6 +311,7 @@ function buildApp(opts?: {
|
|
|
308
311
|
agentTokenService: { validate: async () => null },
|
|
309
312
|
sessionSecret: SESSION_SECRET,
|
|
310
313
|
anthropicFederationEnabled: opts?.anthropicFederationEnabled,
|
|
314
|
+
transcriptUploadEnabled: opts?.transcriptUploadEnabled,
|
|
311
315
|
});
|
|
312
316
|
}
|
|
313
317
|
|
|
@@ -326,6 +330,31 @@ describe("GET /:id/config with no phase-methodology service (platform only)", ()
|
|
|
326
330
|
});
|
|
327
331
|
});
|
|
328
332
|
|
|
333
|
+
describe("GET /:id/config features (COS-43.1)", () => {
|
|
334
|
+
const get = (app: ReturnType<typeof buildApp>) =>
|
|
335
|
+
app.request(`/${KNOWN_AGENT_ID}/config`, {
|
|
336
|
+
headers: { Authorization: `Bearer ${VALID_ADMIN_KEY}` },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("features default to all off", async () => {
|
|
340
|
+
const body = await (await get(buildApp())).json();
|
|
341
|
+
expect(body.features).toEqual({
|
|
342
|
+
transcriptUpload: false,
|
|
343
|
+
repoAccessCheck: false,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("features.transcriptUpload follows the transcript flag", async () => {
|
|
348
|
+
const body = await (
|
|
349
|
+
await get(buildApp({ transcriptUploadEnabled: true }))
|
|
350
|
+
).json();
|
|
351
|
+
expect(body.features).toEqual({
|
|
352
|
+
transcriptUpload: true,
|
|
353
|
+
repoAccessCheck: false,
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
329
358
|
describe("GET /:id/config (mounted as GET /agents/:id/config from root)", () => {
|
|
330
359
|
test("200 with full bundle when agent exists with env vars, tools, plugins", async () => {
|
|
331
360
|
const app = buildApp();
|
|
@@ -787,6 +816,8 @@ function buildCombinedApp() {
|
|
|
787
816
|
system: false,
|
|
788
817
|
parentCronId: null,
|
|
789
818
|
lockdownDisabledAt: null,
|
|
819
|
+
runRequestedAt: null,
|
|
820
|
+
runRequestedBy: null,
|
|
790
821
|
createdAt: new Date(),
|
|
791
822
|
updatedAt: new Date(),
|
|
792
823
|
}),
|
|
@@ -804,6 +835,8 @@ function buildCombinedApp() {
|
|
|
804
835
|
system: false,
|
|
805
836
|
parentCronId: null,
|
|
806
837
|
lockdownDisabledAt: null,
|
|
838
|
+
runRequestedAt: null,
|
|
839
|
+
runRequestedBy: null,
|
|
807
840
|
createdAt: new Date(),
|
|
808
841
|
updatedAt: new Date(),
|
|
809
842
|
}),
|
|
@@ -822,6 +855,8 @@ function buildCombinedApp() {
|
|
|
822
855
|
system: false,
|
|
823
856
|
parentCronId: null,
|
|
824
857
|
lockdownDisabledAt: null,
|
|
858
|
+
runRequestedAt: null,
|
|
859
|
+
runRequestedBy: null,
|
|
825
860
|
createdAt: new Date(),
|
|
826
861
|
updatedAt: new Date(),
|
|
827
862
|
}),
|
|
@@ -839,6 +874,8 @@ function buildCombinedApp() {
|
|
|
839
874
|
system: false,
|
|
840
875
|
parentCronId: null,
|
|
841
876
|
lockdownDisabledAt: null,
|
|
877
|
+
runRequestedAt: null,
|
|
878
|
+
runRequestedBy: null,
|
|
842
879
|
createdAt: new Date(),
|
|
843
880
|
updatedAt: new Date(),
|
|
844
881
|
}),
|
|
@@ -861,6 +898,8 @@ function buildCombinedApp() {
|
|
|
861
898
|
system: false,
|
|
862
899
|
parentCronId: null,
|
|
863
900
|
lockdownDisabledAt: null,
|
|
901
|
+
runRequestedAt: null,
|
|
902
|
+
runRequestedBy: null,
|
|
864
903
|
createdAt: new Date(),
|
|
865
904
|
updatedAt: new Date(),
|
|
866
905
|
}),
|
package/src/api.ts
CHANGED
|
@@ -93,6 +93,11 @@ export interface AgentConfigResponse {
|
|
|
93
93
|
maxFindings: number;
|
|
94
94
|
cleanupMergedWorktrees: boolean;
|
|
95
95
|
cleanupAfterDays: number;
|
|
96
|
+
/**
|
|
97
|
+
* COS-43.1: feature flags the runtime acts on. Optional so fixtures built
|
|
98
|
+
* before it keep compiling; the live handler always populates it.
|
|
99
|
+
*/
|
|
100
|
+
features?: { transcriptUpload: boolean; repoAccessCheck: boolean };
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
interface AgentEnvServiceLike {
|
|
@@ -169,6 +174,11 @@ export interface AgentRuntimeDeps {
|
|
|
169
174
|
agentTokenService: Pick<AgentTokenService, "validate">;
|
|
170
175
|
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
171
176
|
anthropicFederationEnabled?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* COS-43.1: SHIPWRIGHT_TRANSCRIPT_API_ENABLED is on — surfaced to the runtime
|
|
179
|
+
* as `features.transcriptUpload` in the config bundle. Defaults to false.
|
|
180
|
+
*/
|
|
181
|
+
transcriptUploadEnabled?: boolean;
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
// ─── Route definitions ────────────────────────────────────────────────────────
|
|
@@ -342,6 +352,11 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
|
|
|
342
352
|
maxFindings: agent.maxFindings,
|
|
343
353
|
cleanupMergedWorktrees: agent.cleanupMergedWorktrees,
|
|
344
354
|
cleanupAfterDays: agent.cleanupAfterDays,
|
|
355
|
+
// COS-43.1: repoAccessCheck is wired by COS-44.1; false until then.
|
|
356
|
+
features: {
|
|
357
|
+
transcriptUpload: deps.transcriptUploadEnabled === true,
|
|
358
|
+
repoAccessCheck: false,
|
|
359
|
+
},
|
|
345
360
|
};
|
|
346
361
|
|
|
347
362
|
// The route's Zod schema requires phaseMethodology; a platform-only admin
|
package/src/chat.smoke.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|