feat(cron): post isolated summaries

This commit is contained in:
Peter Steinberger
2025-12-13 12:09:15 +00:00
parent 32cd1175fb
commit c02613e15f
12 changed files with 462 additions and 52 deletions

View File

@@ -654,6 +654,7 @@ public struct CronRunLogEntry: Codable {
public let action: String public let action: String
public let status: AnyCodable? public let status: AnyCodable?
public let error: String? public let error: String?
public let summary: String?
public let runatms: Int? public let runatms: Int?
public let durationms: Int? public let durationms: Int?
public let nextrunatms: Int? public let nextrunatms: Int?
@@ -664,6 +665,7 @@ public struct CronRunLogEntry: Codable {
action: String, action: String,
status: AnyCodable?, status: AnyCodable?,
error: String?, error: String?,
summary: String?,
runatms: Int?, runatms: Int?,
durationms: Int?, durationms: Int?,
nextrunatms: Int? nextrunatms: Int?
@@ -673,6 +675,7 @@ public struct CronRunLogEntry: Codable {
self.action = action self.action = action
self.status = status self.status = status
self.error = error self.error = error
self.summary = summary
self.runatms = runatms self.runatms = runatms
self.durationms = durationms self.durationms = durationms
self.nextrunatms = nextrunatms self.nextrunatms = nextrunatms
@@ -683,6 +686,7 @@ public struct CronRunLogEntry: Codable {
case action case action
case status case status
case error case error
case summary
case runatms = "runAtMs" case runatms = "runAtMs"
case durationms = "durationMs" case durationms = "durationMs"
case nextrunatms = "nextRunAtMs" case nextrunatms = "nextRunAtMs"

View File

@@ -1093,9 +1093,6 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"properties": { "properties": {
"postToMain": {
"type": "boolean"
},
"postToMainPrefix": { "postToMainPrefix": {
"type": "string" "type": "string"
} }
@@ -1344,9 +1341,6 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"properties": { "properties": {
"postToMain": {
"type": "boolean"
},
"postToMainPrefix": { "postToMainPrefix": {
"type": "string" "type": "string"
} }
@@ -1543,9 +1537,6 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"properties": { "properties": {
"postToMain": {
"type": "boolean"
},
"postToMainPrefix": { "postToMainPrefix": {
"type": "string" "type": "string"
} }
@@ -1647,6 +1638,9 @@
"error": { "error": {
"type": "string" "type": "string"
}, },
"summary": {
"type": "string"
},
"runAtMs": { "runAtMs": {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"

View File

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

View File

@@ -23,6 +23,7 @@ import type { CronJob } from "./types.js";
export type RunCronAgentTurnResult = { export type RunCronAgentTurnResult = {
status: "ok" | "error" | "skipped"; status: "ok" | "error" | "skipped";
summary?: string; summary?: string;
error?: string;
}; };
function assertCommandReplyConfig(cfg: ClawdisConfig) { function assertCommandReplyConfig(cfg: ClawdisConfig) {
@@ -241,19 +242,24 @@ export async function runCronIsolatedAgentTurn(params: {
const lane = params.lane?.trim() || "cron"; const lane = params.lane?.trim() || "cron";
const runResult = await runCommandReply({ let runResult: Awaited<ReturnType<typeof runCommandReply>>;
reply: { ...replyCfg, mode: "command" }, try {
templatingCtx, runResult = await runCommandReply({
sendSystemOnce, reply: { ...replyCfg, mode: "command" },
isNewSession: cronSession.isNewSession, templatingCtx,
isFirstTurnInSession, sendSystemOnce,
systemSent: cronSession.sessionEntry.systemSent ?? false, isNewSession: cronSession.isNewSession,
timeoutMs, isFirstTurnInSession,
timeoutSeconds, systemSent: cronSession.sessionEntry.systemSent ?? false,
thinkLevel, timeoutMs,
enqueue: (task, opts) => enqueueCommandInLane(lane, task, opts), timeoutSeconds,
runId: cronSession.sessionEntry.sessionId, 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 payloads = runResult.payloads ?? [];
const firstText = payloads[0]?.text ?? ""; const firstText = payloads[0]?.text ?? "";
@@ -263,12 +269,12 @@ export async function runCronIsolatedAgentTurn(params: {
if (delivery) { if (delivery) {
if (resolvedDelivery.channel === "whatsapp") { if (resolvedDelivery.channel === "whatsapp") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) { if (!bestEffortDeliver)
return { return {
status: "error", status: "error",
summary: "Cron delivery to WhatsApp requires a recipient.", summary,
error: "Cron delivery to WhatsApp requires a recipient.",
}; };
}
return { return {
status: "skipped", status: "skipped",
summary: "Delivery skipped (no WhatsApp recipient).", summary: "Delivery skipped (no WhatsApp recipient).",
@@ -292,22 +298,18 @@ export async function runCronIsolatedAgentTurn(params: {
} }
} }
} catch (err) { } catch (err) {
if (!bestEffortDeliver) throw err; if (!bestEffortDeliver)
return { return { status: "error", summary, error: String(err) };
status: "ok", return { status: "ok", summary };
summary: summary
? `${summary} (delivery failed)`
: "completed (delivery failed)",
};
} }
} else if (resolvedDelivery.channel === "telegram") { } else if (resolvedDelivery.channel === "telegram") {
if (!resolvedDelivery.to) { if (!resolvedDelivery.to) {
if (!bestEffortDeliver) { if (!bestEffortDeliver)
return { return {
status: "error", status: "error",
summary: "Cron delivery to Telegram requires a chatId.", summary,
error: "Cron delivery to Telegram requires a chatId.",
}; };
}
return { return {
status: "skipped", status: "skipped",
summary: "Delivery skipped (no Telegram chatId).", summary: "Delivery skipped (no Telegram chatId).",
@@ -337,13 +339,9 @@ export async function runCronIsolatedAgentTurn(params: {
} }
} }
} catch (err) { } catch (err) {
if (!bestEffortDeliver) throw err; if (!bestEffortDeliver)
return { return { status: "error", summary, error: String(err) };
status: "ok", return { status: "ok", summary };
summary: summary
? `${summary} (delivery failed)`
: "completed (delivery failed)",
};
} }
} }
} }

View File

@@ -73,6 +73,7 @@ describe("cron run log", () => {
action: "finished", action: "finished",
status: "error", status: "error",
error: "nope", error: "nope",
summary: "oops",
}); });
await appendCronRunLog(logPath, { await appendCronRunLog(logPath, {
ts: 3, ts: 3,
@@ -93,6 +94,12 @@ describe("cron run log", () => {
const lastOne = await readCronRunLogEntries(logPath, { limit: 1 }); const lastOne = await readCronRunLogEntries(logPath, { limit: 1 });
expect(lastOne.map((e) => e.ts)).toEqual([3]); 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 }); await fs.rm(dir, { recursive: true, force: true });
}); });
}); });

View File

@@ -7,6 +7,7 @@ export type CronRunLogEntry = {
action: "finished"; action: "finished";
status?: "ok" | "error" | "skipped"; status?: "ok" | "error" | "skipped";
error?: string; error?: string;
summary?: string;
runAtMs?: number; runAtMs?: number;
durationMs?: number; durationMs?: number;
nextRunAtMs?: number; nextRunAtMs?: number;

View File

@@ -117,6 +117,173 @@ describe("CronService", () => {
await store.cleanup(); 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 () => { it("does not schedule timers when cron is disabled", async () => {
const store = await makeStorePath(); const store = await makeStorePath();
const enqueueSystemEvent = vi.fn(); const enqueueSystemEvent = vi.fn();

View File

@@ -17,6 +17,7 @@ export type CronEvent = {
durationMs?: number; durationMs?: number;
status?: "ok" | "error" | "skipped"; status?: "ok" | "error" | "skipped";
error?: string; error?: string;
summary?: string;
nextRunAtMs?: number; nextRunAtMs?: number;
}; };
@@ -34,10 +35,11 @@ export type CronServiceDeps = {
cronEnabled: boolean; cronEnabled: boolean;
enqueueSystemEvent: (text: string) => void; enqueueSystemEvent: (text: string) => void;
requestReplyHeartbeatNow: (opts?: { reason?: string }) => void; requestReplyHeartbeatNow: (opts?: { reason?: string }) => void;
runIsolatedAgentJob: (params: { runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
job: CronJob; status: "ok" | "error" | "skipped";
message: string; summary?: string;
}) => Promise<{ status: "ok" | "error" | "skipped"; summary?: string }>; error?: string;
}>;
onEvent?: (evt: CronEvent) => void; onEvent?: (evt: CronEvent) => void;
}; };
@@ -142,6 +144,7 @@ export class CronService {
...input.state, ...input.state,
}, },
}; };
this.assertSupportedJobSpec(job);
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now); job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
this.store?.jobs.push(job); this.store?.jobs.push(job);
await this.persist(); await this.persist();
@@ -173,6 +176,7 @@ export class CronService {
if (patch.state) job.state = { ...job.state, ...patch.state }; if (patch.state) job.state = { ...job.state, ...patch.state };
job.updatedAtMs = now; job.updatedAtMs = now;
this.assertSupportedJobSpec(job);
if (job.enabled) { if (job.enabled) {
job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now); job.state.nextRunAtMs = this.computeJobNextRunAtMs(job, now);
} else { } else {
@@ -397,14 +401,17 @@ export class CronService {
action: "finished", action: "finished",
status, status,
error: err, error: err,
summary,
runAtMs: startedAt, runAtMs: startedAt,
durationMs: job.state.lastDurationMs, durationMs: job.state.lastDurationMs,
nextRunAtMs: job.state.nextRunAtMs, nextRunAtMs: job.state.nextRunAtMs,
}); });
if (summary && job.sessionTarget === "isolated") { if (job.sessionTarget === "isolated") {
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron"; 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") { if (job.wakeMode === "now") {
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` }); this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}:post` });
} }
@@ -413,12 +420,26 @@ export class CronService {
try { try {
if (job.sessionTarget === "main") { if (job.sessionTarget === "main") {
if (job.payload.kind !== "systemEvent") {
await finish(
"skipped",
'main job requires payload.kind="systemEvent"',
);
return;
}
const text = normalizePayloadToSystemText(job.payload); const text = normalizePayloadToSystemText(job.payload);
if (!text) {
await finish(
"skipped",
"main job requires non-empty systemEvent text",
);
return;
}
this.deps.enqueueSystemEvent(text); this.deps.enqueueSystemEvent(text);
if (job.wakeMode === "now") { if (job.wakeMode === "now") {
this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` }); this.deps.requestReplyHeartbeatNow({ reason: `cron:${job.id}` });
} }
await finish("ok"); await finish("ok", undefined, text);
return; return;
} }
@@ -434,7 +455,7 @@ export class CronService {
if (res.status === "ok") await finish("ok", undefined, res.summary); if (res.status === "ok") await finish("ok", undefined, res.summary);
else if (res.status === "skipped") else if (res.status === "skipped")
await finish("skipped", undefined, res.summary); 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) { } catch (err) {
await finish("error", String(err)); await finish("error", String(err));
} finally { } finally {
@@ -456,4 +477,15 @@ export class CronService {
/* ignore */ /* ignore */
} }
} }
private assertSupportedJobSpec(
job: Pick<CronJob, "sessionTarget" | "payload">,
) {
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"');
}
}
} }

View File

@@ -20,7 +20,6 @@ export type CronPayload =
}; };
export type CronIsolation = { export type CronIsolation = {
postToMain?: boolean;
postToMainPrefix?: string; postToMainPrefix?: string;
}; };

View File

@@ -299,7 +299,6 @@ export const CronPayloadSchema = Type.Union([
export const CronIsolationSchema = Type.Object( export const CronIsolationSchema = Type.Object(
{ {
postToMain: Type.Optional(Type.Boolean()),
postToMainPrefix: Type.Optional(Type.String()), postToMainPrefix: Type.Optional(Type.String()),
}, },
{ additionalProperties: false }, { additionalProperties: false },
@@ -411,6 +410,7 @@ export const CronRunLogEntrySchema = Type.Object(
]), ]),
), ),
error: Type.Optional(Type.String()), error: Type.Optional(Type.String()),
summary: Type.Optional(Type.String()),
runAtMs: Type.Optional(Type.Integer({ minimum: 0 })), runAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
durationMs: Type.Optional(Type.Integer({ minimum: 0 })), durationMs: Type.Optional(Type.Integer({ minimum: 0 })),
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),

View File

@@ -551,10 +551,12 @@ describe("gateway server", () => {
jobId?: unknown; jobId?: unknown;
action?: unknown; action?: unknown;
status?: unknown; status?: unknown;
summary?: unknown;
}; };
expect(last.action).toBe("finished"); expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId); expect(last.jobId).toBe(jobId);
expect(last.status).toBe("ok"); expect(last.status).toBe("ok");
expect(last.summary).toBe("hello");
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -573,6 +575,9 @@ describe("gateway server", () => {
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true); expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe(
"hello",
);
ws.close(); ws.close();
await server.close(); await server.close();
@@ -654,9 +659,11 @@ describe("gateway server", () => {
const last = JSON.parse(line ?? "{}") as { const last = JSON.parse(line ?? "{}") as {
jobId?: unknown; jobId?: unknown;
action?: unknown; action?: unknown;
summary?: unknown;
}; };
expect(last.action).toBe("finished"); expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId); expect(last.jobId).toBe(jobId);
expect(last.summary).toBe("hello");
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -675,6 +682,9 @@ describe("gateway server", () => {
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries; const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true); expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId); expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe(
"hello",
);
ws.close(); ws.close();
await server.close(); await server.close();

View File

@@ -418,6 +418,7 @@ export async function startGatewayServer(
action: "finished", action: "finished",
status: evt.status, status: evt.status,
error: evt.error, error: evt.error,
summary: evt.summary,
runAtMs: evt.runAtMs, runAtMs: evt.runAtMs,
durationMs: evt.durationMs, durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs, nextRunAtMs: evt.nextRunAtMs,