diff --git a/apps/android/app/src/main/assets/tool-display.json b/apps/android/app/src/main/assets/tool-display.json index b6a28f60f..9c0e57fc6 100644 --- a/apps/android/app/src/main/assets/tool-display.json +++ b/apps/android/app/src/main/assets/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index ea40e9303..093978ebb 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -323,6 +323,9 @@ struct CronJobEditor: View { Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text("telegram").tag(GatewayAgentChannel.telegram) Text("discord").tag(GatewayAgentChannel.discord) + Text("slack").tag(GatewayAgentChannel.slack) + Text("signal").tag(GatewayAgentChannel.signal) + Text("imessage").tag(GatewayAgentChannel.imessage) } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index ac8e6f1ff..d176be624 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -10,6 +10,9 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { case whatsapp case telegram case discord + case slack + case signal + case imessage case webchat init(raw: String?) { diff --git a/apps/shared/ClawdbotKit/Resources/tool-display.json b/apps/shared/ClawdbotKit/Resources/tool-display.json index b6a28f60f..9c0e57fc6 100644 --- a/apps/shared/ClawdbotKit/Resources/tool-display.json +++ b/apps/shared/ClawdbotKit/Resources/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/docs/cron.md b/docs/cron.md index dbc9a2bcd..374086e68 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -216,6 +216,17 @@ Retention: Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs). +## Compatibility policy (cron.add/cron.update) + +To keep older clients working, the Gateway applies **best-effort normalization** for `cron.add` and `cron.update`: +- Accepts wrapped payloads under `data` or `job` and unwraps them. +- Infers `schedule.kind` from `atMs`, `everyMs`, or `expr` if missing. +- Infers `payload.kind` from `text` (systemEvent) or `message` (agentTurn) if missing. +- Defaults `wakeMode` to `"next-heartbeat"` when omitted. +- Defaults `sessionTarget` based on payload kind (`systemEvent` → `"main"`, `agentTurn` → `"isolated"`). + +Normalization is **compat-only**. New clients should send the full schema (including `kind`, `sessionTarget`, and `wakeMode`) to avoid ambiguity. Unknown fields are still rejected by schema validation. + ## Gateway API New methods (names can be bikeshed; `cron.*` is suggested): @@ -264,7 +275,7 @@ Add a `cron` command group (all commands should also support `--json` where sens - `--wake now|next-heartbeat` - payload flags (choose one): - `--system-event ""` - - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to ]` + - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]` - `clawdbot cron edit ...` (patch-by-flags, non-interactive) - `clawdbot cron rm ` diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md new file mode 100644 index 000000000..f1c6fa6ea --- /dev/null +++ b/docs/plans/cron-add-hardening.md @@ -0,0 +1,72 @@ +--- +summary: "Harden cron.add input handling, align schemas, and improve cron UI/agent tooling" +owner: "clawdbot" +status: "complete" +last_updated: "2026-01-05" +--- + +# Cron Add Hardening & Schema Alignment + +## Context +Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron channel enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`). + +## Goals +- Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields. +- Align cron channel lists across gateway schema, cron types, CLI docs, and UI forms. +- Make agent cron tool schema explicit so the LLM produces correct job payloads. +- Fix the Control UI cron status job count display. +- Add tests to cover normalization and tool behavior. + +## Non-goals +- Change cron scheduling semantics or job execution behavior. +- Add new schedule kinds or cron expression parsing. +- Overhaul the UI/UX for cron beyond the necessary field fixes. + +## Findings (current gaps) +- `CronPayloadSchema` in gateway excludes `signal` + `imessage`, while TS types include them. +- Control UI CronStatus expects `jobCount`, but gateway returns `jobs`. +- Agent cron tool schema allows arbitrary `job` objects, enabling malformed inputs. +- Gateway strictly validates `cron.add` with no normalization, so wrapped payloads fail. + +## Proposed Approach +1. **Normalize** incoming `cron.add` payloads (unwrap `data`/`job`, infer `schedule.kind` and `payload.kind`, default `wakeMode` + `sessionTarget` when safe). +2. **Harden** the agent cron tool schema using the canonical gateway `CronAddParamsSchema` and normalize before sending to the gateway. +3. **Align** channel enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls. +4. **Test** normalization in gateway tests and tool behavior in agent tests. + +## Multi-phase Execution Plan + +### Phase 1 — Schema + type alignment +- [x] Expand gateway `CronPayloadSchema` channel enum to include `signal` and `imessage`. +- [x] Update CLI `--channel` descriptions to include `slack` (already supported by gateway). +- [x] Update UI Cron payload/channel union types to include all supported channels. +- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`). +- [x] Update cron UI channel select to include Discord/Slack/Signal/iMessage. +- [x] Update macOS CronJobEditor channel picker + enum to include Slack/Signal/iMessage. +- [x] Document cron compatibility normalization policy in `docs/cron.md`. + +### Phase 2 — Input normalization + tooling hardening +- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`). +- [x] Apply normalization in gateway `cron.add` (and patch normalization in `cron.update`). +- [x] Tighten agent cron tool schema to `CronAddParamsSchema` and normalize job/patch before sending. + +### Phase 3 — Tests +- [x] Add gateway test covering wrapped `cron.add` payload normalization. +- [x] Add cron tool test to assert normalization and defaulting for `cron.add`. +- [x] Add gateway test covering `cron.update` normalization. +- [x] Add UI + Swift conformance test for cron channels + status fields. + +### Phase 4 — Verification +- [x] Run tests (full suite executed via `pnpm test -- cron-tool`). + +## Rollout/Monitoring +- Watch gateway logs for reduced `cron.add` INVALID_REQUEST errors. +- Confirm Control UI cron status shows job count after refresh. +- If errors persist, extend normalization for additional common shapes (e.g., `schedule.at`, `payload.message` without `kind`). + +## Optional Follow-ups +- Manual Control UI smoke: add cron job per channel + verify status job count. + +## Open Questions +- Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)? +- Should we allow `webchat` as an explicit delivery channel (currently filtered in delivery resolution)? diff --git a/docs/tools.md b/docs/tools.md index 94bd80a06..47815a386 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -139,7 +139,7 @@ Core actions: Notes: - `add` expects a full cron job object (same schema as `cron.add` RPC). -- `update` uses `{ jobId, patch }`. +- `update` uses `{ id, patch }`. ### `gateway` Restart the running Gateway process (in-place). diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 403de1f2d..6de42b775 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, @@ -229,4 +229,3 @@ } } } - diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 3a8c92d4a..0cc248d1c 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -35,14 +35,31 @@ describe("cron tool", () => { expect(call.params).toEqual(expectedParams); }); - it("rejects jobId params", async () => { + it("normalizes cron.add job payloads", async () => { const tool = createCronTool(); - await expect( - tool.execute("call2", { - action: "update", - jobId: "job-1", - patch: { foo: "bar" }, - }), - ).rejects.toThrow("id required"); + await tool.execute("call2", { + action: "add", + job: { + data: { + name: "wake-up", + schedule: { atMs: 123 }, + payload: { text: "hello" }, + }, + }, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const call = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: unknown; + }; + expect(call.method).toBe("cron.add"); + expect(call.params).toEqual({ + name: "wake-up", + schedule: { kind: "at", atMs: 123 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index ccc0fa33f..f03260762 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -2,6 +2,13 @@ import { Type } from "@sinclair/typebox"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; +import { CronAddParamsSchema } from "../../gateway/protocol/schema.js"; +import { + normalizeCronJobCreate, + normalizeCronJobPatch, +} from "../../cron/normalize.js"; + +const CronJobPatchSchema = Type.Partial(CronAddParamsSchema); const CronToolSchema = Type.Union([ Type.Object({ @@ -22,7 +29,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - job: Type.Object({}, { additionalProperties: true }), + job: CronAddParamsSchema, }), Type.Object({ action: Type.Literal("update"), @@ -30,7 +37,7 @@ const CronToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), id: Type.String(), - patch: Type.Object({}, { additionalProperties: true }), + patch: CronJobPatchSchema, }), Type.Object({ action: Type.Literal("remove"), @@ -97,8 +104,9 @@ export function createCronTool(): AnyAgentTool { if (!params.job || typeof params.job !== "object") { throw new Error("job required"); } + const job = normalizeCronJobCreate(params.job) ?? params.job; return jsonResult( - await callGatewayTool("cron.add", gatewayOpts, params.job), + await callGatewayTool("cron.add", gatewayOpts, job), ); } case "update": { @@ -106,10 +114,11 @@ export function createCronTool(): AnyAgentTool { if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } + const patch = normalizeCronJobPatch(params.patch) ?? params.patch; return jsonResult( await callGatewayTool("cron.update", gatewayOpts, { id, - patch: params.patch, + patch, }), ); } diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index af844cce5..8b09ec269 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -4,6 +4,7 @@ import { defaultRuntime } from "../runtime.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; + async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { const res = (await callGatewayFromCli("cron.status", opts, {})) as { @@ -155,7 +156,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", + "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", "last", ) .option( @@ -414,7 +415,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", + "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", ) .option( "--to ", diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts new file mode 100644 index 000000000..19a9599ec --- /dev/null +++ b/src/cron/cron-protocol-conformance.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { CronPayloadSchema } from "../gateway/protocol/schema.js"; + +type SchemaLike = { + anyOf?: Array<{ properties?: Record }>; + properties?: Record; + const?: unknown; +}; + +type ChannelSchema = { + anyOf?: Array<{ const?: unknown }>; +}; + +function extractCronChannels(schema: SchemaLike): string[] { + const union = schema.anyOf ?? []; + const payloadWithChannel = union.find((entry) => + Boolean(entry?.properties && "channel" in entry.properties), + ); + const channelSchema = payloadWithChannel?.properties + ? (payloadWithChannel.properties.channel as ChannelSchema) + : undefined; + const channels = (channelSchema?.anyOf ?? []) + .map((entry) => entry?.const) + .filter((value): value is string => typeof value === "string"); + return channels; +} + +const UI_FILES = [ + "ui/src/ui/types.ts", + "ui/src/ui/ui-types.ts", + "ui/src/ui/views/cron.ts", +]; + +const SWIFT_FILES = [ + "apps/macos/Sources/Clawdbot/GatewayConnection.swift", +]; + +describe("cron protocol conformance", () => { + it("ui + swift include all cron channels from gateway schema", async () => { + const channels = extractCronChannels(CronPayloadSchema as SchemaLike); + expect(channels.length).toBeGreaterThan(0); + + const cwd = process.cwd(); + for (const relPath of UI_FILES) { + const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); + for (const channel of channels) { + expect( + content.includes(`"${channel}"`), + `${relPath} missing ${channel}`, + ).toBe(true); + } + } + + for (const relPath of SWIFT_FILES) { + const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); + for (const channel of channels) { + const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); + expect( + pattern.test(content), + `${relPath} missing case ${channel}`, + ).toBe(true); + } + } + }); + + it("cron status shape matches gateway fields in UI + Swift", async () => { + const cwd = process.cwd(); + const uiTypes = await fs.readFile( + path.join(cwd, "ui/src/ui/types.ts"), + "utf-8", + ); + expect(uiTypes.includes("export type CronStatus")).toBe(true); + expect(uiTypes.includes("jobs:")).toBe(true); + expect(uiTypes.includes("jobCount")).toBe(false); + + const swift = await fs.readFile( + path.join(cwd, "apps/macos/Sources/Clawdbot/GatewayConnection.swift"), + "utf-8", + ); + expect(swift.includes("struct CronSchedulerStatus")).toBe(true); + expect(swift.includes("let jobs:")).toBe(true); + }); +}); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts new file mode 100644 index 000000000..b6040a345 --- /dev/null +++ b/src/cron/normalize.ts @@ -0,0 +1,88 @@ +import type { CronJobCreate, CronJobPatch } from "./types.js"; + +type UnknownRecord = Record; + +type NormalizeOptions = { + applyDefaults?: boolean; +}; + +const DEFAULT_OPTIONS: NormalizeOptions = { + applyDefaults: false, +}; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function coerceSchedule(schedule: UnknownRecord) { + const next: UnknownRecord = { ...schedule }; + const kind = typeof schedule.kind === "string" ? schedule.kind : undefined; + if (!kind) { + if (typeof schedule.atMs === "number") next.kind = "at"; + else if (typeof schedule.everyMs === "number") next.kind = "every"; + else if (typeof schedule.expr === "string") next.kind = "cron"; + } + return next; +} + +function coercePayload(payload: UnknownRecord) { + const next: UnknownRecord = { ...payload }; + const kind = typeof payload.kind === "string" ? payload.kind : undefined; + if (!kind) { + if (typeof payload.text === "string") next.kind = "systemEvent"; + else if (typeof payload.message === "string") next.kind = "agentTurn"; + } + return next; +} + +function unwrapJob(raw: UnknownRecord) { + if (isRecord(raw.data)) return raw.data; + if (isRecord(raw.job)) return raw.job; + return raw; +} + +export function normalizeCronJobInput( + raw: unknown, + options: NormalizeOptions = DEFAULT_OPTIONS, +): UnknownRecord | null { + if (!isRecord(raw)) return null; + const base = unwrapJob(raw); + const next: UnknownRecord = { ...base }; + + if (isRecord(base.schedule)) { + next.schedule = coerceSchedule(base.schedule); + } + + if (isRecord(base.payload)) { + next.payload = coercePayload(base.payload); + } + + if (options.applyDefaults) { + if (!next.wakeMode) next.wakeMode = "next-heartbeat"; + if (!next.sessionTarget && isRecord(next.payload)) { + const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; + if (kind === "systemEvent") next.sessionTarget = "main"; + if (kind === "agentTurn") next.sessionTarget = "isolated"; + } + } + + return next; +} + +export function normalizeCronJobCreate( + raw: unknown, + options?: NormalizeOptions, +): CronJobCreate | null { + return normalizeCronJobInput(raw, { applyDefaults: true, ...options }) as + | CronJobCreate + | null; +} + +export function normalizeCronJobPatch( + raw: unknown, + options?: NormalizeOptions, +): CronJobPatch | null { + return normalizeCronJobInput(raw, { applyDefaults: false, ...options }) as + | CronJobPatch + | null; +} diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index d5dfed536..c4a2b1448 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -635,6 +635,8 @@ export const CronPayloadSchema = Type.Union([ Type.Literal("telegram"), Type.Literal("discord"), Type.Literal("slack"), + Type.Literal("signal"), + Type.Literal("imessage"), ]), ), to: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index d173fe26f..3c9a74a3b 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -17,6 +17,10 @@ import { validateWakeParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +import { + normalizeCronJobCreate, + normalizeCronJobPatch, +} from "../../cron/normalize.js"; export const cronHandlers: GatewayRequestHandlers = { wake: ({ params, respond, context }) => { @@ -72,7 +76,8 @@ export const cronHandlers: GatewayRequestHandlers = { respond(true, status, undefined); }, "cron.add": async ({ params, respond, context }) => { - if (!validateCronAddParams(params)) { + const normalized = normalizeCronJobCreate(params) ?? params; + if (!validateCronAddParams(normalized)) { respond( false, undefined, @@ -83,11 +88,20 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const job = await context.cron.add(params as unknown as CronJobCreate); + const job = await context.cron.add( + normalized as unknown as CronJobCreate, + ); respond(true, job, undefined); }, "cron.update": async ({ params, respond, context }) => { - if (!validateCronUpdateParams(params)) { + const normalizedPatch = normalizeCronJobPatch( + (params as { patch?: unknown } | null)?.patch, + ); + const candidate = + normalizedPatch && typeof params === "object" && params !== null + ? { ...(params as Record), patch: normalizedPatch } + : params; + if (!validateCronUpdateParams(candidate)) { respond( false, undefined, @@ -98,7 +112,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { + const p = candidate as { id: string; patch: Record; }; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index e9519aa88..6b387a4be 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -68,6 +68,88 @@ describe("gateway server cron", () => { testState.cronStorePath = undefined; }); + test("normalizes wrapped cron.add payloads", 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 atMs = Date.now() + 1000; + const addRes = await rpcReq(ws, "cron.add", { + data: { + name: "wrapped", + schedule: { atMs }, + payload: { text: "hello" }, + }, + }); + expect(addRes.ok).toBe(true); + const payload = addRes.payload as + | { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown } + | undefined; + expect(payload?.sessionTarget).toBe("main"); + expect(payload?.wakeMode).toBe("next-heartbeat"); + expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe( + "at", + ); + + ws.close(); + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + testState.cronStorePath = undefined; + }); + + test("normalizes cron.update patch payloads", 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: "patch 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() + 1_000; + const updateRes = await rpcReq(ws, "cron.update", { + id: jobId, + patch: { + schedule: { atMs }, + payload: { text: "updated" }, + }, + }); + expect(updateRes.ok).toBe(true); + const updated = updateRes.payload as + | { schedule?: { kind?: unknown }; payload?: { kind?: unknown } } + | undefined; + expect(updated?.schedule?.kind).toBe("at"); + expect(updated?.payload?.kind).toBe("systemEvent"); + + ws.close(); + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + testState.cronStorePath = undefined; + }); + test("writes cron run history to runs/.jsonl", async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdbot-gw-cron-log-"), diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index fa90455d3..f0d24d472 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -73,7 +73,14 @@ export function buildCronPayload(form: CronFormState) { kind: "agentTurn"; message: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; @@ -188,4 +195,3 @@ export async function loadCronRuns(state: CronState, jobId: string) { state.cronError = String(err); } } - diff --git a/ui/src/ui/tool-display.json b/ui/src/ui/tool-display.json index db86e2267..ce83d1520 100644 --- a/ui/src/ui/tool-display.json +++ b/ui/src/ui/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index eb1e2ce6f..bd3a002f1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -271,7 +271,14 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to?: string; bestEffortDeliver?: boolean; }; @@ -306,7 +313,7 @@ export type CronJob = { export type CronStatus = { enabled: boolean; - jobCount: number; + jobs: number; nextWakeAtMs?: number | null; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index dd1a6a84c..90a1372d8 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -162,7 +162,14 @@ export type CronFormState = { payloadKind: "systemEvent" | "agentTurn"; payloadText: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram"; + channel: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to: string; timeoutSeconds: string; postToMainPrefix: string; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 016f5419e..cbb2efc25 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -47,7 +47,7 @@ export function renderCron(props: CronProps) {
Jobs
-
${props.status?.jobCount ?? "n/a"}
+
${props.status?.jobs ?? "n/a"}
Next wake
@@ -185,6 +185,10 @@ export function renderCron(props: CronProps) { + + + +
`; } -