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

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