From 2bcdf741f90f35aa1bba29d90e18aa294dd6d053 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 19:56:49 +0000 Subject: [PATCH] feat(cron): require job name --- apps/macos/Sources/Clawdis/CronModels.swift | 5 +- apps/macos/Sources/Clawdis/CronSettings.swift | 26 ++++++- .../ClawdisProtocol/GatewayModels.swift | 16 +++- .../ClawdisIPCTests/CronModelsTests.swift | 2 + docs/cron.md | 3 +- skills/clawdis-cron/SKILL.md | 4 + src/cli/cron-cli.ts | 20 +++-- src/cron/isolated-agent.ts | 2 +- src/cron/service.test.ts | 7 ++ src/cron/service.ts | 78 +++++++++++++++++-- src/cron/types.ts | 3 +- src/gateway/protocol/schema.ts | 6 +- src/gateway/server.test.ts | 3 + 13 files changed, 150 insertions(+), 25 deletions(-) diff --git a/apps/macos/Sources/Clawdis/CronModels.swift b/apps/macos/Sources/Clawdis/CronModels.swift index 04f929989..78c009576 100644 --- a/apps/macos/Sources/Clawdis/CronModels.swift +++ b/apps/macos/Sources/Clawdis/CronModels.swift @@ -145,7 +145,8 @@ struct CronJobState: Codable, Equatable { struct CronJob: Identifiable, Codable, Equatable { let id: String - var name: String? + var name: String + var description: String? var enabled: Bool let createdAtMs: Int let updatedAtMs: Int @@ -157,7 +158,7 @@ struct CronJob: Identifiable, Codable, Equatable { let state: CronJobState var displayName: String { - let trimmed = (self.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled job" : trimmed } diff --git a/apps/macos/Sources/Clawdis/CronSettings.swift b/apps/macos/Sources/Clawdis/CronSettings.swift index 3d8875623..6ba806a79 100644 --- a/apps/macos/Sources/Clawdis/CronSettings.swift +++ b/apps/macos/Sources/Clawdis/CronSettings.swift @@ -277,6 +277,9 @@ struct CronSettings: View { private func detailCard(_ job: CronJob) -> some View { VStack(alignment: .leading, spacing: 10) { LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) } + if let desc = job.description, !desc.isEmpty { + LabeledContent("Description") { Text(desc).font(.callout) } + } LabeledContent("Session") { Text(job.sessionTarget.rawValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Next run") { @@ -514,6 +517,7 @@ struct CronJobEditor: View { "Controls the label used when posting the completion summary back to the main session." @State private var name: String = "" + @State private var description: String = "" @State private var enabled: Bool = true @State private var sessionTarget: CronSessionTarget = .main @State private var wakeMode: CronWakeMode = .nextHeartbeat @@ -554,7 +558,13 @@ struct CronJobEditor: View { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { GridRow { self.gridLabel("Name") - TextField("Optional label (e.g. “Daily summary”)", text: self.$name) + TextField("Required (e.g. “Daily summary”)", text: self.$name) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + } + GridRow { + self.gridLabel("Description") + TextField("Optional notes", text: self.$description) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } @@ -833,7 +843,8 @@ struct CronJobEditor: View { private func hydrateFromJob() { guard let job else { return } - self.name = job.name ?? "" + self.name = job.name + self.description = job.description ?? "" self.enabled = job.enabled self.sessionTarget = job.sessionTarget self.wakeMode = job.wakeMode @@ -881,6 +892,13 @@ struct CronJobEditor: View { private func buildPayload() throws -> [String: AnyCodable] { let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { + throw NSError( + domain: "Cron", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "Name is required."]) + } + let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) let schedule: [String: Any] switch self.scheduleKind { case .at: @@ -954,13 +972,14 @@ struct CronJobEditor: View { } var root: [String: Any] = [ + "name": name, "enabled": self.enabled, "schedule": schedule, "sessionTarget": self.sessionTarget.rawValue, "wakeMode": self.wakeMode.rawValue, "payload": payload, ] - if !name.isEmpty { root["name"] = name } + if !description.isEmpty { root["description"] = description } if self.sessionTarget == .isolated { let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1035,6 +1054,7 @@ struct CronSettings_Previews: PreviewProvider { CronJob( id: "job-1", name: "Daily summary", + description: nil, enabled: true, createdAtMs: 0, updatedAtMs: 0, diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index d1b83c718..a547759c9 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -672,7 +672,8 @@ public struct SkillsUpdateParams: Codable { public struct CronJob: Codable { public let id: String - public let name: String? + public let name: String + public let description: String? public let enabled: Bool public let createdatms: Int public let updatedatms: Int @@ -685,7 +686,8 @@ public struct CronJob: Codable { public init( id: String, - name: String?, + name: String, + description: String?, enabled: Bool, createdatms: Int, updatedatms: Int, @@ -698,6 +700,7 @@ public struct CronJob: Codable { ) { self.id = id self.name = name + self.description = description self.enabled = enabled self.createdatms = createdatms self.updatedatms = updatedatms @@ -711,6 +714,7 @@ public struct CronJob: Codable { private enum CodingKeys: String, CodingKey { case id case name + case description case enabled case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" @@ -740,7 +744,8 @@ public struct CronStatusParams: Codable { } public struct CronAddParams: Codable { - public let name: String? + public let name: String + public let description: String? public let enabled: Bool? public let schedule: AnyCodable public let sessiontarget: AnyCodable @@ -749,7 +754,8 @@ public struct CronAddParams: Codable { public let isolation: [String: AnyCodable]? public init( - name: String?, + name: String, + description: String?, enabled: Bool?, schedule: AnyCodable, sessiontarget: AnyCodable, @@ -758,6 +764,7 @@ public struct CronAddParams: Codable { isolation: [String: AnyCodable]? ) { self.name = name + self.description = description self.enabled = enabled self.schedule = schedule self.sessiontarget = sessiontarget @@ -767,6 +774,7 @@ public struct CronAddParams: Codable { } private enum CodingKeys: String, CodingKey { case name + case description case enabled case schedule case sessiontarget = "sessionTarget" diff --git a/apps/macos/Tests/ClawdisIPCTests/CronModelsTests.swift b/apps/macos/Tests/ClawdisIPCTests/CronModelsTests.swift index 4db3d3129..d7efd7bec 100644 --- a/apps/macos/Tests/ClawdisIPCTests/CronModelsTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/CronModelsTests.swift @@ -61,6 +61,7 @@ struct CronModelsTests { let base = CronJob( id: "x", name: " hello ", + description: nil, enabled: true, createdAtMs: 0, updatedAtMs: 0, @@ -81,6 +82,7 @@ struct CronModelsTests { let job = CronJob( id: "x", name: "t", + description: nil, enabled: true, createdAtMs: 0, updatedAtMs: 0, diff --git a/docs/cron.md b/docs/cron.md index 5c9b23713..29309ab31 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -60,7 +60,8 @@ This RFC adds a small “cron job system” so Clawd can schedule future work an Each job is a JSON object with stable keys (unknown keys ignored for forward compatibility): - `id: string` (UUID) -- `name?: string` +- `name: string` (required) +- `description?: string` (optional) - `enabled: boolean` - `createdAtMs: number` - `updatedAtMs: number` diff --git a/skills/clawdis-cron/SKILL.md b/skills/clawdis-cron/SKILL.md index f482fc773..5b0a5ba5f 100644 --- a/skills/clawdis-cron/SKILL.md +++ b/skills/clawdis-cron/SKILL.md @@ -13,6 +13,10 @@ Enable/disable - Disable with config `cron.enabled=false` or env `CLAWDIS_SKIP_CRON=1`. - Config: `cron.store`, `cron.maxConcurrentRuns`. +Job fields +- `name` is required (non-empty). +- `description` is optional. + RPC methods (Gateway WS) - `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` - `wake` (enqueue system event + optionally trigger immediate heartbeat) diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 2d77d88ee..c35869e85 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -132,7 +132,8 @@ export function registerCronCli(program: Command) { cron .command("add") .description("Add a cron job") - .option("--name ", "Optional name") + .requiredOption("--name ", "Job name") + .option("--description ", "Optional description") .option("--disabled", "Create job disabled", false) .option("--session ", "Session target (main|isolated)", "main") .option( @@ -278,11 +279,17 @@ export function registerCronCli(program: Command) { } : undefined; + const name = String(opts.name ?? "").trim(); + if (!name) throw new Error("--name is required"); + + const description = + typeof opts.description === "string" && opts.description.trim() + ? opts.description.trim() + : undefined; + const params = { - name: - typeof opts.name === "string" && opts.name.trim() - ? opts.name.trim() - : undefined, + name, + description, enabled: !opts.disabled, schedule, sessionTarget, @@ -388,6 +395,7 @@ export function registerCronCli(program: Command) { .description("Edit a cron job (patch fields)") .argument("", "Job id") .option("--name ", "Set name") + .option("--description ", "Set description") .option("--enable", "Enable job", false) .option("--disable", "Disable job", false) .option("--session ", "Session target (main|isolated)") @@ -430,6 +438,8 @@ export function registerCronCli(program: Command) { const patch: Record = {}; if (typeof opts.name === "string") patch.name = opts.name; + if (typeof opts.description === "string") + patch.description = opts.description; if (opts.enable && opts.disable) throw new Error("Choose --enable or --disable, not both"); if (opts.enable) patch.enabled = true; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 64698b08e..cf4762c6a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -201,7 +201,7 @@ export async function runCronIsolatedAgentTurn(params: { }); const base = - `[cron:${params.job.id}${params.job.name ? ` ${params.job.name}` : ""}] ${params.message}`.trim(); + `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); const commandBody = base; diff --git a/src/cron/service.test.ts b/src/cron/service.test.ts index de9fb73f8..357498d71 100644 --- a/src/cron/service.test.ts +++ b/src/cron/service.test.ts @@ -54,6 +54,7 @@ describe("CronService", () => { await cron.start(); const atMs = Date.parse("2025-12-13T00:00:02.000Z"); const job = await cron.add({ + name: "one-shot hello", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", @@ -139,6 +140,7 @@ describe("CronService", () => { await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cron.add({ + name: "isolated error test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "isolated", @@ -174,6 +176,7 @@ describe("CronService", () => { await expect( cron.add({ + name: "bad combo (main/agentTurn)", enabled: true, schedule: { kind: "every", everyMs: 1000 }, sessionTarget: "main", @@ -184,6 +187,7 @@ describe("CronService", () => { await expect( cron.add({ + name: "bad combo (isolated/systemEvent)", enabled: true, schedule: { kind: "every", everyMs: 1000 }, sessionTarget: "isolated", @@ -265,6 +269,7 @@ describe("CronService", () => { await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cron.add({ + name: "empty systemEvent test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", @@ -303,6 +308,7 @@ describe("CronService", () => { await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cron.add({ + name: "disabled cron job", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", @@ -342,6 +348,7 @@ describe("CronService", () => { await cron.start(); const atMs = Date.parse("2025-12-13T00:00:05.000Z"); await cron.add({ + name: "status next wake", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", diff --git a/src/cron/service.ts b/src/cron/service.ts index 79e70df8f..87001bdbf 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -45,8 +45,49 @@ export type CronServiceDeps = { const STUCK_RUN_MS = 2 * 60 * 60 * 1000; -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; +function normalizeRequiredName(raw: unknown) { + if (typeof raw !== "string") throw new Error("cron job name is required"); + const name = raw.trim(); + if (!name) throw new Error("cron job name is required"); + return name; +} + +function normalizeOptionalText(raw: unknown) { + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; +} + +function truncateText(input: string, maxLen: number) { + if (input.length <= maxLen) return input; + return `${input.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; +} + +function inferLegacyName(job: { + schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown }; + payload?: { kind?: unknown; text?: unknown; message?: unknown }; +}) { + const text = + job?.payload?.kind === "systemEvent" && typeof job.payload.text === "string" + ? job.payload.text + : job?.payload?.kind === "agentTurn" && + typeof job.payload.message === "string" + ? job.payload.message + : ""; + const firstLine = + text + .split("\n") + .map((l) => l.trim()) + .find(Boolean) ?? ""; + if (firstLine) return truncateText(firstLine, 60); + + const kind = typeof job?.schedule?.kind === "string" ? job.schedule.kind : ""; + if (kind === "cron" && typeof job?.schedule?.expr === "string") + return `Cron: ${truncateText(job.schedule.expr, 52)}`; + if (kind === "every" && typeof job?.schedule?.everyMs === "number") + return `Every: ${job.schedule.everyMs}ms`; + if (kind === "at") return "One-shot"; + return "Cron job"; } function normalizePayloadToSystemText(payload: CronPayload) { @@ -131,7 +172,8 @@ export class CronService { const id = crypto.randomUUID(); const job: CronJob = { id, - name: input.name?.trim() || undefined, + name: normalizeRequiredName(input.name), + description: normalizeOptionalText(input.description), enabled: input.enabled !== false, createdAtMs: now, updatedAtMs: now, @@ -165,8 +207,9 @@ export class CronService { const job = this.findJobOrThrow(id); const now = this.deps.nowMs(); - if (isNonEmptyString(patch.name)) job.name = patch.name.trim(); - if (patch.name === null || patch.name === "") job.name = undefined; + if ("name" in patch) job.name = normalizeRequiredName(patch.name); + if ("description" in patch) + job.description = normalizeOptionalText(patch.description); if (typeof patch.enabled === "boolean") job.enabled = patch.enabled; if (patch.schedule) job.schedule = patch.schedule; if (patch.sessionTarget) job.sessionTarget = patch.sessionTarget; @@ -251,7 +294,30 @@ export class CronService { private async ensureLoaded() { if (this.store) return; const loaded = await loadCronStore(this.deps.storePath); - this.store = { version: 1, jobs: loaded.jobs ?? [] }; + const jobs = (loaded.jobs ?? []) as unknown as Array< + Record + >; + let mutated = false; + for (const raw of jobs) { + const nameRaw = raw.name; + if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { + raw.name = inferLegacyName({ + schedule: raw.schedule as never, + payload: raw.payload as never, + }); + mutated = true; + } else { + raw.name = nameRaw.trim(); + } + + const desc = normalizeOptionalText(raw.description); + if (raw.description !== desc) { + raw.description = desc; + mutated = true; + } + } + this.store = { version: 1, jobs: jobs as unknown as CronJob[] }; + if (mutated) await this.persist(); } private warnIfDisabled(action: string) { diff --git a/src/cron/types.ts b/src/cron/types.ts index f0f73cfe9..43b26f204 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -34,7 +34,8 @@ export type CronJobState = { export type CronJob = { id: string; - name?: string; + name: string; + description?: string; enabled: boolean; createdAtMs: number; updatedAtMs: number; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index d57f9d0c7..7f214678a 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -412,7 +412,8 @@ export const CronJobStateSchema = Type.Object( export const CronJobSchema = Type.Object( { id: NonEmptyString, - name: Type.Optional(Type.String()), + name: NonEmptyString, + description: Type.Optional(Type.String()), enabled: Type.Boolean(), createdAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }), @@ -440,7 +441,8 @@ export const CronStatusParamsSchema = Type.Object( export const CronAddParamsSchema = Type.Object( { - name: Type.Optional(Type.String()), + name: NonEmptyString, + description: Type.Optional(Type.String()), enabled: Type.Optional(Type.Boolean()), schedule: CronScheduleSchema, sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 31c7c4b52..fc27c6e1a 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -968,6 +968,7 @@ describe("gateway server", () => { id: "cron-add-log-1", method: "cron.add", params: { + name: "log test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", @@ -1077,6 +1078,7 @@ describe("gateway server", () => { id: "cron-add-log-2", method: "cron.add", params: { + name: "log test (jobs.json)", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", @@ -1207,6 +1209,7 @@ describe("gateway server", () => { id: "cron-add-auto-1", method: "cron.add", params: { + name: "auto run test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main",