Cron: accept jobId in gateway cron params

Closes #252
This commit is contained in:
Shadow
2026-01-12 21:35:43 -06:00
parent ab993904d7
commit 25297ce3f5
4 changed files with 166 additions and 38 deletions

View File

@@ -7,6 +7,7 @@
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm) - Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
### Fixes ### Fixes
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow) - Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
- TUI: keep the last streamed response instead of replacing it with “(no output)”. (#747 — thanks @thewilloftheshadow) - TUI: keep the last streamed response instead of replacing it with “(no output)”. (#747 — thanks @thewilloftheshadow)
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow) - Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)

View File

@@ -869,38 +869,75 @@ export const CronAddParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const CronUpdateParamsSchema = Type.Object( export const CronUpdateParamsSchema = Type.Union([
{ Type.Object(
id: NonEmptyString, {
patch: Type.Partial(CronAddParamsSchema), id: NonEmptyString,
}, patch: Type.Partial(CronAddParamsSchema),
{ additionalProperties: false }, },
); { additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
patch: Type.Partial(CronAddParamsSchema),
},
{ additionalProperties: false },
),
]);
export const CronRemoveParamsSchema = Type.Object( export const CronRemoveParamsSchema = Type.Union([
{ Type.Object(
id: NonEmptyString, {
}, id: NonEmptyString,
{ additionalProperties: false }, },
); { additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
},
{ additionalProperties: false },
),
]);
export const CronRunParamsSchema = Type.Object( export const CronRunParamsSchema = Type.Union([
{ Type.Object(
id: NonEmptyString, {
mode: Type.Optional( id: NonEmptyString,
Type.Union([Type.Literal("due"), Type.Literal("force")]), mode: Type.Optional(
), Type.Union([Type.Literal("due"), Type.Literal("force")]),
}, ),
{ additionalProperties: false }, },
); { additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
mode: Type.Optional(
Type.Union([Type.Literal("due"), Type.Literal("force")]),
),
},
{ additionalProperties: false },
),
]);
export const CronRunsParamsSchema = Type.Object( export const CronRunsParamsSchema = Type.Union([
{ Type.Object(
id: NonEmptyString, {
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })), id: NonEmptyString,
}, limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
{ additionalProperties: false }, },
); { additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
},
{ additionalProperties: false },
),
]);
export const CronRunLogEntrySchema = Type.Object( export const CronRunLogEntrySchema = Type.Object(
{ {

View File

@@ -111,11 +111,24 @@ export const cronHandlers: GatewayRequestHandlers = {
return; return;
} }
const p = candidate as { const p = candidate as {
id: string; id?: string;
jobId?: string;
patch: Record<string, unknown>; patch: Record<string, unknown>;
}; };
const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.update params: missing id",
),
);
return;
}
const job = await context.cron.update( const job = await context.cron.update(
p.id, jobId,
p.patch as unknown as CronJobPatch, p.patch as unknown as CronJobPatch,
); );
respond(true, job, undefined); respond(true, job, undefined);
@@ -132,8 +145,20 @@ export const cronHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const p = params as { id: string }; const p = params as { id?: string; jobId?: string };
const result = await context.cron.remove(p.id); const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.remove params: missing id",
),
);
return;
}
const result = await context.cron.remove(jobId);
respond(true, result, undefined); respond(true, result, undefined);
}, },
"cron.run": async ({ params, respond, context }) => { "cron.run": async ({ params, respond, context }) => {
@@ -148,8 +173,20 @@ export const cronHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const p = params as { id: string; mode?: "due" | "force" }; const p = params as { id?: string; jobId?: string; mode?: "due" | "force" };
const result = await context.cron.run(p.id, p.mode); const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.run params: missing id",
),
);
return;
}
const result = await context.cron.run(jobId, p.mode);
respond(true, result, undefined); respond(true, result, undefined);
}, },
"cron.runs": async ({ params, respond, context }) => { "cron.runs": async ({ params, respond, context }) => {
@@ -164,14 +201,26 @@ export const cronHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const p = params as { id: string; limit?: number }; const p = params as { id?: string; jobId?: string; limit?: number };
const jobId = p.id ?? p.jobId;
if (!jobId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.runs params: missing id",
),
);
return;
}
const logPath = resolveCronRunLogPath({ const logPath = resolveCronRunLogPath({
storePath: context.cronStorePath, storePath: context.cronStorePath,
jobId: p.id, jobId,
}); });
const entries = await readCronRunLogEntries(logPath, { const entries = await readCronRunLogEntries(logPath, {
limit: p.limit, limit: p.limit,
jobId: p.id, jobId,
}); });
respond(true, { entries }, undefined); respond(true, { entries }, undefined);
}, },

View File

@@ -180,6 +180,47 @@ describe("gateway server cron", () => {
testState.cronStorePath = undefined; testState.cronStorePath = undefined;
}); });
test("accepts jobId for cron.update", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "jobId test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const atMs = Date.now() + 2_000;
const updateRes = await rpcReq(ws, "cron.update", {
jobId,
patch: {
schedule: { atMs },
payload: { text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("writes cron run history to runs/<jobId>.jsonl", async () => { test("writes cron run history to runs/<jobId>.jsonl", async () => {
const dir = await fs.mkdtemp( const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-log-"), path.join(os.tmpdir(), "clawdbot-gw-cron-log-"),