From 75a7855223f75b55779c146f8f9584f0180eb3be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 04:55:39 +0000 Subject: [PATCH] feat: cron ISO at + delete-after-run --- CHANGELOG.md | 5 +++ .../Clawdbot/CronJobEditor+Helpers.swift | 6 +++ .../Sources/Clawdbot/CronJobEditor.swift | 6 +++ apps/macos/Sources/Clawdbot/CronModels.swift | 1 + .../Sources/Clawdbot/CronSettings+Rows.swift | 3 ++ .../Clawdbot/CronSettings+Testing.swift | 2 + docs/automation/cron-jobs.md | 32 ++++++++++++++- src/agents/system-prompt.ts | 4 +- src/cli/cron-cli.ts | 18 +++++++-- src/cron/normalize.test.ts | 36 +++++++++++++++++ src/cron/normalize.ts | 24 ++++++++++- src/cron/parse.ts | 21 ++++++++++ src/cron/service.test.ts | 40 +++++++++++++++++++ src/cron/service.ts | 36 ++++++++++++----- src/cron/types.ts | 1 + src/gateway/protocol/schema.ts | 2 + ui/src/ui/types.ts | 1 + 17 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 src/cron/parse.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff864f54f..25b12c968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.13-1 + +### Changes +- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor. + ## 2026.1.12-4 ### Changes diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift index 19f07ebad..1a8ad6fd9 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift @@ -15,6 +15,7 @@ extension CronJobEditor { self.description = job.description ?? "" self.agentId = job.agentId ?? "" self.enabled = job.enabled + self.deleteAfterRun = job.deleteAfterRun ?? false self.sessionTarget = job.sessionTarget self.wakeMode = job.wakeMode @@ -149,6 +150,11 @@ extension CronJobEditor { "wakeMode": self.wakeMode.rawValue, "payload": payload, ] + if self.scheduleKind == .at { + root["deleteAfterRun"] = self.deleteAfterRun + } else if self.job?.deleteAfterRun != nil { + root["deleteAfterRun"] = false + } if !description.isEmpty { root["description"] = description } if !agentId.isEmpty { root["agentId"] = agentId diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index c86d581b9..7201b738e 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -31,6 +31,7 @@ struct CronJobEditor: View { @State var enabled: Bool = true @State var sessionTarget: CronSessionTarget = .main @State var wakeMode: CronWakeMode = .nextHeartbeat + @State var deleteAfterRun: Bool = false enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } } @State var scheduleKind: ScheduleKind = .every @@ -156,6 +157,11 @@ struct CronJobEditor: View { .labelsHidden() .frame(maxWidth: .infinity, alignment: .leading) } + GridRow { + self.gridLabel("Auto-delete") + Toggle("Delete after successful run", isOn: self.$deleteAfterRun) + .toggleStyle(.switch) + } case .every: GridRow { self.gridLabel("Every") diff --git a/apps/macos/Sources/Clawdbot/CronModels.swift b/apps/macos/Sources/Clawdbot/CronModels.swift index 0c43049fd..437cc99fe 100644 --- a/apps/macos/Sources/Clawdbot/CronModels.swift +++ b/apps/macos/Sources/Clawdbot/CronModels.swift @@ -149,6 +149,7 @@ struct CronJob: Identifiable, Codable, Equatable { var name: String var description: String? var enabled: Bool + var deleteAfterRun: Bool? let createdAtMs: Int let updatedAtMs: Int let schedule: CronSchedule diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift index 526020492..98ebc23e6 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Rows.swift @@ -94,6 +94,9 @@ extension CronSettings { func detailCard(_ job: CronJob) -> some View { VStack(alignment: .leading, spacing: 10) { LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } + if case .at = job.schedule, job.deleteAfterRun == true { + LabeledContent("Auto-delete") { Text("after success") } + } if let desc = job.description, !desc.isEmpty { LabeledContent("Description") { Text(desc).font(.callout) } } diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift index 7b82248b3..0cd8a092e 100644 --- a/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift +++ b/apps/macos/Sources/Clawdbot/CronSettings+Testing.swift @@ -11,6 +11,7 @@ struct CronSettings_Previews: PreviewProvider { name: "Daily summary", description: nil, enabled: true, + deleteAfterRun: nil, createdAtMs: 0, updatedAtMs: 0, schedule: .every(everyMs: 86_400_000, anchorMs: nil), @@ -64,6 +65,7 @@ extension CronSettings { name: "Daily summary", description: "Summary job", enabled: true, + deleteAfterRun: nil, createdAtMs: 1_700_000_000_000, updatedAtMs: 1_700_000_100_000, schedule: .cron(expr: "0 8 * * *", tz: "UTC"), diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index b942793a4..6715b5df3 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -20,6 +20,24 @@ cron is the mechanism. - **Isolated**: run a dedicated agent turn in `cron:`, optionally deliver output. - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. +## Beginner-friendly overview +Think of a cron job as: **when** to run + **what** to do. + +1) **Choose a schedule** + - One-shot reminder → `schedule.kind = "at"` (CLI: `--at`) + - Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"` + - If your ISO timestamp omits a timezone, it is treated as **UTC**. + +2) **Choose where it runs** + - `sessionTarget: "main"` → run during the next heartbeat with main context. + - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. + +3) **Choose the payload** + - Main session → `payload.kind = "systemEvent"` + - Isolated session → `payload.kind = "agentTurn"` + +Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store. + ## Concepts ### Jobs @@ -32,10 +50,11 @@ A cron job is a stored record with: Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs). In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility. +Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`. ### Schedules Cron supports three schedule kinds: -- `at`: one-shot timestamp (ms since epoch). +- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC. - `every`: fixed interval (ms). - `cron`: 5-field cron expression with optional IANA timezone. @@ -143,6 +162,17 @@ Disable cron entirely: ## CLI quickstart +One-shot reminder (UTC ISO, auto-delete after success): +```bash +clawdbot cron add \ + --name "Send reminder" \ + --at "2026-01-12T18:00:00Z" \ + --session main \ + --system-event "Reminder: submit expense report." \ + --wake now \ + --delete-after-run +``` + One-shot reminder (main session, wake immediately): ```bash clawdbot cron add \ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 522c38cfc..a49bc9632 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -60,7 +60,7 @@ export function buildAgentSystemPrompt(params: { browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", - cron: "Manage cron jobs and wake events", + cron: "Manage cron jobs and wake events (use for reminders)", message: "Send messages and provider actions", gateway: "Restart, apply config, or run updates on the running Clawdbot process", @@ -211,7 +211,7 @@ export function buildAgentSystemPrompt(params: { "- browser: control clawd's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", - "- cron: manage cron jobs and wake events", + "- cron: manage cron jobs and wake events (use for reminders)", "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index dfac7cca4..1b4d0c7bb 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { parseAbsoluteTimeMs } from "../cron/parse.js"; import type { CronJob, CronSchedule } from "../cron/types.js"; import { danger } from "../globals.js"; import { PROVIDER_IDS } from "../providers/registry.js"; @@ -57,10 +58,8 @@ function parseDurationMs(input: string): number | null { function parseAtMs(input: string): number | null { const raw = input.trim(); if (!raw) return null; - const asNum = Number(raw); - if (Number.isFinite(asNum) && asNum > 0) return Math.floor(asNum); - const parsed = Date.parse(raw); - if (Number.isFinite(parsed)) return parsed; + const absolute = parseAbsoluteTimeMs(raw); + if (absolute) return absolute; const dur = parseDurationMs(raw); if (dur) return Date.now() + dur; return null; @@ -294,6 +293,7 @@ export function registerCronCli(program: Command) { .requiredOption("--name ", "Job name") .option("--description ", "Optional description") .option("--disabled", "Create job disabled", false) + .option("--delete-after-run", "Delete one-shot job after it succeeds", false) .option("--agent ", "Agent id for this job") .option("--session ", "Session target (main|isolated)", "main") .option( @@ -468,6 +468,7 @@ export function registerCronCli(program: Command) { name, description, enabled: !opts.disabled, + deleteAfterRun: Boolean(opts.deleteAfterRun), agentId, schedule, sessionTarget, @@ -578,6 +579,8 @@ export function registerCronCli(program: Command) { .option("--description ", "Set description") .option("--enable", "Enable job", false) .option("--disable", "Disable job", false) + .option("--delete-after-run", "Delete one-shot job after it succeeds", false) + .option("--keep-after-run", "Keep one-shot job after it succeeds", false) .option("--session ", "Session target (main|isolated)") .option("--agent ", "Set agent id") .option("--clear-agent", "Unset agent and use default", false) @@ -630,6 +633,13 @@ export function registerCronCli(program: Command) { throw new Error("Choose --enable or --disable, not both"); if (opts.enable) patch.enabled = true; if (opts.disable) patch.enabled = false; + if (opts.deleteAfterRun && opts.keepAfterRun) { + throw new Error( + "Choose --delete-after-run or --keep-after-run, not both", + ); + } + if (opts.deleteAfterRun) patch.deleteAfterRun = true; + if (opts.keepAfterRun) patch.deleteAfterRun = false; if (typeof opts.session === "string") patch.sessionTarget = opts.session; if (typeof opts.wake === "string") patch.wakeMode = opts.wake; diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index b33f13e84..9c9c7204c 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -75,4 +75,40 @@ describe("normalizeCronJobCreate", () => { const payload = normalized.payload as Record; expect(payload.provider).toBe("telegram"); }); + + it("coerces ISO schedule.at to atMs (UTC)", () => { + const normalized = normalizeCronJobCreate({ + name: "iso at", + enabled: true, + schedule: { at: "2026-01-12T18:00:00" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "hi", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule.kind).toBe("at"); + expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z")); + }); + + it("coerces ISO schedule.atMs string to atMs (UTC)", () => { + const normalized = normalizeCronJobCreate({ + name: "iso atMs", + enabled: true, + schedule: { kind: "at", atMs: "2026-01-12T18:00:00" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "hi", + }, + }) as unknown as Record; + + const schedule = normalized.schedule as Record; + expect(schedule.kind).toBe("at"); + expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z")); + }); }); diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 3fb7af490..c432b7da6 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -1,4 +1,5 @@ import { normalizeAgentId } from "../routing/session-key.js"; +import { parseAbsoluteTimeMs } from "./parse.js"; import { migrateLegacyCronPayload } from "./payload-migration.js"; import type { CronJobCreate, CronJobPatch } from "./types.js"; @@ -19,11 +20,32 @@ function isRecord(value: unknown): value is UnknownRecord { function coerceSchedule(schedule: UnknownRecord) { const next: UnknownRecord = { ...schedule }; const kind = typeof schedule.kind === "string" ? schedule.kind : undefined; + const atMsRaw = schedule.atMs; + const atRaw = schedule.at; + const parsedAtMs = + typeof atMsRaw === "string" + ? parseAbsoluteTimeMs(atMsRaw) + : typeof atRaw === "string" + ? parseAbsoluteTimeMs(atRaw) + : null; + if (!kind) { - if (typeof schedule.atMs === "number") next.kind = "at"; + if ( + typeof schedule.atMs === "number" || + typeof schedule.at === "string" || + typeof schedule.atMs === "string" + ) + next.kind = "at"; else if (typeof schedule.everyMs === "number") next.kind = "every"; else if (typeof schedule.expr === "string") next.kind = "cron"; } + + if (typeof schedule.atMs !== "number" && parsedAtMs !== null) { + next.atMs = parsedAtMs; + } + + if ("at" in next) delete next.at; + return next; } diff --git a/src/cron/parse.ts b/src/cron/parse.ts new file mode 100644 index 000000000..e42dd775e --- /dev/null +++ b/src/cron/parse.ts @@ -0,0 +1,21 @@ +const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i; +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/; + +function normalizeUtcIso(raw: string) { + if (ISO_TZ_RE.test(raw)) return raw; + if (ISO_DATE_RE.test(raw)) return `${raw}T00:00:00Z`; + if (ISO_DATE_TIME_RE.test(raw)) return `${raw}Z`; + return raw; +} + +export function parseAbsoluteTimeMs(input: string): number | null { + const raw = input.trim(); + if (!raw) return null; + if (/^\d+$/.test(raw)) { + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return Math.floor(n); + } + const parsed = Date.parse(normalizeUtcIso(raw)); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/src/cron/service.test.ts b/src/cron/service.test.ts index 168c9faaa..55454e0dd 100644 --- a/src/cron/service.test.ts +++ b/src/cron/service.test.ts @@ -81,6 +81,46 @@ describe("CronService", () => { await store.cleanup(); }); + it("runs a one-shot job and deletes it after success when requested", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestHeartbeatNow = vi.fn(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const atMs = Date.parse("2025-12-13T00:00:02.000Z"); + const job = await cron.add({ + name: "one-shot delete", + enabled: true, + deleteAfterRun: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "hello" }, + }); + + vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z")); + await vi.runOnlyPendingTimersAsync(); + + const jobs = await cron.list({ includeDisabled: true }); + expect(jobs.find((j) => j.id === job.id)).toBeUndefined(); + expect(enqueueSystemEvent).toHaveBeenCalledWith("hello", { + agentId: undefined, + }); + expect(requestHeartbeatNow).toHaveBeenCalled(); + + cron.stop(); + await store.cleanup(); + }); + it("wakeMode now waits for heartbeat completion when available", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); diff --git a/src/cron/service.ts b/src/cron/service.ts index 77dd9095d..e9899cd1f 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -193,6 +193,7 @@ export class CronService { name: normalizeRequiredName(input.name), description: normalizeOptionalText(input.description), enabled: input.enabled !== false, + deleteAfterRun: input.deleteAfterRun, createdAtMs: now, updatedAtMs: now, schedule: input.schedule, @@ -229,6 +230,8 @@ export class CronService { if ("description" in patch) job.description = normalizeOptionalText(patch.description); if (typeof patch.enabled === "boolean") job.enabled = patch.enabled; + if (typeof patch.deleteAfterRun === "boolean") + job.deleteAfterRun = patch.deleteAfterRun; if (patch.schedule) job.schedule = patch.schedule; if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget; if (patch.wakeMode) job.wakeMode = patch.wakeMode; @@ -472,6 +475,8 @@ export class CronService { job.state.lastError = undefined; this.emit({ jobId: job.id, action: "started", runAtMs: startedAt }); + let deleted = false; + const finish = async ( status: "ok" | "error" | "skipped", err?: string, @@ -484,14 +489,21 @@ export class CronService { job.state.lastDurationMs = Math.max(0, endedAt - startedAt); job.state.lastError = err; - if (job.schedule.kind === "at" && status === "ok") { - // One-shot job completed successfully; disable it. - job.enabled = false; - job.state.nextRunAtMs = undefined; - } else if (job.enabled) { - job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, endedAt); - } else { - job.state.nextRunAtMs = undefined; + const shouldDelete = + job.schedule.kind === "at" && + status === "ok" && + job.deleteAfterRun === true; + + if (!shouldDelete) { + if (job.schedule.kind === "at" && status === "ok") { + // One-shot job completed successfully; disable it. + job.enabled = false; + job.state.nextRunAtMs = undefined; + } else if (job.enabled) { + job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, endedAt); + } else { + job.state.nextRunAtMs = undefined; + } } this.emit({ @@ -505,6 +517,12 @@ export class CronService { nextRunAtMs: job.state.nextRunAtMs, }); + if (shouldDelete && this.store) { + this.store.jobs = this.store.jobs.filter((j) => j.id !== job.id); + deleted = true; + this.emit({ jobId: job.id, action: "removed" }); + } + if (job.sessionTarget === "isolated") { const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron"; const body = (summary ?? err ?? status).trim(); @@ -592,7 +610,7 @@ export class CronService { await finish("error", String(err)); } finally { job.updatedAtMs = nowMs; - if (!opts.forced && job.enabled) { + if (!opts.forced && job.enabled && !deleted) { // Keep nextRunAtMs in sync in case the schedule advanced during a long run. job.state.nextRunAtMs = this.computeJobNextRunAtMs( job, diff --git a/src/cron/types.ts b/src/cron/types.ts index c01225cd9..3cb5caf21 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -44,6 +44,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; schedule: CronSchedule; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index cdd883ffa..0c4c69e47 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -830,6 +830,7 @@ export const CronJobSchema = Type.Object( name: NonEmptyString, description: Type.Optional(Type.String()), enabled: Type.Boolean(), + deleteAfterRun: Type.Optional(Type.Boolean()), createdAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }), schedule: CronScheduleSchema, @@ -860,6 +861,7 @@ export const CronAddParamsSchema = Type.Object( agentId: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), + deleteAfterRun: Type.Optional(Type.Boolean()), schedule: CronScheduleSchema, sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index c6b729720..7a012a8c1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -354,6 +354,7 @@ export type CronJob = { name: string; description?: string; enabled: boolean; + deleteAfterRun?: boolean; createdAtMs: number; updatedAtMs: number; schedule: CronSchedule;