diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 5d4011918..4aba3adea 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -654,6 +654,7 @@ public struct CronRunLogEntry: Codable { public let action: String public let status: AnyCodable? public let error: String? + public let summary: String? public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? @@ -664,6 +665,7 @@ public struct CronRunLogEntry: Codable { action: String, status: AnyCodable?, error: String?, + summary: String?, runatms: Int?, durationms: Int?, nextrunatms: Int? @@ -673,6 +675,7 @@ public struct CronRunLogEntry: Codable { self.action = action self.status = status self.error = error + self.summary = summary self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms @@ -683,6 +686,7 @@ public struct CronRunLogEntry: Codable { case action case status case error + case summary case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" diff --git a/dist/protocol.schema.json b/dist/protocol.schema.json index 924ca02b7..bac1f55b4 100644 --- a/dist/protocol.schema.json +++ b/dist/protocol.schema.json @@ -1093,9 +1093,6 @@ "additionalProperties": false, "type": "object", "properties": { - "postToMain": { - "type": "boolean" - }, "postToMainPrefix": { "type": "string" } @@ -1344,9 +1341,6 @@ "additionalProperties": false, "type": "object", "properties": { - "postToMain": { - "type": "boolean" - }, "postToMainPrefix": { "type": "string" } @@ -1543,9 +1537,6 @@ "additionalProperties": false, "type": "object", "properties": { - "postToMain": { - "type": "boolean" - }, "postToMainPrefix": { "type": "string" } @@ -1647,6 +1638,9 @@ "error": { "type": "string" }, + "summary": { + "type": "string" + }, "runAtMs": { "minimum": 0, "type": "integer" diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts new file mode 100644 index 000000000..1ff0dee83 --- /dev/null +++ b/src/cron/isolated-agent.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CliDeps } from "../cli/deps.js"; +import type { ClawdisConfig } from "../config/config.js"; +import type { CronJob } from "./types.js"; + +vi.mock("../auto-reply/command-reply.js", () => ({ + runCommandReply: vi.fn(), +})); + +import { runCommandReply } from "../auto-reply/command-reply.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; + +async function makeSessionStorePath() { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdis-cron-sessions-"), + ); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "main-session", + updatedAt: Date.now(), + lastChannel: "webchat", + lastTo: "", + }, + }, + null, + 2, + ), + ); + return { + storePath, + cleanup: async () => { + await fs.rm(dir, { recursive: true, force: true }); + }, + }; +} + +function makeCfg(storePath: string): ClawdisConfig { + return { + inbound: { + reply: { + mode: "command", + command: ["echo", "ok"], + session: { + store: storePath, + mainKey: "main", + }, + }, + }, + } as ClawdisConfig; +} + +function makeJob(payload: CronJob["payload"]): CronJob { + const now = Date.now(); + return { + id: "job-1", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload, + state: {}, + isolation: { postToMainPrefix: "Cron" }, + }; +} + +describe("runCronIsolatedAgentTurn", () => { + beforeEach(() => { + vi.mocked(runCommandReply).mockReset(); + }); + + it("uses last non-empty agent text as summary", async () => { + const sessions = await makeSessionStorePath(); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runCommandReply).mockResolvedValue({ + payloads: [{ text: "first" }, { text: " " }, { text: " last " }], + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(sessions.storePath), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(res.summary).toBe("last"); + + await sessions.cleanup(); + }); + + it("truncates long summaries", async () => { + const sessions = await makeSessionStorePath(); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + const long = "a".repeat(2001); + vi.mocked(runCommandReply).mockResolvedValue({ + payloads: [{ text: long }], + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(sessions.storePath), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(String(res.summary ?? "")).toMatch(/…$/); + + await sessions.cleanup(); + }); + + it("fails delivery without a WhatsApp recipient when bestEffortDeliver=false", async () => { + const sessions = await makeSessionStorePath(); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runCommandReply).mockResolvedValue({ + payloads: [{ text: "hello" }], + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(sessions.storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + bestEffortDeliver: false, + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("error"); + expect(res.summary).toBe("hello"); + expect(String(res.error ?? "")).toMatch(/requires a recipient/i); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + + await sessions.cleanup(); + }); + + it("skips delivery without a WhatsApp recipient when bestEffortDeliver=true", async () => { + const sessions = await makeSessionStorePath(); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + }; + vi.mocked(runCommandReply).mockResolvedValue({ + payloads: [{ text: "hello" }], + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(sessions.storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + bestEffortDeliver: true, + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("skipped"); + expect(String(res.summary ?? "")).toMatch(/delivery skipped/i); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + + await sessions.cleanup(); + }); +}); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 9c482cd26..2637e110d 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -23,6 +23,7 @@ import type { CronJob } from "./types.js"; export type RunCronAgentTurnResult = { status: "ok" | "error" | "skipped"; summary?: string; + error?: string; }; function assertCommandReplyConfig(cfg: ClawdisConfig) { @@ -241,19 +242,24 @@ export async function runCronIsolatedAgentTurn(params: { const lane = params.lane?.trim() || "cron"; - const runResult = await runCommandReply({ - reply: { ...replyCfg, mode: "command" }, - templatingCtx, - sendSystemOnce, - isNewSession: cronSession.isNewSession, - isFirstTurnInSession, - systemSent: cronSession.sessionEntry.systemSent ?? false, - timeoutMs, - timeoutSeconds, - thinkLevel, - enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts), - runId: cronSession.sessionEntry.sessionId, - }); + let runResult: Awaited>; + try { + runResult = await runCommandReply({ + reply: { ...replyCfg, mode: "command" }, + templatingCtx, + sendSystemOnce, + isNewSession: cronSession.isNewSession, + isFirstTurnInSession, + systemSent: cronSession.sessionEntry.systemSent ?? false, + timeoutMs, + timeoutSeconds, + thinkLevel, + enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts), + runId: cronSession.sessionEntry.sessionId, + }); + } catch (err) { + return { status: "error", error: String(err) }; + } const payloads = runResult.payloads ?? []; const firstText = payloads[0]?.text ?? ""; @@ -263,12 +269,12 @@ export async function runCronIsolatedAgentTurn(params: { if (delivery) { if (resolvedDelivery.channel === "whatsapp") { if (!resolvedDelivery.to) { - if (!bestEffortDeliver) { + if (!bestEffortDeliver) return { status: "error", - summary: "Cron delivery to WhatsApp requires a recipient.", + summary, + error: "Cron delivery to WhatsApp requires a recipient.", }; - } return { status: "skipped", summary: "Delivery skipped (no WhatsApp recipient).", @@ -292,22 +298,18 @@ export async function runCronIsolatedAgentTurn(params: { } } } catch (err) { - if (!bestEffortDeliver) throw err; - return { - status: "ok", - summary: summary - ? `${summary} (delivery failed)` - : "completed (delivery failed)", - }; + if (!bestEffortDeliver) + return { status: "error", summary, error: String(err) }; + return { status: "ok", summary }; } } else if (resolvedDelivery.channel === "telegram") { if (!resolvedDelivery.to) { - if (!bestEffortDeliver) { + if (!bestEffortDeliver) return { status: "error", - summary: "Cron delivery to Telegram requires a chatId.", + summary, + error: "Cron delivery to Telegram requires a chatId.", }; - } return { status: "skipped", summary: "Delivery skipped (no Telegram chatId).", @@ -337,13 +339,9 @@ export async function runCronIsolatedAgentTurn(params: { } } } catch (err) { - if (!bestEffortDeliver) throw err; - return { - status: "ok", - summary: summary - ? `${summary} (delivery failed)` - : "completed (delivery failed)", - }; + if (!bestEffortDeliver) + return { status: "error", summary, error: String(err) }; + return { status: "ok", summary }; } } } diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts index c2492ac88..158efda09 100644 --- a/src/cron/run-log.test.ts +++ b/src/cron/run-log.test.ts @@ -73,6 +73,7 @@ describe("cron run log", () => { action: "finished", status: "error", error: "nope", + summary: "oops", }); await appendCronRunLog(logPath, { ts: 3, @@ -93,6 +94,12 @@ describe("cron run log", () => { const lastOne = await readCronRunLogEntries(logPath, { limit: 1 }); expect(lastOne.map((e) => e.ts)).toEqual([3]); + const onlyB = await readCronRunLogEntries(logPath, { + limit: 10, + jobId: "b", + }); + expect(onlyB[0]?.summary).toBe("oops"); + await fs.rm(dir, { recursive: true, force: true }); }); }); diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 4dc247325..444759b57 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -7,6 +7,7 @@ export type CronRunLogEntry = { action: "finished"; status?: "ok" | "error" | "skipped"; error?: string; + summary?: string; runAtMs?: number; durationMs?: number; nextRunAtMs?: number; diff --git a/src/cron/service.test.ts b/src/cron/service.test.ts index 656ccf86d..11ea73b6b 100644 --- a/src/cron/service.test.ts +++ b/src/cron/service.test.ts @@ -117,6 +117,173 @@ describe("CronService", () => { await store.cleanup(); }); + it("posts last output to main even when isolated job errors", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestReplyHeartbeatNow = vi.fn(); + const runIsolatedAgentJob = vi.fn(async () => ({ + status: "error" as const, + summary: "last output", + error: "boom", + })); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestReplyHeartbeatNow, + runIsolatedAgentJob, + }); + + await cron.start(); + const atMs = Date.parse("2025-12-13T00:00:01.000Z"); + await cron.add({ + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "do it", deliver: false }, + }); + + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Cron (error): last output", + ); + expect(requestReplyHeartbeatNow).toHaveBeenCalled(); + cron.stop(); + await store.cleanup(); + }); + + it("rejects unsupported session/payload combinations", async () => { + const store = await makeStorePath(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestReplyHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + + await expect( + cron.add({ + enabled: true, + schedule: { kind: "every", everyMs: 1000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "nope" }, + }), + ).rejects.toThrow(/main cron jobs require/); + + await expect( + cron.add({ + enabled: true, + schedule: { kind: "every", everyMs: 1000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "nope" }, + }), + ).rejects.toThrow(/isolated cron jobs require/); + + cron.stop(); + await store.cleanup(); + }); + + it("skips invalid main jobs with agentTurn payloads from disk", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestReplyHeartbeatNow = vi.fn(); + + const atMs = Date.parse("2025-12-13T00:00:01.000Z"); + await fs.writeFile( + store.storePath, + JSON.stringify({ + version: 1, + jobs: [ + { + id: "job-1", + enabled: true, + createdAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + updatedAtMs: Date.parse("2025-12-13T00:00:00.000Z"), + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "agentTurn", message: "bad" }, + state: {}, + }, + ], + }), + ); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestReplyHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestReplyHeartbeatNow).not.toHaveBeenCalled(); + + const jobs = await cron.list({ includeDisabled: true }); + expect(jobs[0]?.state.lastStatus).toBe("skipped"); + expect(jobs[0]?.state.lastError).toMatch(/main job requires/i); + + cron.stop(); + await store.cleanup(); + }); + + it("skips main jobs with empty systemEvent text", async () => { + const store = await makeStorePath(); + const enqueueSystemEvent = vi.fn(); + const requestReplyHeartbeatNow = vi.fn(); + + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent, + requestReplyHeartbeatNow, + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), + }); + + await cron.start(); + const atMs = Date.parse("2025-12-13T00:00:01.000Z"); + await cron.add({ + enabled: true, + schedule: { kind: "at", atMs }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: " " }, + }); + + vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); + await vi.runOnlyPendingTimersAsync(); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(requestReplyHeartbeatNow).not.toHaveBeenCalled(); + + const jobs = await cron.list({ includeDisabled: true }); + expect(jobs[0]?.state.lastStatus).toBe("skipped"); + expect(jobs[0]?.state.lastError).toMatch(/non-empty/i); + + cron.stop(); + await store.cleanup(); + }); + it("does not schedule timers when cron is disabled", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); diff --git a/src/cron/service.ts b/src/cron/service.ts index 828194ba9..79e70df8f 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -17,6 +17,7 @@ export type CronEvent = { durationMs?: number; status?: "ok" | "error" | "skipped"; error?: string; + summary?: string; nextRunAtMs?: number; }; @@ -34,10 +35,11 @@ export type CronServiceDeps = { cronEnabled: boolean; enqueueSystemEvent: (text: string) => void; requestReplyHeartbeatNow: (opts?: { reason?: string }) => void; - runIsolatedAgentJob: (params: { - job: CronJob; - message: string; - }) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string }>; + runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{ + status: "ok" | "error" | "skipped"; + summary?: string; + error?: string; + }>; onEvent?: (evt: CronEvent) => void; }; @@ -142,6 +144,7 @@ export class CronService { ...input.state, }, }; + this.assertSupportedJobSpec(job); job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now); this.store?.jobs.push(job); await this.persist(); @@ -173,6 +176,7 @@ export class CronService { if (patch.state) job.state = { ...job.state, ...patch.state }; job.updatedAtMs = now; + this.assertSupportedJobSpec(job); if (job.enabled) { job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now); } else { @@ -397,14 +401,17 @@ export class CronService { action: "finished", status, error: err, + summary, runAtMs: startedAt, durationMs: job.state.lastDurationMs, nextRunAtMs: job.state.nextRunAtMs, }); - if (summary && job.sessionTarget === "isolated") { + if (job.sessionTarget === "isolated") { const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron"; - this.deps.enqueueSystemEvent(`${prefix}: ${summary}`); + const body = (summary ?? err ?? status).trim(); + const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`; + this.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`); if (job.wakeMode === "now") { this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` }); } @@ -413,12 +420,26 @@ export class CronService { try { if (job.sessionTarget === "main") { + if (job.payload.kind !== "systemEvent") { + await finish( + "skipped", + 'main job requires payload.kind="systemEvent"', + ); + return; + } const text = normalizePayloadToSystemText(job.payload); + if (!text) { + await finish( + "skipped", + "main job requires non-empty systemEvent text", + ); + return; + } this.deps.enqueueSystemEvent(text); if (job.wakeMode === "now") { this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` }); } - await finish("ok"); + await finish("ok", undefined, text); return; } @@ -434,7 +455,7 @@ export class CronService { if (res.status === "ok") await finish("ok", undefined, res.summary); else if (res.status === "skipped") await finish("skipped", undefined, res.summary); - else await finish("error", res.summary ?? "cron job failed"); + else await finish("error", res.error ?? "cron job failed", res.summary); } catch (err) { await finish("error", String(err)); } finally { @@ -456,4 +477,15 @@ export class CronService { /* ignore */ } } + + private assertSupportedJobSpec( + job: Pick, + ) { + if (job.sessionTarget === "main" && job.payload.kind !== "systemEvent") { + throw new Error('main cron jobs require payload.kind="systemEvent"'); + } + if (job.sessionTarget === "isolated" && job.payload.kind !== "agentTurn") { + throw new Error('isolated cron jobs require payload.kind="agentTurn"'); + } + } } diff --git a/src/cron/types.ts b/src/cron/types.ts index 4786c7ba1..f0f73cfe9 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -20,7 +20,6 @@ export type CronPayload = }; export type CronIsolation = { - postToMain?: boolean; postToMainPrefix?: string; }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 0721426d0..90916f69b 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -299,7 +299,6 @@ export const CronPayloadSchema = Type.Union([ export const CronIsolationSchema = Type.Object( { - postToMain: Type.Optional(Type.Boolean()), postToMainPrefix: Type.Optional(Type.String()), }, { additionalProperties: false }, @@ -411,6 +410,7 @@ export const CronRunLogEntrySchema = Type.Object( ]), ), error: Type.Optional(Type.String()), + summary: Type.Optional(Type.String()), runAtMs: Type.Optional(Type.Integer({ minimum: 0 })), durationMs: Type.Optional(Type.Integer({ minimum: 0 })), nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 772d30e2d..7143c06ae 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -551,10 +551,12 @@ describe("gateway server", () => { jobId?: unknown; action?: unknown; status?: unknown; + summary?: unknown; }; expect(last.action).toBe("finished"); expect(last.jobId).toBe(jobId); expect(last.status).toBe("ok"); + expect(last.summary).toBe("hello"); ws.send( JSON.stringify({ @@ -573,6 +575,9 @@ describe("gateway server", () => { const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; expect(Array.isArray(entries)).toBe(true); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); + expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe( + "hello", + ); ws.close(); await server.close(); @@ -654,9 +659,11 @@ describe("gateway server", () => { const last = JSON.parse(line ?? "{}") as { jobId?: unknown; action?: unknown; + summary?: unknown; }; expect(last.action).toBe("finished"); expect(last.jobId).toBe(jobId); + expect(last.summary).toBe("hello"); ws.send( JSON.stringify({ @@ -675,6 +682,9 @@ describe("gateway server", () => { const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; expect(Array.isArray(entries)).toBe(true); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); + expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe( + "hello", + ); ws.close(); await server.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index a71eac26e..4ec6eb20a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -418,6 +418,7 @@ export async function startGatewayServer( action: "finished", status: evt.status, error: evt.error, + summary: evt.summary, runAtMs: evt.runAtMs, durationMs: evt.durationMs, nextRunAtMs: evt.nextRunAtMs,