fix(cron-tool): add typed properties to job/patch schemas (#55043)

Merged via squash.

Prepared head SHA: 979bb0e8b7
Co-authored-by: brunolorente <127802443+brunolorente@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
This commit is contained in:
Bruno Lorente
2026-04-01 22:41:19 +02:00
committed by GitHub
parent 7027dda8cd
commit ca76e2fedc
10 changed files with 1260 additions and 74 deletions

View File

@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import { CronToolSchema } from "./cron-tool.js";
/** Walk a TypeBox schema by dot-separated property path and return sorted keys. */
function keysAt(schema: Record<string, unknown>, path: string): string[] {
let cursor: Record<string, unknown> | undefined = schema;
for (const segment of path.split(".")) {
const props = cursor?.["properties"] as Record<string, Record<string, unknown>> | undefined;
cursor = props?.[segment];
}
const leaf = cursor?.["properties"] as Record<string, unknown> | undefined;
return leaf ? Object.keys(leaf).toSorted() : [];
}
function propertyAt(
schema: Record<string, unknown>,
path: string,
): Record<string, unknown> | undefined {
let cursor: Record<string, unknown> | undefined = schema;
for (const segment of path.split(".")) {
const props = cursor?.["properties"] as Record<string, Record<string, unknown>> | undefined;
cursor = props?.[segment];
}
return cursor;
}
describe("CronToolSchema", () => {
// Regression: models like GPT-5.4 rely on these fields to populate job/patch.
// If a field is removed from this list the test must be updated intentionally.
it("job exposes the expected top-level fields", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job")).toEqual(
[
"agentId",
"deleteAfterRun",
"delivery",
"description",
"enabled",
"failureAlert",
"name",
"payload",
"schedule",
"sessionKey",
"sessionTarget",
"wakeMode",
].toSorted(),
);
});
it("patch exposes the expected top-level fields", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "patch")).toEqual(
[
"agentId",
"deleteAfterRun",
"delivery",
"description",
"enabled",
"failureAlert",
"name",
"payload",
"schedule",
"sessionKey",
"sessionTarget",
"wakeMode",
].toSorted(),
);
});
it("job.schedule exposes kind, at, everyMs, anchorMs, expr, tz, staggerMs", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.schedule")).toEqual(
["anchorMs", "at", "everyMs", "expr", "kind", "staggerMs", "tz"].toSorted(),
);
});
it("marks staggerMs as cron-only in both job and patch schedule schemas", () => {
const jobStagger = propertyAt(
CronToolSchema as Record<string, unknown>,
"job.schedule.staggerMs",
);
const patchStagger = propertyAt(
CronToolSchema as Record<string, unknown>,
"patch.schedule.staggerMs",
);
expect(jobStagger?.description).toBe("Random jitter in ms (kind=cron)");
expect(patchStagger?.description).toBe("Random jitter in ms (kind=cron)");
});
it("job.delivery exposes mode, channel, to, bestEffort, accountId, failureDestination", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.delivery")).toEqual(
["accountId", "bestEffort", "channel", "failureDestination", "mode", "to"].toSorted(),
);
});
it("job.payload exposes kind, text, message, model, thinking and extras", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.payload")).toEqual(
[
"allowUnsafeExternalContent",
"fallbacks",
"kind",
"lightContext",
"message",
"model",
"text",
"thinking",
"toolsAllow",
"timeoutSeconds",
].toSorted(),
);
});
it("job.payload includes fallbacks", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.payload")).toContain("fallbacks");
});
it("patch.payload exposes agentTurn fallback overrides", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "patch.payload")).toEqual(
[
"allowUnsafeExternalContent",
"fallbacks",
"kind",
"lightContext",
"message",
"model",
"text",
"thinking",
"toolsAllow",
"timeoutSeconds",
].toSorted(),
);
});
it("job.failureAlert exposes after, channel, to, cooldownMs, mode, accountId", () => {
expect(keysAt(CronToolSchema as Record<string, unknown>, "job.failureAlert")).toEqual(
["accountId", "after", "channel", "cooldownMs", "mode", "to"].toSorted(),
);
});
it("job.failureAlert also allows boolean false", () => {
const root = (CronToolSchema as Record<string, unknown>).properties as
| Record<string, { properties?: Record<string, unknown>; type?: unknown }>
| undefined;
const jobProps = root?.job?.properties as
| Record<string, { type?: unknown; not?: { const?: unknown } }>
| undefined;
const schema = jobProps?.failureAlert;
expect(schema?.type).toEqual(["object", "boolean"]);
expect(schema?.not?.const).toBe(true);
});
it("job.agentId and job.sessionKey accept null for clear/keep-unset flows", () => {
const root = (CronToolSchema as Record<string, unknown>).properties as
| Record<string, { properties?: Record<string, unknown> }>
| undefined;
const jobProps = root?.job?.properties as Record<string, { type?: unknown }> | undefined;
expect(jobProps?.agentId?.type).toEqual(["string", "null"]);
expect(jobProps?.sessionKey?.type).toEqual(["string", "null"]);
});
it("patch.payload.toolsAllow accepts null for clear flows", () => {
const root = (CronToolSchema as Record<string, unknown>).properties as
| Record<string, { properties?: Record<string, unknown> }>
| undefined;
const patchProps = root?.patch?.properties as
| Record<string, { properties?: Record<string, { type?: unknown }> }>
| undefined;
expect(patchProps?.payload?.properties?.toolsAllow?.type).toEqual(["array", "null"]);
});
});

