Cron: normalize cron.add inputs + align channels (#256)

* fix: harden cron add and align channels

* fix: keep cron tool id params

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Marcus Neves
2026-01-05 23:09:48 -03:00
committed by GitHub
parent 00061b2fd3
commit 67e1452f4a
21 changed files with 457 additions and 48 deletions

View File

@@ -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"] }
}
},

View File

@@ -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)

View File

@@ -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?) {

View File

@@ -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"] }
}
},

View File

@@ -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 "<text>"`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to <dest>]`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to <dest>]`
- `clawdbot cron edit <id> ...` (patch-by-flags, non-interactive)
- `clawdbot cron rm <id>`

View File

@@ -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)?

View File

@@ -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).

View File

@@ -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 @@
}
}
}

View File

@@ -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" },
});
});
});

View File

@@ -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,
}),
);
}

View File

@@ -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 <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 <channel>",
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)",
)
.option(
"--to <dest>",

View File

@@ -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<string, unknown> }>;
properties?: Record<string, unknown>;
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);
});
});

88
src/cron/normalize.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { CronJobCreate, CronJobPatch } from "./types.js";
type UnknownRecord = Record<string, unknown>;
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;
}

View File

@@ -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()),

View File

@@ -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<string, unknown>), 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<string, unknown>;
};

View File

@@ -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/<jobId>.jsonl", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-log-"),

View File

@@ -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);
}
}

View File

@@ -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"] }
}
},

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -47,7 +47,7 @@ export function renderCron(props: CronProps) {
</div>
<div class="stat">
<div class="stat-label">Jobs</div>
<div class="stat-value">${props.status?.jobCount ?? "n/a"}</div>
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div>
</div>
<div class="stat">
<div class="stat-label">Next wake</div>
@@ -185,6 +185,10 @@ export function renderCron(props: CronProps) {
<option value="last">Last</option>
<option value="whatsapp">WhatsApp</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="signal">Signal</option>
<option value="imessage">iMessage</option>
</select>
</label>
<label class="field">
@@ -387,4 +391,3 @@ function renderRun(entry: CronRunLogEntry) {
</div>
`;
}