Cron: add scheduler, wakeups, and run history
This commit is contained in:
@@ -9,6 +9,21 @@ import {
|
||||
ChatSendParamsSchema,
|
||||
type ConnectParams,
|
||||
ConnectParamsSchema,
|
||||
type CronAddParams,
|
||||
CronAddParamsSchema,
|
||||
type CronJob,
|
||||
CronJobSchema,
|
||||
type CronListParams,
|
||||
CronListParamsSchema,
|
||||
type CronRemoveParams,
|
||||
CronRemoveParamsSchema,
|
||||
type CronRunLogEntry,
|
||||
type CronRunParams,
|
||||
CronRunParamsSchema,
|
||||
type CronRunsParams,
|
||||
CronRunsParamsSchema,
|
||||
type CronUpdateParams,
|
||||
CronUpdateParamsSchema,
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
ErrorShapeSchema,
|
||||
@@ -36,6 +51,8 @@ import {
|
||||
StateVersionSchema,
|
||||
type TickEvent,
|
||||
TickEventSchema,
|
||||
type WakeParams,
|
||||
WakeParamsSchema,
|
||||
} from "./schema.js";
|
||||
|
||||
const ajv = new (
|
||||
@@ -54,6 +71,21 @@ export const validateRequestFrame =
|
||||
ajv.compile<RequestFrame>(RequestFrameSchema);
|
||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
|
||||
export const validateCronListParams =
|
||||
ajv.compile<CronListParams>(CronListParamsSchema);
|
||||
export const validateCronAddParams =
|
||||
ajv.compile<CronAddParams>(CronAddParamsSchema);
|
||||
export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(
|
||||
CronUpdateParamsSchema,
|
||||
);
|
||||
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(
|
||||
CronRemoveParamsSchema,
|
||||
);
|
||||
export const validateCronRunParams =
|
||||
ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||
export const validateCronRunsParams =
|
||||
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||
@@ -80,6 +112,14 @@ export {
|
||||
ChatEventSchema,
|
||||
SendParamsSchema,
|
||||
AgentParamsSchema,
|
||||
WakeParamsSchema,
|
||||
CronJobSchema,
|
||||
CronListParamsSchema,
|
||||
CronAddParamsSchema,
|
||||
CronUpdateParamsSchema,
|
||||
CronRemoveParamsSchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
TickEventSchema,
|
||||
@@ -105,4 +145,13 @@ export type {
|
||||
ChatEvent,
|
||||
TickEvent,
|
||||
ShutdownEvent,
|
||||
WakeParams,
|
||||
CronJob,
|
||||
CronListParams,
|
||||
CronAddParams,
|
||||
CronUpdateParams,
|
||||
CronRemoveParams,
|
||||
CronRunParams,
|
||||
CronRunsParams,
|
||||
CronRunLogEntry,
|
||||
};
|
||||
|
||||
@@ -203,6 +203,185 @@ export const AgentParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const WakeParamsSchema = Type.Object(
|
||||
{
|
||||
mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
|
||||
text: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronScheduleSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
kind: Type.Literal("at"),
|
||||
atMs: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
kind: Type.Literal("every"),
|
||||
everyMs: Type.Integer({ minimum: 1 }),
|
||||
anchorMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
kind: Type.Literal("cron"),
|
||||
expr: NonEmptyString,
|
||||
tz: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
||||
export const CronPayloadSchema = Type.Union([
|
||||
Type.Object(
|
||||
{
|
||||
kind: Type.Literal("systemEvent"),
|
||||
text: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
kind: Type.Literal("agentTurn"),
|
||||
message: NonEmptyString,
|
||||
thinking: Type.Optional(Type.String()),
|
||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
deliver: Type.Optional(Type.Boolean()),
|
||||
channel: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("last"),
|
||||
Type.Literal("whatsapp"),
|
||||
Type.Literal("telegram"),
|
||||
]),
|
||||
),
|
||||
to: Type.Optional(Type.String()),
|
||||
bestEffortDeliver: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
]);
|
||||
|
||||
export const CronIsolationSchema = Type.Object(
|
||||
{
|
||||
postToMain: Type.Optional(Type.Boolean()),
|
||||
postToMainPrefix: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronJobStateSchema = Type.Object(
|
||||
{
|
||||
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
lastStatus: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("error"),
|
||||
Type.Literal("skipped"),
|
||||
]),
|
||||
),
|
||||
lastError: Type.Optional(Type.String()),
|
||||
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronJobSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
name: Type.Optional(Type.String()),
|
||||
enabled: Type.Boolean(),
|
||||
createdAtMs: Type.Integer({ minimum: 0 }),
|
||||
updatedAtMs: Type.Integer({ minimum: 0 }),
|
||||
schedule: CronScheduleSchema,
|
||||
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]),
|
||||
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
|
||||
payload: CronPayloadSchema,
|
||||
isolation: Type.Optional(CronIsolationSchema),
|
||||
state: CronJobStateSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronListParamsSchema = Type.Object(
|
||||
{
|
||||
includeDisabled: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronAddParamsSchema = Type.Object(
|
||||
{
|
||||
name: Type.Optional(Type.String()),
|
||||
enabled: 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")]),
|
||||
payload: CronPayloadSchema,
|
||||
isolation: Type.Optional(CronIsolationSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronUpdateParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
patch: Type.Partial(CronAddParamsSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronRemoveParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronRunParamsSchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
mode: Type.Optional(
|
||||
Type.Union([Type.Literal("due"), Type.Literal("force")]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronRunsParamsSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(NonEmptyString),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const CronRunLogEntrySchema = Type.Object(
|
||||
{
|
||||
ts: Type.Integer({ minimum: 0 }),
|
||||
jobId: NonEmptyString,
|
||||
action: Type.Literal("finished"),
|
||||
status: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("error"),
|
||||
Type.Literal("skipped"),
|
||||
]),
|
||||
),
|
||||
error: 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 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
// WebChat/WebSocket-native chat methods
|
||||
export const ChatHistoryParamsSchema = Type.Object(
|
||||
{
|
||||
@@ -256,6 +435,15 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
AgentEvent: AgentEventSchema,
|
||||
SendParams: SendParamsSchema,
|
||||
AgentParams: AgentParamsSchema,
|
||||
WakeParams: WakeParamsSchema,
|
||||
CronJob: CronJobSchema,
|
||||
CronListParams: CronListParamsSchema,
|
||||
CronAddParams: CronAddParamsSchema,
|
||||
CronUpdateParams: CronUpdateParamsSchema,
|
||||
CronRemoveParams: CronRemoveParamsSchema,
|
||||
CronRunParams: CronRunParamsSchema,
|
||||
CronRunsParams: CronRunsParamsSchema,
|
||||
CronRunLogEntry: CronRunLogEntrySchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatEvent: ChatEventSchema,
|
||||
@@ -276,6 +464,15 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||
export type WakeParams = Static<typeof WakeParamsSchema>;
|
||||
export type CronJob = Static<typeof CronJobSchema>;
|
||||
export type CronListParams = Static<typeof CronListParamsSchema>;
|
||||
export type CronAddParams = Static<typeof CronAddParamsSchema>;
|
||||
export type CronUpdateParams = Static<typeof CronUpdateParamsSchema>;
|
||||
export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
|
||||
export type CronRunParams = Static<typeof CronRunParamsSchema>;
|
||||
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
export type TickEvent = Static<typeof TickEventSchema>;
|
||||
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { startGatewayServer } from "./server.js";
|
||||
|
||||
let testSessionStorePath: string | undefined;
|
||||
let testAllowFrom: string[] | undefined;
|
||||
let testCronStorePath: string | undefined;
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
inbound: {
|
||||
@@ -24,6 +25,7 @@ vi.mock("../config/config.js", () => ({
|
||||
session: { mainKey: "main", store: testSessionStorePath },
|
||||
},
|
||||
},
|
||||
cron: { enabled: false, store: testCronStorePath },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -173,6 +175,273 @@ async function connectOk(
|
||||
}
|
||||
|
||||
describe("gateway server", () => {
|
||||
test("supports cron.add and cron.list", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-cron-"));
|
||||
testCronStorePath = path.join(dir, "cron.json");
|
||||
await fs.writeFile(
|
||||
testCronStorePath,
|
||||
JSON.stringify({ version: 1, jobs: [] }),
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-add-1",
|
||||
method: "cron.add",
|
||||
params: {
|
||||
name: "daily",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const addRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-add-1");
|
||||
expect(addRes.ok).toBe(true);
|
||||
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe(
|
||||
"string",
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-list-1",
|
||||
method: "cron.list",
|
||||
params: { includeDisabled: true },
|
||||
}),
|
||||
);
|
||||
const listRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-list-1");
|
||||
expect(listRes.ok).toBe(true);
|
||||
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
|
||||
expect(Array.isArray(jobs)).toBe(true);
|
||||
expect((jobs as unknown[]).length).toBe(1);
|
||||
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe(
|
||||
"daily",
|
||||
);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
testCronStorePath = undefined;
|
||||
});
|
||||
|
||||
test("writes cron run history for flat store paths", async () => {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-gw-cron-log-"),
|
||||
);
|
||||
testCronStorePath = path.join(dir, "cron.json");
|
||||
await fs.writeFile(
|
||||
testCronStorePath,
|
||||
JSON.stringify({ version: 1, jobs: [] }),
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const atMs = Date.now() - 1;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-add-log-1",
|
||||
method: "cron.add",
|
||||
params: {
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const addRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1");
|
||||
expect(addRes.ok).toBe(true);
|
||||
const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? "");
|
||||
expect(jobId.length > 0).toBe(true);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-run-log-1",
|
||||
method: "cron.run",
|
||||
params: { id: jobId, mode: "force" },
|
||||
}),
|
||||
);
|
||||
const runRes = await onceMessage<{ type: "res"; ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "cron-run-log-1",
|
||||
8000,
|
||||
);
|
||||
expect(runRes.ok).toBe(true);
|
||||
|
||||
const logPath = path.join(dir, "cron.runs.jsonl");
|
||||
const waitForLog = async () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
|
||||
if (raw.trim().length > 0) return raw;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for cron run log");
|
||||
};
|
||||
|
||||
const raw = await waitForLog();
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
const last = JSON.parse(lines.at(-1) ?? "{}") as {
|
||||
jobId?: unknown;
|
||||
action?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
expect(last.action).toBe("finished");
|
||||
expect(last.jobId).toBe(jobId);
|
||||
expect(last.status).toBe("ok");
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-runs-1",
|
||||
method: "cron.runs",
|
||||
params: { id: jobId, limit: 50 },
|
||||
}),
|
||||
);
|
||||
const runsRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-runs-1", 8000);
|
||||
expect(runsRes.ok).toBe(true);
|
||||
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);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
testCronStorePath = undefined;
|
||||
});
|
||||
|
||||
test("writes cron run history to per-job runs/ when store is jobs.json", async () => {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "clawdis-gw-cron-log-jobs-"),
|
||||
);
|
||||
const cronDir = path.join(dir, "cron");
|
||||
testCronStorePath = path.join(cronDir, "jobs.json");
|
||||
await fs.mkdir(cronDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
testCronStorePath,
|
||||
JSON.stringify({ version: 1, jobs: [] }),
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const atMs = Date.now() - 1;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-add-log-2",
|
||||
method: "cron.add",
|
||||
params: {
|
||||
enabled: true,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const addRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2");
|
||||
expect(addRes.ok).toBe(true);
|
||||
const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? "");
|
||||
expect(jobId.length > 0).toBe(true);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-run-log-2",
|
||||
method: "cron.run",
|
||||
params: { id: jobId, mode: "force" },
|
||||
}),
|
||||
);
|
||||
const runRes = await onceMessage<{ type: "res"; ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === "cron-run-log-2",
|
||||
8000,
|
||||
);
|
||||
expect(runRes.ok).toBe(true);
|
||||
|
||||
const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`);
|
||||
const waitForLog = async () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
|
||||
if (raw.trim().length > 0) return raw;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for per-job cron run log");
|
||||
};
|
||||
|
||||
const raw = await waitForLog();
|
||||
const line = raw
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
const last = JSON.parse(line ?? "{}") as {
|
||||
jobId?: unknown;
|
||||
action?: unknown;
|
||||
};
|
||||
expect(last.action).toBe("finished");
|
||||
expect(last.jobId).toBe(jobId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "cron-runs-2",
|
||||
method: "cron.runs",
|
||||
params: { id: jobId, limit: 20 },
|
||||
}),
|
||||
);
|
||||
const runsRes = await onceMessage<{
|
||||
type: "res";
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "res" && o.id === "cron-runs-2", 8000);
|
||||
expect(runsRes.ok).toBe(true);
|
||||
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);
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
testCronStorePath = undefined;
|
||||
});
|
||||
|
||||
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
|
||||
type HeartbeatPayload = {
|
||||
ts: number;
|
||||
@@ -196,16 +465,7 @@ describe("gateway server", () => {
|
||||
};
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
||||
caps: [],
|
||||
}),
|
||||
);
|
||||
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||
await connectOk(ws);
|
||||
|
||||
const waitHeartbeat = onceMessage<EventFrame>(
|
||||
ws,
|
||||
@@ -631,13 +891,18 @@ describe("gateway server", () => {
|
||||
await connectOk(ws);
|
||||
|
||||
// Emit a fake agent event directly through the shared emitter.
|
||||
const runId = randomUUID();
|
||||
const evtPromise = onceMessage(
|
||||
ws,
|
||||
(o) => o.type === "event" && o.event === "agent",
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "agent" &&
|
||||
o.payload?.runId === runId &&
|
||||
o.payload?.stream === "job",
|
||||
);
|
||||
emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
|
||||
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } });
|
||||
const evt = await evtPromise;
|
||||
expect(evt.payload.runId).toBe("run-1");
|
||||
expect(evt.payload.runId).toBe(runId);
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
expect(evt.payload.data.msg).toBe("hi");
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
|
||||
import {
|
||||
appendCronRunLog,
|
||||
readCronRunLogEntries,
|
||||
resolveCronRunLogPath,
|
||||
} from "../cron/run-log.js";
|
||||
import { CronService } from "../cron/service.js";
|
||||
import { resolveCronStorePath } from "../cron/store.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "../cron/types.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { GatewayLockError } from "../infra/gateway-lock.js";
|
||||
@@ -33,7 +42,12 @@ import {
|
||||
upsertPresence,
|
||||
} from "../infra/system-presence.js";
|
||||
import { logError, logInfo, logWarn } from "../logger.js";
|
||||
import { getLogger, getResolvedLoggerSettings } from "../logging.js";
|
||||
import {
|
||||
getChildLogger,
|
||||
getLogger,
|
||||
getResolvedLoggerSettings,
|
||||
} from "../logging.js";
|
||||
import { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { monitorTelegramProvider } from "../telegram/monitor.js";
|
||||
@@ -41,6 +55,7 @@ import { sendMessageTelegram } from "../telegram/send.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
|
||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||
import {
|
||||
@@ -56,8 +71,15 @@ import {
|
||||
validateChatHistoryParams,
|
||||
validateChatSendParams,
|
||||
validateConnectParams,
|
||||
validateCronAddParams,
|
||||
validateCronListParams,
|
||||
validateCronRemoveParams,
|
||||
validateCronRunParams,
|
||||
validateCronRunsParams,
|
||||
validateCronUpdateParams,
|
||||
validateRequestFrame,
|
||||
validateSendParams,
|
||||
validateWakeParams,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
type Client = {
|
||||
@@ -72,6 +94,13 @@ const METHODS = [
|
||||
"status",
|
||||
"last-heartbeat",
|
||||
"set-heartbeats",
|
||||
"wake",
|
||||
"cron.list",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
"system-presence",
|
||||
"system-event",
|
||||
"send",
|
||||
@@ -89,6 +118,7 @@ const EVENTS = [
|
||||
"shutdown",
|
||||
"health",
|
||||
"heartbeat",
|
||||
"cron",
|
||||
];
|
||||
|
||||
export type GatewayServer = {
|
||||
@@ -322,6 +352,59 @@ export async function startGatewayServer(
|
||||
const providerAbort = new AbortController();
|
||||
const providerTasks: Array<Promise<unknown>> = [];
|
||||
const clients = new Set<Client>();
|
||||
const cfgAtStart = loadConfig();
|
||||
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
|
||||
|
||||
const cronStorePath = resolveCronStorePath(cfgAtStart.cron?.store);
|
||||
const cronLogger = getChildLogger({
|
||||
module: "cron",
|
||||
storePath: cronStorePath,
|
||||
});
|
||||
const cronDeps = createDefaultDeps();
|
||||
const cronEnabled =
|
||||
process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled === true;
|
||||
const cron = new CronService({
|
||||
storePath: cronStorePath,
|
||||
cronEnabled,
|
||||
enqueueSystemEvent,
|
||||
requestReplyHeartbeatNow,
|
||||
runIsolatedAgentJob: async ({ job, message }) => {
|
||||
const cfg = loadConfig();
|
||||
return await runCronIsolatedAgentTurn({
|
||||
cfg,
|
||||
deps: cronDeps,
|
||||
job,
|
||||
message,
|
||||
sessionKey: `cron:${job.id}`,
|
||||
lane: "cron",
|
||||
});
|
||||
},
|
||||
log: cronLogger,
|
||||
onEvent: (evt) => {
|
||||
broadcast("cron", evt, { dropIfSlow: true });
|
||||
if (evt.action === "finished") {
|
||||
const logPath = resolveCronRunLogPath({
|
||||
storePath: cronStorePath,
|
||||
jobId: evt.jobId,
|
||||
});
|
||||
void appendCronRunLog(logPath, {
|
||||
ts: Date.now(),
|
||||
jobId: evt.jobId,
|
||||
action: "finished",
|
||||
status: evt.status,
|
||||
error: evt.error,
|
||||
runAtMs: evt.runAtMs,
|
||||
durationMs: evt.durationMs,
|
||||
nextRunAtMs: evt.nextRunAtMs,
|
||||
}).catch((err) => {
|
||||
cronLogger.warn(
|
||||
{ err: String(err), logPath },
|
||||
"cron: run log append failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const startProviders = async () => {
|
||||
const cfg = loadConfig();
|
||||
@@ -513,6 +596,10 @@ export async function startGatewayServer(
|
||||
broadcast("heartbeat", evt, { dropIfSlow: true });
|
||||
});
|
||||
|
||||
void cron
|
||||
.start()
|
||||
.catch((err) => logError(`cron failed to start: ${String(err)}`));
|
||||
|
||||
wss.on("connection", (socket) => {
|
||||
let client: Client | null = null;
|
||||
let closed = false;
|
||||
@@ -988,6 +1075,157 @@ export async function startGatewayServer(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "wake": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateWakeParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid wake params: ${formatValidationErrors(validateWakeParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as {
|
||||
mode: "now" | "next-heartbeat";
|
||||
text: string;
|
||||
};
|
||||
const result = cron.wake({ mode: p.mode, text: p.text });
|
||||
respond(true, result, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.list": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronListParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.list params: ${formatValidationErrors(validateCronListParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as { includeDisabled?: boolean };
|
||||
const jobs = await cron.list({
|
||||
includeDisabled: p.includeDisabled,
|
||||
});
|
||||
respond(true, { jobs }, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.add": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronAddParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.add params: ${formatValidationErrors(validateCronAddParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const job = await cron.add(params as unknown as CronJobCreate);
|
||||
respond(true, job, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.update": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronUpdateParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.update params: ${formatValidationErrors(validateCronUpdateParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as { id: string; patch: Record<string, unknown> };
|
||||
const job = await cron.update(
|
||||
p.id,
|
||||
p.patch as unknown as CronJobPatch,
|
||||
);
|
||||
respond(true, job, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.remove": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronRemoveParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.remove params: ${formatValidationErrors(validateCronRemoveParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as { id: string };
|
||||
const result = await cron.remove(p.id);
|
||||
respond(true, result, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.run": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronRunParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.run params: ${formatValidationErrors(validateCronRunParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as { id: string; mode?: "due" | "force" };
|
||||
const result = await cron.run(p.id, p.mode);
|
||||
respond(true, result, undefined);
|
||||
break;
|
||||
}
|
||||
case "cron.runs": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateCronRunsParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid cron.runs params: ${formatValidationErrors(validateCronRunsParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const p = params as { id?: string; limit?: number };
|
||||
if (!p.id && cronStorePath.endsWith(`${path.sep}jobs.json`)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"cron.runs requires id when using jobs.json store layout",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const logPath = resolveCronRunLogPath({
|
||||
storePath: cronStorePath,
|
||||
jobId: p.id ?? "all",
|
||||
});
|
||||
const entries = await readCronRunLogEntries(logPath, {
|
||||
limit: p.limit,
|
||||
jobId: p.id,
|
||||
});
|
||||
respond(true, { entries }, undefined);
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
const status = await getStatusSummary();
|
||||
respond(true, status, undefined);
|
||||
@@ -1426,6 +1664,7 @@ export async function startGatewayServer(
|
||||
return {
|
||||
close: async () => {
|
||||
providerAbort.abort();
|
||||
cron.stop();
|
||||
broadcast("shutdown", {
|
||||
reason: "gateway stopping",
|
||||
restartExpectedMs: null,
|
||||
|
||||
Reference in New Issue
Block a user