View File

@@ -206,6 +206,59 @@ describe("cron tool", () => {
expect(call?.params?.agentId).toBeNull();
});
it("passes through failureAlert=false for add", async () => {
const tool = createTestCronTool();
await tool.execute("call-disable-alerts-add", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
failureAlert: false,
},
});
const params = expectSingleGatewayCallMethod("cron.add") as
| { failureAlert?: unknown }
| undefined;
expect(params?.failureAlert).toBe(false);
});
it("recovers flattened add params for failureAlert and payload extras", async () => {
const tool = createTestCronTool();
await tool.execute("call-flat-add-extras", {
action: "add",
name: "reminder",
schedule: { at: new Date(123).toISOString() },
message: "hello",
lightContext: true,
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
toolsAllow: [" exec ", " read "],
failureAlert: { after: 3, cooldownMs: 60_000 },
});
const params = expectSingleGatewayCallMethod("cron.add") as
| {
payload?: {
kind?: string;
message?: string;
lightContext?: boolean;
fallbacks?: string[];
toolsAllow?: string[];
};
failureAlert?: { after?: number; cooldownMs?: number };
}
| undefined;
expect(params?.payload).toEqual({
kind: "agentTurn",
message: "hello",
lightContext: true,
fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"],
toolsAllow: ["exec", "read"],
});
expect(params?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
});
it("stamps cron.add with caller sessionKey when missing", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
@@ -465,6 +518,20 @@ describe("cron tool", () => {
expect(delivery).toEqual({ mode: "none" });
});
it("preserves explicit mode-less delivery objects for add", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const delivery = await executeAddAndReadDelivery({
callId: "call-implicit-announce",
agentSessionKey: "agent:main:discord:dm:buddy",
delivery: { channel: "telegram", to: "123" },
});
expect(delivery).toEqual({
channel: "telegram",
to: "123",
});
});
it("does not infer announce delivery when mode is webhook", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const delivery = await executeAddAndReadDelivery({
@@ -551,4 +618,216 @@ describe("cron tool", () => {
expect(params?.patch?.sessionTarget).toBe("main");
expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
});
it("passes through failureAlert=false for update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-disable-alerts", {
action: "update",
id: "job-4",
patch: { failureAlert: false },
});
const params = expectSingleGatewayCallMethod("cron.update") as
| { id?: string; patch?: { failureAlert?: unknown } }
| undefined;
expect(params?.id).toBe("job-4");
expect(params?.patch?.failureAlert).toBe(false);
});
it("recovers flattened payload patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat-payload", {
action: "update",
id: "job-3",
message: "run report",
model: " openrouter/deepseek/deepseek-r1 ",
thinking: " high ",
timeoutSeconds: 45,
lightContext: true,
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
message?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
lightContext?: boolean;
};
};
}
| undefined;
expect(params?.id).toBe("job-3");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
message: "run report",
model: "openrouter/deepseek/deepseek-r1",
thinking: "high",
timeoutSeconds: 45,
lightContext: true,
});
});
it("recovers flattened model-only payload patch params for update action", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-flat-model-only", {
action: "update",
id: "job-5",
model: " openrouter/deepseek/deepseek-r1 ",
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
toolsAllow: [" exec ", " read "],
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
model?: string;
fallbacks?: string[];
toolsAllow?: string[];
};
};
}
| undefined;
expect(params?.id).toBe("job-5");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
model: "openrouter/deepseek/deepseek-r1",
fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"],
toolsAllow: ["exec", "read"],
});
});
it("rejects malformed flattened fallback-only payload patch params for update action", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-update-flat-invalid-fallbacks", {
action: "update",
id: "job-9",
fallbacks: [123],
}),
).rejects.toThrow("patch required");
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("rejects malformed flattened toolsAllow-only payload patch params for update action", async () => {
const tool = createTestCronTool();
await expect(
tool.execute("call-update-flat-invalid-tools", {
action: "update",
id: "job-10",
toolsAllow: [123],
}),
).rejects.toThrow("patch required");
expect(callGatewayMock).toHaveBeenCalledTimes(0);
});
it("infers kind for nested fallback-only payload patches on update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-nested-fallbacks-only", {
action: "update",
id: "job-6",
patch: {
payload: {
fallbacks: [" openrouter/gpt-4.1-mini ", "anthropic/claude-haiku-3-5"],
},
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
fallbacks?: string[];
};
};
}
| undefined;
expect(params?.id).toBe("job-6");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
fallbacks: ["openrouter/gpt-4.1-mini", "anthropic/claude-haiku-3-5"],
});
});
it("infers kind for nested toolsAllow-only payload patches on update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-nested-tools-only", {
action: "update",
id: "job-7",
patch: {
payload: {
toolsAllow: [" exec ", " read "],
},
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
toolsAllow?: string[];
};
};
}
| undefined;
expect(params?.id).toBe("job-7");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["exec", "read"],
});
});
it("preserves null toolsAllow payload patches on update", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createTestCronTool();
await tool.execute("call-update-clear-tools", {
action: "update",
id: "job-8",
patch: {
payload: {
toolsAllow: null,
},
},
});
const params = expectSingleGatewayCallMethod("cron.update") as
| {
id?: string;
patch?: {
payload?: {
kind?: string;
toolsAllow?: string[] | null;
};
};
}
| undefined;
expect(params?.id).toBe("job-8");
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: null,
});
});
});

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type, type TSchema } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import type { CronDelivery, CronMessageChannel } from "../../cron/types.js";
@@ -12,33 +12,252 @@ import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
// contain nested unions. Tool schemas need to stay provider-friendly, so we
// accept "any object" here and validate at runtime.
// We spell out job/patch properties so that LLMs know what fields to send.
// Nested unions are avoided; runtime validation happens in normalizeCronJob*.
const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const;
const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn"] as const;
const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const;
const CRON_RUN_MODES = ["due", "force"] as const;
const CRON_FLAT_PAYLOAD_KEYS = [
"message",
"text",
"model",
"fallbacks",
"toolsAllow",
"thinking",
"timeoutSeconds",
"lightContext",
"allowUnsafeExternalContent",
] as const;
const CRON_RECOVERABLE_OBJECT_KEYS: ReadonlySet<string> = new Set([
"name",
"schedule",
"sessionTarget",
"wakeMode",
"payload",
"delivery",
"enabled",
"description",
"deleteAfterRun",
"agentId",
"sessionKey",
"failureAlert",
...CRON_FLAT_PAYLOAD_KEYS,
]);
const REMINDER_CONTEXT_MESSAGES_MAX = 10;
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
const REMINDER_CONTEXT_TOTAL_MAX = 700;
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
function nullableStringSchema(description: string) {
return Type.Optional(
Type.Unsafe<string | null>({
type: ["string", "null"],
description,
}),
);
}
function nullableStringArraySchema(description: string) {
return Type.Optional(
Type.Unsafe<string[] | null>({
type: ["array", "null"],
items: { type: "string" },
description,
}),
);
}
function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) {
return Type.Object(
{
kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload type" }),
text: Type.Optional(Type.String({ description: "Message text (kind=systemEvent)" })),
message: Type.Optional(Type.String({ description: "Agent prompt (kind=agentTurn)" })),
model: Type.Optional(Type.String({ description: "Model override" })),
thinking: Type.Optional(Type.String({ description: "Thinking level override" })),
timeoutSeconds: Type.Optional(Type.Number()),
lightContext: Type.Optional(Type.Boolean()),
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
fallbacks: Type.Optional(Type.Array(Type.String(), { description: "Fallback model ids" })),
toolsAllow: params.toolsAllow,
},
{ additionalProperties: true },
);
}
const CronScheduleSchema = Type.Optional(
Type.Object(
{
kind: optionalStringEnum(CRON_SCHEDULE_KINDS, { description: "Schedule type" }),
at: Type.Optional(Type.String({ description: "ISO-8601 timestamp (kind=at)" })),
everyMs: Type.Optional(Type.Number({ description: "Interval in milliseconds (kind=every)" })),
anchorMs: Type.Optional(
Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }),
),
expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })),
tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })),
staggerMs: Type.Optional(Type.Number({ description: "Random jitter in ms (kind=cron)" })),
},
{ additionalProperties: true },
),
);
const CronPayloadSchema = Type.Optional(
cronPayloadObjectSchema({
toolsAllow: Type.Optional(Type.Array(Type.String(), { description: "Allowed tool ids" })),
}),
);
const CronDeliverySchema = Type.Optional(
Type.Object(
{
mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }),
channel: Type.Optional(Type.String({ description: "Delivery channel" })),
to: Type.Optional(Type.String({ description: "Delivery target" })),
bestEffort: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String({ description: "Account target for delivery" })),
failureDestination: Type.Optional(
Type.Object(
{
channel: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
mode: optionalStringEnum(["announce", "webhook"] as const),
},
{ additionalProperties: true },
),
),
},
{ additionalProperties: true },
),
);
// Keep `false` expressible without reintroducing anyOf/oneOf into the raw tool schema.
// Omitting `failureAlert` means "leave defaults/unchanged"; `false` explicitly disables alerts.
const CronFailureAlertSchema = Type.Optional(
Type.Unsafe<Record<string, unknown> | false>({
type: ["object", "boolean"],
not: { const: true },
properties: {
after: Type.Optional(Type.Number({ description: "Failures before alerting" })),
channel: Type.Optional(Type.String({ description: "Alert channel" })),
to: Type.Optional(Type.String({ description: "Alert target" })),
cooldownMs: Type.Optional(Type.Number({ description: "Cooldown between alerts in ms" })),
mode: optionalStringEnum(["announce", "webhook"] as const),
accountId: Type.Optional(Type.String()),
},
additionalProperties: true,
description: "Failure alert object, or false to disable alerts for this job",
}),
);
const CronJobObjectSchema = Type.Optional(
Type.Object(
{
name: Type.Optional(Type.String({ description: "Job name" })),
schedule: CronScheduleSchema,
sessionTarget: Type.Optional(
Type.String({
description: 'Session target: "main", "isolated", "current", or "session:<id>"',
}),
),
wakeMode: optionalStringEnum(CRON_WAKE_MODES, { description: "When to wake the session" }),
payload: CronPayloadSchema,
delivery: CronDeliverySchema,
agentId: nullableStringSchema("Agent id, or null to keep it unset"),
description: Type.Optional(Type.String({ description: "Human-readable description" })),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after first execution" })),
sessionKey: nullableStringSchema("Explicit session key, or null to clear it"),
failureAlert: CronFailureAlertSchema,
},
{ additionalProperties: true },
),
);
const CronPatchObjectSchema = Type.Optional(
Type.Object(
{
name: Type.Optional(Type.String({ description: "Job name" })),
schedule: Type.Optional(
Type.Object(
{
kind: optionalStringEnum(CRON_SCHEDULE_KINDS, { description: "Schedule type" }),
at: Type.Optional(Type.String({ description: "ISO-8601 timestamp (kind=at)" })),
everyMs: Type.Optional(
Type.Number({ description: "Interval in milliseconds (kind=every)" }),
),
anchorMs: Type.Optional(
Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }),
),
expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })),
tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })),
staggerMs: Type.Optional(
Type.Number({ description: "Random jitter in ms (kind=cron)" }),
),
},
{ additionalProperties: true },
),
),
sessionTarget: Type.Optional(Type.String({ description: "Session target" })),
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
payload: Type.Optional(
cronPayloadObjectSchema({
toolsAllow: nullableStringArraySchema("Allowed tool ids, or null to clear"),
}),
),
delivery: Type.Optional(
Type.Object(
{
mode: optionalStringEnum(CRON_DELIVERY_MODES, { description: "Delivery mode" }),
channel: Type.Optional(Type.String({ description: "Delivery channel" })),
to: Type.Optional(Type.String({ description: "Delivery target" })),
bestEffort: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String({ description: "Account target for delivery" })),
failureDestination: Type.Optional(
Type.Object(
{
channel: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
mode: optionalStringEnum(["announce", "webhook"] as const),
},
{ additionalProperties: true },
),
),
},
{ additionalProperties: true },
),
),
description: Type.Optional(Type.String()),
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
agentId: nullableStringSchema("Agent id, or null to clear it"),
sessionKey: nullableStringSchema("Explicit session key, or null to clear it"),
failureAlert: CronFailureAlertSchema,
},
{ additionalProperties: true },
),
);
// Flattened schema: runtime validates per-action requirements.
const CronToolSchema = Type.Object(
export const CronToolSchema = Type.Object(
{
action: stringEnum(CRON_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
job: Type.Optional(Type.Object({}, { additionalProperties: true })),
job: CronJobObjectSchema,
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
patch: CronPatchObjectSchema,
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
runMode: optionalStringEnum(CRON_RUN_MODES),
@@ -316,29 +535,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
params.job !== null &&
Object.keys(params.job as Record<string, unknown>).length === 0)
) {
const JOB_KEYS: ReadonlySet<string> = new Set([
"name",
"schedule",
"sessionTarget",
"wakeMode",
"payload",
"delivery",
"enabled",
"description",
"deleteAfterRun",
"agentId",
"sessionKey",
"message",
"text",
"model",
"thinking",
"timeoutSeconds",
"allowUnsafeExternalContent",
]);
const synthetic: Record<string, unknown> = {};
let found = false;
for (const key of Object.keys(params)) {
if (JOB_KEYS.has(key) && params[key] !== undefined) {
if (CRON_RECOVERABLE_OBJECT_KEYS.has(key) && params[key] !== undefined) {
synthetic[key] = params[key];
found = true;
}
@@ -457,37 +657,24 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
}
// Flat-params recovery for patch
let recoveredFlatPatch = false;
if (
!params.patch ||
(typeof params.patch === "object" &&
params.patch !== null &&
Object.keys(params.patch as Record<string, unknown>).length === 0)
) {
const PATCH_KEYS: ReadonlySet<string> = new Set([
"name",
"schedule",
"payload",
"delivery",
"enabled",
"description",
"deleteAfterRun",
"agentId",
"sessionKey",
"sessionTarget",
"wakeMode",
"failureAlert",
"allowUnsafeExternalContent",
]);
const synthetic: Record<string, unknown> = {};
let found = false;
for (const key of Object.keys(params)) {
if (PATCH_KEYS.has(key) && params[key] !== undefined) {
if (CRON_RECOVERABLE_OBJECT_KEYS.has(key) && params[key] !== undefined) {
synthetic[key] = params[key];
found = true;
}
}
if (found) {
params.patch = synthetic;
recoveredFlatPatch = true;
}
}
@@ -495,6 +682,14 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
throw new Error("patch required");
}
const patch = normalizeCronJobPatch(params.patch) ?? params.patch;
if (
recoveredFlatPatch &&
typeof patch === "object" &&
patch !== null &&
Object.keys(patch as Record<string, unknown>).length === 0
) {
throw new Error("patch required");
}
return jsonResult(
await callGateway("cron.update", gatewayOpts, {
id,