mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat: cron ISO at + delete-after-run
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -20,6 +20,24 @@ cron is the mechanism.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, 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:<jobId>`.
|
||||
|
||||
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 \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <name>", "Job name")
|
||||
.option("--description <text>", "Optional description")
|
||||
.option("--disabled", "Create job disabled", false)
|
||||
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
|
||||
.option("--agent <id>", "Agent id for this job")
|
||||
.option("--session <target>", "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 <text>", "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 <target>", "Session target (main|isolated)")
|
||||
.option("--agent <id>", "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;
|
||||
|
||||
@@ -75,4 +75,40 @@ describe("normalizeCronJobCreate", () => {
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("at");
|
||||
expect(schedule.atMs).toBe(Date.parse("2026-01-12T18:00:00Z"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
21
src/cron/parse.ts
Normal file
21
src/cron/parse.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ export type CronJob = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
deleteAfterRun?: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: CronSchedule;
|
||||
|
||||
@@ -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")]),
|
||||
|
||||
@@ -354,6 +354,7 @@ export type CronJob = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
deleteAfterRun?: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: CronSchedule;
|
||||
|
||||
Reference in New Issue
Block a user