mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
feat(cron): add direct job lookup
Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
committed by
Peter Steinberger
parent
dc83a10733
commit
380a679313
@@ -27,6 +27,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
<Step title="Check your jobs">
|
||||
```bash
|
||||
openclaw cron list
|
||||
openclaw cron get <job-id>
|
||||
openclaw cron show <job-id>
|
||||
```
|
||||
</Step>
|
||||
@@ -359,6 +360,9 @@ When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `
|
||||
# List all jobs
|
||||
openclaw cron list
|
||||
|
||||
# Get one stored job as JSON
|
||||
openclaw cron get <jobId>
|
||||
|
||||
# Show one job, including resolved delivery route
|
||||
openclaw cron show <jobId>
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ Manual run and inspection:
|
||||
```bash
|
||||
openclaw cron list
|
||||
openclaw cron list --agent ops
|
||||
openclaw cron get <job-id>
|
||||
openclaw cron show <job-id>
|
||||
openclaw cron run <job-id>
|
||||
openclaw cron run <job-id> --due
|
||||
@@ -228,6 +229,8 @@ openclaw cron runs --id <job-id> --limit 50
|
||||
|
||||
`openclaw cron list` shows all matching jobs by default. Pass `--agent <id>` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent.
|
||||
|
||||
`openclaw cron get <job-id>` returns the stored job JSON directly. Use `cron show <job-id>` when you want the human-readable view with delivery-route preview.
|
||||
|
||||
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it.
|
||||
|
||||
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
|
||||
|
||||
@@ -256,6 +256,7 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
cron
|
||||
status
|
||||
list
|
||||
get
|
||||
add
|
||||
edit
|
||||
rm
|
||||
|
||||
@@ -467,7 +467,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Automation, skills, and tools">
|
||||
- Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
|
||||
- Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
|
||||
- Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("cron tool", () => {
|
||||
action: "remove",
|
||||
jobId: "job-other",
|
||||
}),
|
||||
).rejects.toThrow("Cron tool is restricted to removing the current cron job.");
|
||||
).rejects.toThrow("Cron tool is restricted to the current cron job.");
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -233,7 +233,7 @@ describe("cron tool", () => {
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await expect(tool.execute("call-runs-denied", args)).rejects.toThrow(
|
||||
"Cron tool is restricted to removing the current cron job.",
|
||||
"Cron tool is restricted to the current cron job.",
|
||||
);
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
@@ -258,6 +258,33 @@ describe("cron tool", () => {
|
||||
expect(result.details).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("allows scoped isolated cron runs to get the current job", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" });
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
const result = await tool.execute("call-get", {
|
||||
action: "get",
|
||||
jobId: "job-current",
|
||||
});
|
||||
|
||||
const params = expectSingleGatewayCallMethod("cron.get");
|
||||
expect(params).toStrictEqual({ id: "job-current" });
|
||||
expect(result.details).toEqual({ id: "job-current", name: "current" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another job", { action: "get", jobId: "job-other" }],
|
||||
["missing job id", { action: "get" }],
|
||||
])("denies scoped isolated cron runs from getting %s", async (_label, args) => {
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await expect(tool.execute("call-get-denied", args)).rejects.toThrow(
|
||||
"Cron tool is restricted to the current cron job.",
|
||||
);
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows scoped isolated cron runs to list only the current job", async () => {
|
||||
callGatewayMock.mockResolvedValueOnce({
|
||||
jobs: [
|
||||
@@ -366,7 +393,7 @@ describe("cron tool", () => {
|
||||
const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" });
|
||||
|
||||
await expect(tool.execute("call-denied", args)).rejects.toThrow(
|
||||
"Cron tool is restricted to removing the current cron job.",
|
||||
"Cron tool is restricted to the current cron job.",
|
||||
);
|
||||
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
@@ -437,6 +464,8 @@ describe("cron tool", () => {
|
||||
["remove", { action: "remove", id: "job-2" }, { id: "job-2" }],
|
||||
["run", { action: "run", jobId: "job-1" }, { id: "job-1", mode: "force" }],
|
||||
["run", { action: "run", id: "job-2" }, { id: "job-2", mode: "force" }],
|
||||
["get", { action: "get", jobId: "job-1" }, { id: "job-1" }],
|
||||
["get", { action: "get", id: "job-2" }, { id: "job-2" }],
|
||||
["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }],
|
||||
["runs", { action: "runs", id: "job-2" }, { id: "job-2" }],
|
||||
])("%s sends id to gateway", async (action, args, expectedParams) => {
|
||||
|
||||
@@ -29,7 +29,17 @@ import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-h
|
||||
// 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_ACTIONS = [
|
||||
"status",
|
||||
"list",
|
||||
"get",
|
||||
"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;
|
||||
@@ -315,7 +325,6 @@ export const CronToolSchema = Type.Object(
|
||||
type CronToolOptions = {
|
||||
agentSessionKey?: string;
|
||||
currentDeliveryContext?: DeliveryContext;
|
||||
/** Restrict this cron tool instance to removing only this active cron job. */
|
||||
selfRemoveOnlyJobId?: string;
|
||||
};
|
||||
|
||||
@@ -350,7 +359,7 @@ function readCronJobIdParam(params: Record<string, unknown>) {
|
||||
return readStringParam(params, "jobId") ?? readStringParam(params, "id");
|
||||
}
|
||||
|
||||
const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to removing the current cron job.";
|
||||
const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to the current cron job.";
|
||||
|
||||
function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) {
|
||||
return opts?.selfRemoveOnlyJobId?.trim() || undefined;
|
||||
@@ -369,7 +378,7 @@ function assertCronSelfRemoveScope(
|
||||
if (!selfRemoveOnlyJobId || isCronSelfIntrospectionAction(action)) {
|
||||
return;
|
||||
}
|
||||
if (action === "remove" || action === "runs") {
|
||||
if (action === "get" || action === "remove" || action === "runs") {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (id && id === selfRemoveOnlyJobId) {
|
||||
return;
|
||||
@@ -623,13 +632,14 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
|
||||
name: "cron",
|
||||
ownerOnly: isOpenClawOwnerOnlyCoreToolName("cron"),
|
||||
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
|
||||
description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.
|
||||
description: `Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.
|
||||
|
||||
Main-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in \`openclaw tasks\`.
|
||||
|
||||
ACTIONS:
|
||||
- status: Check cron scheduler status
|
||||
- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)
|
||||
- get: Get one job by id (requires jobId)
|
||||
- add: Create job (requires job object, see schema below)
|
||||
- update: Modify job (requires jobId + patch object)
|
||||
- remove: Delete job (requires jobId)
|
||||
@@ -692,7 +702,7 @@ CRITICAL CONSTRAINTS:
|
||||
Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.
|
||||
|
||||
RESTRICTED CRON RUNS:
|
||||
- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.
|
||||
- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.
|
||||
|
||||
WAKE MODES (for wake action):
|
||||
- "next-heartbeat" (default): Wake on next heartbeat
|
||||
@@ -756,6 +766,13 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
|
||||
selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result,
|
||||
);
|
||||
}
|
||||
case "get": {
|
||||
const id = readCronJobIdParam(params);
|
||||
if (!id) {
|
||||
throw new Error("jobId required (id accepted for backward compatibility)");
|
||||
}
|
||||
return jsonResult(await callGateway("cron.get", gatewayOpts, { id }));
|
||||
}
|
||||
case "add": {
|
||||
// Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten
|
||||
// job properties to the top level alongside `action` instead of nesting
|
||||
|
||||
@@ -513,6 +513,14 @@ describe("cron cli", () => {
|
||||
expect(listCall?.[2]).toEqual({ includeDisabled: false, agentId: "ops" });
|
||||
});
|
||||
|
||||
it("routes cron get to cron.get with the provided id", async () => {
|
||||
await runCronCommand(["cron", "get", "job-1"]);
|
||||
|
||||
const getCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.get");
|
||||
expect(getCall?.[2]).toEqual({ id: "job-1" });
|
||||
expect(stdoutText()).toContain('"id": "job-1"');
|
||||
});
|
||||
|
||||
it("paginates cron show lookups", async () => {
|
||||
resetGatewayMock();
|
||||
callGatewayFromCli.mockImplementation(
|
||||
|
||||
@@ -110,6 +110,21 @@ export function registerCronSimpleCommands(cron: Command) {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("get")
|
||||
.description("Get a cron job as JSON")
|
||||
.argument("<id>", "Job id")
|
||||
.action(async (id, opts) => {
|
||||
try {
|
||||
const res = await callGatewayFromCli("cron.get", opts, { id: String(id) });
|
||||
printCronJson(res);
|
||||
} catch (err) {
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
.command("show")
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface CronServiceContract {
|
||||
run(id: string, mode?: CronRunMode): Promise<CronServiceRunResult>;
|
||||
enqueueRun(id: string, mode?: CronRunMode): Promise<CronServiceRunResult>;
|
||||
getJob(id: string): CronJob | undefined;
|
||||
readJob(id: string): Promise<CronJob | undefined>;
|
||||
getDefaultAgentId(): string | undefined;
|
||||
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ const logger = createNoopLogger();
|
||||
const { makeStorePath } = createCronStoreHarness({ prefix: "openclaw-cron-get-job-" });
|
||||
installCronTestHooks({ logger });
|
||||
|
||||
function createCronService(storePath: string) {
|
||||
function createCronService(storePath: string, cronEnabled = true) {
|
||||
return new CronService({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
cronEnabled,
|
||||
log: logger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeat: vi.fn(),
|
||||
@@ -38,7 +38,8 @@ describe("CronService.getJob", () => {
|
||||
});
|
||||
|
||||
expect(cron.getJob(added.id)?.id).toBe(added.id);
|
||||
expect(cron.getJob("missing-job-id")).toBeUndefined();
|
||||
await expect(cron.readJob(added.id)).resolves.toEqual(added);
|
||||
await expect(cron.readJob("missing-job-id")).resolves.toBeUndefined();
|
||||
} finally {
|
||||
cron.stop();
|
||||
}
|
||||
@@ -59,6 +60,12 @@ describe("CronService.getJob", () => {
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
});
|
||||
await expect(cron.readJob(webhookJob.id)).resolves.toMatchObject({
|
||||
delivery: {
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
},
|
||||
});
|
||||
expect(cron.getJob(webhookJob.id)?.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
@@ -67,4 +74,27 @@ describe("CronService.getJob", () => {
|
||||
cron.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads persisted jobs for direct reads without starting the scheduler", async () => {
|
||||
const { storePath } = await makeStorePath();
|
||||
const writer = createCronService(storePath);
|
||||
await writer.start();
|
||||
const persisted = await writer.add({
|
||||
name: "persisted-job",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "ping" },
|
||||
});
|
||||
writer.stop();
|
||||
|
||||
const reader = createCronService(storePath, false);
|
||||
|
||||
await expect(reader.readJob(persisted.id)).resolves.toMatchObject({
|
||||
id: persisted.id,
|
||||
name: "persisted-job",
|
||||
});
|
||||
expect(reader.getJob(persisted.id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,10 @@ export class CronService implements CronServiceContract {
|
||||
return this.state.store?.jobs.find((job) => job.id === id);
|
||||
}
|
||||
|
||||
async readJob(id: string): Promise<CronJob | undefined> {
|
||||
return await ops.readJob(this.state, id);
|
||||
}
|
||||
|
||||
getDefaultAgentId(): string | undefined {
|
||||
return this.state.deps.defaultAgentId;
|
||||
}
|
||||
|
||||
@@ -236,6 +236,13 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b
|
||||
});
|
||||
}
|
||||
|
||||
export async function readJob(state: CronServiceState, id: string) {
|
||||
return await locked(state, async () => {
|
||||
await ensureLoadedForRead(state);
|
||||
return state.store?.jobs.find((job) => job.id === id);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter {
|
||||
if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") {
|
||||
return opts.enabled;
|
||||
|
||||
@@ -113,6 +113,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"sessions.usage",
|
||||
"sessions.usage.timeseries",
|
||||
"sessions.usage.logs",
|
||||
"cron.get",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.runs",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
validateCronAddParams,
|
||||
validateCronGetParams,
|
||||
validateCronListParams,
|
||||
validateCronRemoveParams,
|
||||
validateCronRunParams,
|
||||
@@ -54,6 +55,13 @@ describe("cron protocol validators", () => {
|
||||
expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts get params for id and jobId selectors", () => {
|
||||
expect(validateCronGetParams({ id: "job-1" })).toBe(true);
|
||||
expect(validateCronGetParams({ jobId: "job-2" })).toBe(true);
|
||||
expect(validateCronGetParams({})).toBe(false);
|
||||
expect(validateCronGetParams({ id: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts delivery threadId on add and update params", () => {
|
||||
expect(
|
||||
validateCronAddParams({
|
||||
|
||||
@@ -146,6 +146,8 @@ import {
|
||||
ConnectParamsSchema,
|
||||
type CronAddParams,
|
||||
CronAddParamsSchema,
|
||||
type CronGetParams,
|
||||
CronGetParamsSchema,
|
||||
type CronJob,
|
||||
CronJobSchema,
|
||||
type CronListParams,
|
||||
@@ -671,6 +673,7 @@ export const validateSkillsSearchParams = ajv.compile<SkillsSearchParams>(Skills
|
||||
export const validateSkillsDetailParams = ajv.compile<SkillsDetailParams>(SkillsDetailParamsSchema);
|
||||
export const validateCronListParams = ajv.compile<CronListParams>(CronListParamsSchema);
|
||||
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
|
||||
export const validateCronGetParams = ajv.compile<CronGetParams>(CronGetParamsSchema);
|
||||
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
|
||||
export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdateParamsSchema);
|
||||
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
||||
@@ -940,6 +943,7 @@ export {
|
||||
CronJobSchema,
|
||||
CronListParamsSchema,
|
||||
CronStatusParamsSchema,
|
||||
CronGetParamsSchema,
|
||||
CronAddParamsSchema,
|
||||
CronUpdateParamsSchema,
|
||||
CronRemoveParamsSchema,
|
||||
@@ -1122,6 +1126,7 @@ export type {
|
||||
CronJob,
|
||||
CronListParams,
|
||||
CronStatusParams,
|
||||
CronGetParams,
|
||||
CronAddParams,
|
||||
CronUpdateParams,
|
||||
CronRemoveParams,
|
||||
|
||||
@@ -351,6 +351,8 @@ export const CronListParamsSchema = Type.Object(
|
||||
|
||||
export const CronStatusParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
export const CronGetParamsSchema = cronIdOrJobIdParams({});
|
||||
|
||||
export const CronAddParamsSchema = Type.Object(
|
||||
{
|
||||
name: NonEmptyString,
|
||||
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
} from "./config.js";
|
||||
import {
|
||||
CronAddParamsSchema,
|
||||
CronGetParamsSchema,
|
||||
CronJobSchema,
|
||||
CronListParamsSchema,
|
||||
CronRemoveParamsSchema,
|
||||
@@ -454,6 +455,7 @@ export const ProtocolSchemas = {
|
||||
CronJob: CronJobSchema,
|
||||
CronListParams: CronListParamsSchema,
|
||||
CronStatusParams: CronStatusParamsSchema,
|
||||
CronGetParams: CronGetParamsSchema,
|
||||
CronAddParams: CronAddParamsSchema,
|
||||
CronUpdateParams: CronUpdateParamsSchema,
|
||||
CronRemoveParams: CronRemoveParamsSchema,
|
||||
|
||||
@@ -190,6 +190,7 @@ export type SkillsUpdateParams = SchemaType<"SkillsUpdateParams">;
|
||||
export type CronJob = SchemaType<"CronJob">;
|
||||
export type CronListParams = SchemaType<"CronListParams">;
|
||||
export type CronStatusParams = SchemaType<"CronStatusParams">;
|
||||
export type CronGetParams = SchemaType<"CronGetParams">;
|
||||
export type CronAddParams = SchemaType<"CronAddParams">;
|
||||
export type CronUpdateParams = SchemaType<"CronUpdateParams">;
|
||||
export type CronRemoveParams = SchemaType<"CronRemoveParams">;
|
||||
|
||||
@@ -43,6 +43,17 @@ describe("createLazyGatewayCronState", () => {
|
||||
expect(cron.status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads the cron service for direct job reads", async () => {
|
||||
const cron = createCronService();
|
||||
hoisted.setState(createCronState(cron));
|
||||
|
||||
const lazy = createLazyGatewayCronState(createParams());
|
||||
await lazy.cron.readJob("demo");
|
||||
|
||||
expect(hoisted.buildGatewayCronService).toHaveBeenCalledTimes(1);
|
||||
expect(cron.readJob).toHaveBeenCalledWith("demo");
|
||||
});
|
||||
|
||||
it("starts the loaded cron service once", async () => {
|
||||
const cron = createCronService();
|
||||
hoisted.setState(createCronState(cron));
|
||||
@@ -140,6 +151,7 @@ function createCronService(): CronServiceContract {
|
||||
run: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never),
|
||||
enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "invalid-spec" }) as never),
|
||||
getJob: vi.fn(() => undefined),
|
||||
readJob: vi.fn(async () => undefined),
|
||||
getDefaultAgentId: vi.fn(() => "default"),
|
||||
wake: vi.fn(() => ({ ok: true })),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,9 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew
|
||||
}
|
||||
return loaded.state.cron.getJob(id);
|
||||
},
|
||||
async readJob(id) {
|
||||
return await (await load()).state.cron.readJob(id);
|
||||
},
|
||||
getDefaultAgentId() {
|
||||
if (!loaded) {
|
||||
return undefined;
|
||||
|
||||
@@ -156,6 +156,7 @@ const BASE_METHODS = [
|
||||
"node.pending.ack",
|
||||
"node.invoke.result",
|
||||
"node.event",
|
||||
"cron.get",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.add",
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateCronAddParams,
|
||||
validateCronGetParams,
|
||||
validateCronListParams,
|
||||
validateCronRemoveParams,
|
||||
validateCronRunParams,
|
||||
@@ -248,6 +249,39 @@ export const cronHandlers: GatewayRequestHandlers = {
|
||||
const status = await context.cron.status();
|
||||
respond(true, status, undefined);
|
||||
},
|
||||
"cron.get": async ({ params, respond, context }) => {
|
||||
if (!validateCronGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.get params: ${formatValidationErrors(validateCronGetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as { id?: string; jobId?: string };
|
||||
const jobId = p.id ?? p.jobId;
|
||||
if (!jobId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.get params: missing id"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const job = await context.cron.readJob(jobId);
|
||||
if (!job) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `cron job not found: ${jobId}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, job, undefined);
|
||||
},
|
||||
"cron.add": async ({ params, respond, context }) => {
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string"
|
||||
|
||||
@@ -78,6 +78,7 @@ function createCronContext(currentJob?: CronJob) {
|
||||
getDefaultAgentId: vi.fn(() => "main"),
|
||||
getJob: vi.fn(() => currentJob),
|
||||
wake: vi.fn(() => ({ ok: true }) as const),
|
||||
readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)),
|
||||
},
|
||||
logGateway: {
|
||||
info: vi.fn(),
|
||||
@@ -100,6 +101,20 @@ async function invokeCronAdd(params: Record<string, unknown>) {
|
||||
return { context, respond };
|
||||
}
|
||||
|
||||
async function invokeCronGet(params: Record<string, unknown>, currentJob?: CronJob) {
|
||||
const context = createCronContext(currentJob);
|
||||
const respond = vi.fn();
|
||||
await cronHandlers["cron.get"]({
|
||||
req: {} as never,
|
||||
params: params as never,
|
||||
respond: respond as never,
|
||||
context: context as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return { context, respond };
|
||||
}
|
||||
|
||||
async function invokeCronUpdate(params: Record<string, unknown>, currentJob: CronJob) {
|
||||
const context = createCronContext(currentJob);
|
||||
const respond = vi.fn();
|
||||
@@ -231,6 +246,24 @@ describe("cron method validation", () => {
|
||||
expect(respond).toHaveBeenCalledWith(true, { id: "cron-1" }, undefined);
|
||||
});
|
||||
|
||||
it("returns a single cron job for cron.get", async () => {
|
||||
const job = createCronJob({ id: "cron-42", name: "single job" });
|
||||
|
||||
const { context, respond } = await invokeCronGet({ id: "cron-42" }, job);
|
||||
|
||||
expect(context.cron.readJob).toHaveBeenCalledWith("cron-42");
|
||||
expect(respond).toHaveBeenCalledWith(true, job, undefined);
|
||||
});
|
||||
|
||||
it("returns INVALID_REQUEST when cron.get cannot find the job", async () => {
|
||||
const { respond } = await invokeCronGet({ jobId: "missing" });
|
||||
|
||||
expectResponseError(respond, {
|
||||
code: "INVALID_REQUEST",
|
||||
messageIncludes: "cron job not found: missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts threadId on announce delivery update params", async () => {
|
||||
getRuntimeConfig.mockReturnValue({
|
||||
channels: {
|
||||
|
||||
@@ -469,6 +469,16 @@ describe("gateway server cron", () => {
|
||||
detail: "webhook",
|
||||
});
|
||||
|
||||
const getRes = await directCronReq(cronState, "cron.get", { id: String(dailyJobId) });
|
||||
expect(getRes.ok).toBe(true);
|
||||
expect((getRes.payload as { id?: unknown } | null)?.id).toBe(dailyJobId);
|
||||
expect((getRes.payload as { name?: unknown } | null)?.name).toBe("daily");
|
||||
|
||||
const missingGetRes = await directCronReq(cronState, "cron.get", { id: "missing-job-id" });
|
||||
expect(missingGetRes.ok).toBe(false);
|
||||
expect(missingGetRes.error?.code).toBe("INVALID_REQUEST");
|
||||
expect(missingGetRes.error?.message).toContain("cron job not found: missing-job-id");
|
||||
|
||||
const routeAtMs = Date.now() - 1;
|
||||
const routeRes = await directCronReq(cronState, "cron.add", {
|
||||
name: "route test",
|
||||
|
||||
@@ -101,6 +101,7 @@ function createMockCronService(): CronServiceContract {
|
||||
run: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })),
|
||||
enqueueRun: vi.fn(async () => ({ ok: true, ran: false, reason: "not-due" })),
|
||||
getJob: vi.fn(() => undefined),
|
||||
readJob: vi.fn(async () => undefined),
|
||||
getDefaultAgentId: vi.fn(() => undefined),
|
||||
wake: vi.fn(() => ({ ok: true })),
|
||||
} as CronServiceContract;
|
||||
|
||||
@@ -137,12 +137,12 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"type": "string"
|
||||
},
|
||||
"agentId": {
|
||||
|
||||
@@ -137,12 +137,12 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"type": "string"
|
||||
},
|
||||
"agentId": {
|
||||
|
||||
@@ -137,12 +137,12 @@
|
||||
},
|
||||
{
|
||||
"deferLoading": true,
|
||||
"description": "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, runs (job run history) is allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"description": "Manage Gateway cron jobs (status/list/get/add/update/remove/run/runs) and send wake events. Use this for reminders, \"check back later\" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nMain-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in `openclaw tasks`.\n\nACTIONS:\n- status: Check cron scheduler status\n- list: List jobs (use includeDisabled:true to include disabled; agentId filters by agent, auto-filled from session)\n- get: Get one job by id (requires jobId)\n- add: Create job (requires job object, see schema below)\n- update: Modify job (requires jobId + patch object)\n- remove: Delete job (requires jobId)\n- run: Trigger job immediately (requires jobId)\n- runs: Get job run history (requires jobId)\n- wake: Send wake event (requires text, optional mode)\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string (optional)\",\n \"schedule\": { ... }, // Required: when to run\n \"payload\": { ... }, // Required: what to execute\n \"delivery\": { ... }, // Optional: announce summary (isolated/current/session:xxx only) or webhook POST\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<custom-id>\", // Optional, defaults based on context\n \"enabled\": true | false // Optional, default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": Run in the main session (requires payload.kind=\"systemEvent\")\n- \"isolated\": Run in an ephemeral isolated session (requires payload.kind=\"agentTurn\")\n- \"current\": Bind to the current session where the cron is created (resolved at creation time)\n- \"session:<custom-id>\": Run in a persistent named session (e.g., \"session:project-alpha-daily\")\n\nDEFAULT BEHAVIOR (unchanged for backward compatibility):\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nTo use current session binding, explicitly set sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": One-shot at absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": Recurring interval\n { \"kind\": \"every\", \"everyMs\": <interval-ms>, \"anchorMs\": <optional-start-ms> }\n- \"cron\": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first.\n If tz is omitted, do not assume UTC; the Gateway host local timezone is used.\n Example: \"Remind me every day at 6pm Shanghai time\" -> { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor schedule.kind=\"at\", ISO timestamps without an explicit timezone are treated as UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": Injects text as system event into session\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": Runs agent with message (isolated sessions only)\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0 means no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - Default for isolated agentTurn jobs (when delivery omitted): \"announce\"\n - announce: send to chat channel (optional channel/to target)\n - threadId: chat thread/topic id for channels that support threaded delivery\n - webhook: send finished-run event as HTTP POST to delivery.to (URL required)\n - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- For webhook callbacks, use delivery.mode=\"webhook\" with delivery.to set to a URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs receive a narrow cron grant for self-cleanup. In that mode, read-only status and list are for self-introspection only, get/runs are allowed for the current job only, and mutation actions remain limited to removing the current cron job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" (default): Wake on next heartbeat\n- \"now\": Wake immediately\n\nUse jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"action": {
|
||||
"enum": ["status", "list", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"enum": ["status", "list", "get", "add", "update", "remove", "run", "runs", "wake"],
|
||||
"type": "string"
|
||||
},
|
||||
"agentId": {
|
||||
|
||||
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 140
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 43000,
|
||||
"roughTokens": 10750
|
||||
"chars": 43053,
|
||||
"roughTokens": 10764
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 5436,
|
||||
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 7129
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 71518,
|
||||
"roughTokens": 17880
|
||||
"chars": 71571,
|
||||
"roughTokens": 17893
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 870,
|
||||
|
||||
@@ -217,8 +217,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 140
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 42691,
|
||||
"roughTokens": 10673
|
||||
"chars": 42744,
|
||||
"roughTokens": 10686
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 4412,
|
||||
@@ -229,8 +229,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 6748
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 69685,
|
||||
"roughTokens": 17422
|
||||
"chars": 69738,
|
||||
"roughTokens": 17435
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 370,
|
||||
|
||||
@@ -218,8 +218,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 140
|
||||
},
|
||||
"dynamicToolsJson": {
|
||||
"chars": 43869,
|
||||
"roughTokens": 10968
|
||||
"chars": 43922,
|
||||
"roughTokens": 10981
|
||||
},
|
||||
"openClawDeveloperInstructions": {
|
||||
"chars": 4412,
|
||||
@@ -230,8 +230,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
|
||||
"roughTokens": 7155
|
||||
},
|
||||
"totalWithDynamicToolsJson": {
|
||||
"chars": 72490,
|
||||
"roughTokens": 18123
|
||||
"chars": 72543,
|
||||
"roughTokens": 18136
|
||||
},
|
||||
"userInputText": {
|
||||
"chars": 608,
|
||||
|
||||
Reference in New Issue
Block a user