mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
ui(cron): add advanced controls for run-if-due and routing (#31244)
* ui(cron): add advanced run controls and routing fields * ui(cron): gate delivery account id to announce mode * ui(cron): allow clearing delivery account id in editor * cron: persist payload lightContext updates * tests(cron): fix payload lightContext assertion typing
This commit is contained in:
@@ -137,6 +137,53 @@ describe("applyJobPatch", () => {
|
|||||||
expect(job.delivery?.accountId).toBeUndefined();
|
expect(job.delivery?.accountId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists agentTurn payload.lightContext updates when editing existing jobs", () => {
|
||||||
|
const job = createIsolatedAgentTurnJob("job-light-context", {
|
||||||
|
mode: "announce",
|
||||||
|
channel: "telegram",
|
||||||
|
});
|
||||||
|
job.payload = {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "do it",
|
||||||
|
lightContext: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyJobPatch(job, {
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "do it",
|
||||||
|
lightContext: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(job.payload.kind).toBe("agentTurn");
|
||||||
|
if (job.payload.kind === "agentTurn") {
|
||||||
|
expect(job.payload.lightContext).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies payload.lightContext when replacing payload kind via patch", () => {
|
||||||
|
const job = createIsolatedAgentTurnJob("job-light-context-switch", {
|
||||||
|
mode: "announce",
|
||||||
|
channel: "telegram",
|
||||||
|
});
|
||||||
|
job.payload = { kind: "systemEvent", text: "ping" };
|
||||||
|
|
||||||
|
applyJobPatch(job, {
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
message: "do it",
|
||||||
|
lightContext: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = job.payload as CronJob["payload"];
|
||||||
|
expect(payload.kind).toBe("agentTurn");
|
||||||
|
if (payload.kind === "agentTurn") {
|
||||||
|
expect(payload.lightContext).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects webhook delivery without a valid http(s) target URL", () => {
|
it("rejects webhook delivery without a valid http(s) target URL", () => {
|
||||||
const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL";
|
const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL";
|
||||||
const cases = [
|
const cases = [
|
||||||
|
|||||||
@@ -564,6 +564,9 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
|
|||||||
if (typeof patch.timeoutSeconds === "number") {
|
if (typeof patch.timeoutSeconds === "number") {
|
||||||
next.timeoutSeconds = patch.timeoutSeconds;
|
next.timeoutSeconds = patch.timeoutSeconds;
|
||||||
}
|
}
|
||||||
|
if (typeof patch.lightContext === "boolean") {
|
||||||
|
next.lightContext = patch.lightContext;
|
||||||
|
}
|
||||||
if (typeof patch.allowUnsafeExternalContent === "boolean") {
|
if (typeof patch.allowUnsafeExternalContent === "boolean") {
|
||||||
next.allowUnsafeExternalContent = patch.allowUnsafeExternalContent;
|
next.allowUnsafeExternalContent = patch.allowUnsafeExternalContent;
|
||||||
}
|
}
|
||||||
@@ -641,6 +644,7 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
|
|||||||
model: patch.model,
|
model: patch.model,
|
||||||
thinking: patch.thinking,
|
thinking: patch.thinking,
|
||||||
timeoutSeconds: patch.timeoutSeconds,
|
timeoutSeconds: patch.timeoutSeconds,
|
||||||
|
lightContext: patch.lightContext,
|
||||||
allowUnsafeExternalContent: patch.allowUnsafeExternalContent,
|
allowUnsafeExternalContent: patch.allowUnsafeExternalContent,
|
||||||
deliver: patch.deliver,
|
deliver: patch.deliver,
|
||||||
channel: patch.channel,
|
channel: patch.channel,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
agentId: "",
|
agentId: "",
|
||||||
|
sessionKey: "",
|
||||||
clearAgent: false,
|
clearAgent: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
deleteAfterRun: true,
|
deleteAfterRun: true,
|
||||||
@@ -32,9 +33,11 @@ export const DEFAULT_CRON_FORM: CronFormState = {
|
|||||||
payloadText: "",
|
payloadText: "",
|
||||||
payloadModel: "",
|
payloadModel: "",
|
||||||
payloadThinking: "",
|
payloadThinking: "",
|
||||||
|
payloadLightContext: false,
|
||||||
deliveryMode: "announce",
|
deliveryMode: "announce",
|
||||||
deliveryChannel: "last",
|
deliveryChannel: "last",
|
||||||
deliveryTo: "",
|
deliveryTo: "",
|
||||||
|
deliveryAccountId: "",
|
||||||
deliveryBestEffort: false,
|
deliveryBestEffort: false,
|
||||||
failureAlertMode: "inherit",
|
failureAlertMode: "inherit",
|
||||||
failureAlertAfter: "2",
|
failureAlertAfter: "2",
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
...jobToSuggestions,
|
...jobToSuggestions,
|
||||||
...accountToSuggestions,
|
...accountToSuggestions,
|
||||||
]);
|
]);
|
||||||
|
const accountSuggestions = uniquePreserveOrder(accountToSuggestions);
|
||||||
const deliveryToSuggestions =
|
const deliveryToSuggestions =
|
||||||
state.cronForm.deliveryMode === "webhook"
|
state.cronForm.deliveryMode === "webhook"
|
||||||
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
|
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
|
||||||
@@ -482,6 +483,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
thinkingSuggestions: CRON_THINKING_SUGGESTIONS,
|
thinkingSuggestions: CRON_THINKING_SUGGESTIONS,
|
||||||
timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS,
|
timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS,
|
||||||
deliveryToSuggestions,
|
deliveryToSuggestions,
|
||||||
|
accountSuggestions,
|
||||||
onFormChange: (patch) => {
|
onFormChange: (patch) => {
|
||||||
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
|
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
|
||||||
state.cronFieldErrors = validateCronForm(state.cronForm);
|
state.cronFieldErrors = validateCronForm(state.cronForm);
|
||||||
@@ -492,7 +494,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
onClone: (job) => startCronClone(state, job),
|
onClone: (job) => startCronClone(state, job),
|
||||||
onCancelEdit: () => cancelCronEdit(state),
|
onCancelEdit: () => cancelCronEdit(state),
|
||||||
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
|
||||||
onRun: (job) => runCronJob(state, job),
|
onRun: (job, mode) => runCronJob(state, job, mode ?? "force"),
|
||||||
onRemove: (job) => removeCronJob(state, job),
|
onRemove: (job) => removeCronJob(state, job),
|
||||||
onLoadRuns: async (jobId) => {
|
onLoadRuns: async (jobId) => {
|
||||||
updateCronRunsFilter(state, { cronRunsScope: "job" });
|
updateCronRunsFilter(state, { cronRunsScope: "job" });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
loadCronRuns,
|
loadCronRuns,
|
||||||
loadMoreCronRuns,
|
loadMoreCronRuns,
|
||||||
normalizeCronFormState,
|
normalizeCronFormState,
|
||||||
|
runCronJob,
|
||||||
startCronEdit,
|
startCronEdit,
|
||||||
startCronClone,
|
startCronClone,
|
||||||
validateCronForm,
|
validateCronForm,
|
||||||
@@ -119,6 +120,83 @@ describe("cron controller", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards sessionKey and delivery accountId in cron.add payload", async () => {
|
||||||
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
|
if (method === "cron.add") {
|
||||||
|
return { id: "job-3" };
|
||||||
|
}
|
||||||
|
if (method === "cron.list") {
|
||||||
|
return { jobs: [] };
|
||||||
|
}
|
||||||
|
if (method === "cron.status") {
|
||||||
|
return { enabled: true, jobs: 0, nextWakeAtMs: null };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = createState({
|
||||||
|
client: { request } as unknown as CronState["client"],
|
||||||
|
cronForm: {
|
||||||
|
...DEFAULT_CRON_FORM,
|
||||||
|
name: "account-routed",
|
||||||
|
scheduleKind: "cron",
|
||||||
|
cronExpr: "0 * * * *",
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
payloadKind: "agentTurn",
|
||||||
|
payloadText: "run this",
|
||||||
|
sessionKey: "agent:ops:main",
|
||||||
|
deliveryMode: "announce",
|
||||||
|
deliveryAccountId: "ops-bot",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await addCronJob(state);
|
||||||
|
|
||||||
|
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
|
||||||
|
expect(addCall).toBeDefined();
|
||||||
|
expect(addCall?.[1]).toMatchObject({
|
||||||
|
sessionKey: "agent:ops:main",
|
||||||
|
delivery: { mode: "announce", accountId: "ops-bot" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards lightContext in cron payload", async () => {
|
||||||
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
|
if (method === "cron.add") {
|
||||||
|
return { id: "job-light" };
|
||||||
|
}
|
||||||
|
if (method === "cron.list") {
|
||||||
|
return { jobs: [] };
|
||||||
|
}
|
||||||
|
if (method === "cron.status") {
|
||||||
|
return { enabled: true, jobs: 0, nextWakeAtMs: null };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = createState({
|
||||||
|
client: { request } as unknown as CronState["client"],
|
||||||
|
cronForm: {
|
||||||
|
...DEFAULT_CRON_FORM,
|
||||||
|
name: "light-context job",
|
||||||
|
scheduleKind: "cron",
|
||||||
|
cronExpr: "0 * * * *",
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
payloadKind: "agentTurn",
|
||||||
|
payloadText: "run this",
|
||||||
|
payloadLightContext: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await addCronJob(state);
|
||||||
|
|
||||||
|
const addCall = request.mock.calls.find(([method]) => method === "cron.add");
|
||||||
|
expect(addCall).toBeDefined();
|
||||||
|
expect(addCall?.[1]).toMatchObject({
|
||||||
|
payload: { kind: "agentTurn", lightContext: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sends delivery: { mode: "none" } explicitly in cron.add payload', async () => {
|
it('sends delivery: { mode: "none" } explicitly in cron.add payload', async () => {
|
||||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
if (method === "cron.add") {
|
if (method === "cron.add") {
|
||||||
@@ -306,12 +384,74 @@ describe("cron controller", () => {
|
|||||||
expect(state.cronEditingJobId).toBeNull();
|
expect(state.cronEditingJobId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends empty delivery.accountId in cron.update to clear persisted account routing", async () => {
|
||||||
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
|
if (method === "cron.update") {
|
||||||
|
return { id: "job-clear-account-id" };
|
||||||
|
}
|
||||||
|
if (method === "cron.list") {
|
||||||
|
return { jobs: [{ id: "job-clear-account-id" }] };
|
||||||
|
}
|
||||||
|
if (method === "cron.status") {
|
||||||
|
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = createState({
|
||||||
|
client: { request } as unknown as CronState["client"],
|
||||||
|
cronEditingJobId: "job-clear-account-id",
|
||||||
|
cronJobs: [
|
||||||
|
{
|
||||||
|
id: "job-clear-account-id",
|
||||||
|
name: "clear account",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
schedule: { kind: "cron", expr: "0 * * * *" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "agentTurn", message: "run" },
|
||||||
|
delivery: { mode: "announce", accountId: "ops-bot" },
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cronForm: {
|
||||||
|
...DEFAULT_CRON_FORM,
|
||||||
|
name: "clear account",
|
||||||
|
scheduleKind: "cron",
|
||||||
|
cronExpr: "0 * * * *",
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payloadKind: "agentTurn",
|
||||||
|
payloadText: "run",
|
||||||
|
deliveryMode: "announce",
|
||||||
|
deliveryAccountId: " ",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await addCronJob(state);
|
||||||
|
|
||||||
|
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
expect(updateCall?.[1]).toMatchObject({
|
||||||
|
id: "job-clear-account-id",
|
||||||
|
patch: {
|
||||||
|
delivery: {
|
||||||
|
mode: "announce",
|
||||||
|
accountId: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps a cron job into editable form fields", () => {
|
it("maps a cron job into editable form fields", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
const job = {
|
const job = {
|
||||||
id: "job-9",
|
id: "job-9",
|
||||||
name: "Weekly report",
|
name: "Weekly report",
|
||||||
description: "desc",
|
description: "desc",
|
||||||
|
sessionKey: "agent:ops:main",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
createdAtMs: 0,
|
createdAtMs: 0,
|
||||||
updatedAtMs: 0,
|
updatedAtMs: 0,
|
||||||
@@ -319,7 +459,7 @@ describe("cron controller", () => {
|
|||||||
sessionTarget: "isolated" as const,
|
sessionTarget: "isolated" as const,
|
||||||
wakeMode: "next-heartbeat" as const,
|
wakeMode: "next-heartbeat" as const,
|
||||||
payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 },
|
payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 },
|
||||||
delivery: { mode: "announce" as const, channel: "telegram", to: "123" },
|
delivery: { mode: "announce" as const, channel: "telegram", to: "123", accountId: "bot-2" },
|
||||||
state: {},
|
state: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -328,6 +468,7 @@ describe("cron controller", () => {
|
|||||||
expect(state.cronEditingJobId).toBe("job-9");
|
expect(state.cronEditingJobId).toBe("job-9");
|
||||||
expect(state.cronRunsJobId).toBe("job-9");
|
expect(state.cronRunsJobId).toBe("job-9");
|
||||||
expect(state.cronForm.name).toBe("Weekly report");
|
expect(state.cronForm.name).toBe("Weekly report");
|
||||||
|
expect(state.cronForm.sessionKey).toBe("agent:ops:main");
|
||||||
expect(state.cronForm.enabled).toBe(false);
|
expect(state.cronForm.enabled).toBe(false);
|
||||||
expect(state.cronForm.scheduleKind).toBe("every");
|
expect(state.cronForm.scheduleKind).toBe("every");
|
||||||
expect(state.cronForm.everyAmount).toBe("2");
|
expect(state.cronForm.everyAmount).toBe("2");
|
||||||
@@ -338,6 +479,7 @@ describe("cron controller", () => {
|
|||||||
expect(state.cronForm.deliveryMode).toBe("announce");
|
expect(state.cronForm.deliveryMode).toBe("announce");
|
||||||
expect(state.cronForm.deliveryChannel).toBe("telegram");
|
expect(state.cronForm.deliveryChannel).toBe("telegram");
|
||||||
expect(state.cronForm.deliveryTo).toBe("123");
|
expect(state.cronForm.deliveryTo).toBe("123");
|
||||||
|
expect(state.cronForm.deliveryAccountId).toBe("bot-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => {
|
it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => {
|
||||||
@@ -391,6 +533,62 @@ describe("cron controller", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends lightContext=false in cron.update when clearing prior light-context setting", async () => {
|
||||||
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
|
if (method === "cron.update") {
|
||||||
|
return { id: "job-clear-light" };
|
||||||
|
}
|
||||||
|
if (method === "cron.list") {
|
||||||
|
return { jobs: [{ id: "job-clear-light" }] };
|
||||||
|
}
|
||||||
|
if (method === "cron.status") {
|
||||||
|
return { enabled: true, jobs: 1, nextWakeAtMs: null };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const state = createState({
|
||||||
|
client: { request } as unknown as CronState["client"],
|
||||||
|
cronEditingJobId: "job-clear-light",
|
||||||
|
cronJobs: [
|
||||||
|
{
|
||||||
|
id: "job-clear-light",
|
||||||
|
name: "Light job",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
schedule: { kind: "cron", expr: "0 9 * * *" },
|
||||||
|
sessionTarget: "isolated",
|
||||||
|
wakeMode: "now",
|
||||||
|
payload: { kind: "agentTurn", message: "run", lightContext: true },
|
||||||
|
state: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cronForm: {
|
||||||
|
...DEFAULT_CRON_FORM,
|
||||||
|
name: "Light job",
|
||||||
|
scheduleKind: "cron",
|
||||||
|
cronExpr: "0 9 * * *",
|
||||||
|
payloadKind: "agentTurn",
|
||||||
|
payloadText: "run",
|
||||||
|
payloadLightContext: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await addCronJob(state);
|
||||||
|
|
||||||
|
const updateCall = request.mock.calls.find(([method]) => method === "cron.update");
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
expect(updateCall?.[1]).toMatchObject({
|
||||||
|
id: "job-clear-light",
|
||||||
|
patch: {
|
||||||
|
payload: {
|
||||||
|
kind: "agentTurn",
|
||||||
|
lightContext: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("includes custom failureAlert fields in cron.update patch", async () => {
|
it("includes custom failureAlert fields in cron.update patch", async () => {
|
||||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||||
if (method === "cron.update") {
|
if (method === "cron.update") {
|
||||||
@@ -787,4 +985,38 @@ describe("cron controller", () => {
|
|||||||
expect(state.cronRuns[0]?.summary).toBe("newest");
|
expect(state.cronRuns[0]?.summary).toBe("newest");
|
||||||
expect(state.cronRuns[1]?.summary).toBe("older");
|
expect(state.cronRuns[1]?.summary).toBe("older");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs cron job in due mode when requested", async () => {
|
||||||
|
const request = vi.fn(async (method: string, payload?: unknown) => {
|
||||||
|
if (method === "cron.run") {
|
||||||
|
expect(payload).toMatchObject({ id: "job-due", mode: "due" });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
if (method === "cron.runs") {
|
||||||
|
return { entries: [], total: 0, hasMore: false, nextOffset: null };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const state = createState({
|
||||||
|
client: { request } as unknown as CronState["client"],
|
||||||
|
cronRunsScope: "job",
|
||||||
|
cronRunsJobId: "job-due",
|
||||||
|
});
|
||||||
|
const job = {
|
||||||
|
id: "job-due",
|
||||||
|
name: "Due test",
|
||||||
|
enabled: true,
|
||||||
|
createdAtMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
schedule: { kind: "cron" as const, expr: "0 * * * *" },
|
||||||
|
sessionTarget: "isolated" as const,
|
||||||
|
wakeMode: "now" as const,
|
||||||
|
payload: { kind: "agentTurn" as const, message: "run" },
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await runCronJob(state, job, "due");
|
||||||
|
|
||||||
|
expect(request).toHaveBeenCalledWith("cron.run", { id: "job-due", mode: "due" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
|
|||||||
name: job.name,
|
name: job.name,
|
||||||
description: job.description ?? "",
|
description: job.description ?? "",
|
||||||
agentId: job.agentId ?? "",
|
agentId: job.agentId ?? "",
|
||||||
|
sessionKey: job.sessionKey ?? "",
|
||||||
clearAgent: false,
|
clearAgent: false,
|
||||||
enabled: job.enabled,
|
enabled: job.enabled,
|
||||||
deleteAfterRun: job.deleteAfterRun ?? false,
|
deleteAfterRun: job.deleteAfterRun ?? false,
|
||||||
@@ -452,9 +453,12 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
|
|||||||
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
|
payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message,
|
||||||
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
|
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
|
||||||
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
|
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
|
||||||
|
payloadLightContext:
|
||||||
|
job.payload.kind === "agentTurn" ? job.payload.lightContext === true : false,
|
||||||
deliveryMode: job.delivery?.mode ?? "none",
|
deliveryMode: job.delivery?.mode ?? "none",
|
||||||
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
|
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
|
||||||
deliveryTo: job.delivery?.to ?? "",
|
deliveryTo: job.delivery?.to ?? "",
|
||||||
|
deliveryAccountId: job.delivery?.accountId ?? "",
|
||||||
deliveryBestEffort: job.delivery?.bestEffort ?? false,
|
deliveryBestEffort: job.delivery?.bestEffort ?? false,
|
||||||
failureAlertMode:
|
failureAlertMode:
|
||||||
failureAlert === false
|
failureAlert === false
|
||||||
@@ -555,6 +559,7 @@ export function buildCronPayload(form: CronFormState) {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
|
lightContext?: boolean;
|
||||||
} = { kind: "agentTurn", message };
|
} = { kind: "agentTurn", message };
|
||||||
const model = form.payloadModel.trim();
|
const model = form.payloadModel.trim();
|
||||||
if (model) {
|
if (model) {
|
||||||
@@ -568,6 +573,9 @@ export function buildCronPayload(form: CronFormState) {
|
|||||||
if (timeoutSeconds > 0) {
|
if (timeoutSeconds > 0) {
|
||||||
payload.timeoutSeconds = timeoutSeconds;
|
payload.timeoutSeconds = timeoutSeconds;
|
||||||
}
|
}
|
||||||
|
if (form.payloadLightContext) {
|
||||||
|
payload.lightContext = true;
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +620,20 @@ export async function addCronJob(state: CronState) {
|
|||||||
|
|
||||||
const schedule = buildCronSchedule(form);
|
const schedule = buildCronSchedule(form);
|
||||||
const payload = buildCronPayload(form);
|
const payload = buildCronPayload(form);
|
||||||
|
const editingJob = state.cronEditingJobId
|
||||||
|
? state.cronJobs.find((job) => job.id === state.cronEditingJobId)
|
||||||
|
: undefined;
|
||||||
|
if (payload.kind === "agentTurn") {
|
||||||
|
const existingLightContext =
|
||||||
|
editingJob?.payload.kind === "agentTurn" ? editingJob.payload.lightContext : undefined;
|
||||||
|
if (
|
||||||
|
!form.payloadLightContext &&
|
||||||
|
state.cronEditingJobId &&
|
||||||
|
existingLightContext !== undefined
|
||||||
|
) {
|
||||||
|
payload.lightContext = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const selectedDeliveryMode = form.deliveryMode;
|
const selectedDeliveryMode = form.deliveryMode;
|
||||||
const delivery =
|
const delivery =
|
||||||
selectedDeliveryMode && selectedDeliveryMode !== "none"
|
selectedDeliveryMode && selectedDeliveryMode !== "none"
|
||||||
@@ -622,6 +644,8 @@ export async function addCronJob(state: CronState) {
|
|||||||
? form.deliveryChannel.trim() || "last"
|
? form.deliveryChannel.trim() || "last"
|
||||||
: undefined,
|
: undefined,
|
||||||
to: form.deliveryTo.trim() || undefined,
|
to: form.deliveryTo.trim() || undefined,
|
||||||
|
accountId:
|
||||||
|
selectedDeliveryMode === "announce" ? form.deliveryAccountId.trim() : undefined,
|
||||||
bestEffort: form.deliveryBestEffort,
|
bestEffort: form.deliveryBestEffort,
|
||||||
}
|
}
|
||||||
: selectedDeliveryMode === "none"
|
: selectedDeliveryMode === "none"
|
||||||
@@ -629,10 +653,13 @@ export async function addCronJob(state: CronState) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const failureAlert = buildFailureAlert(form);
|
const failureAlert = buildFailureAlert(form);
|
||||||
const agentId = form.clearAgent ? null : form.agentId.trim();
|
const agentId = form.clearAgent ? null : form.agentId.trim();
|
||||||
|
const sessionKeyRaw = form.sessionKey.trim();
|
||||||
|
const sessionKey = sessionKeyRaw || (editingJob?.sessionKey ? null : undefined);
|
||||||
const job = {
|
const job = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
agentId: agentId === null ? null : agentId || undefined,
|
agentId: agentId === null ? null : agentId || undefined,
|
||||||
|
sessionKey,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
deleteAfterRun: form.deleteAfterRun,
|
deleteAfterRun: form.deleteAfterRun,
|
||||||
schedule,
|
schedule,
|
||||||
@@ -681,14 +708,14 @@ export async function toggleCronJob(state: CronState, job: CronJob, enabled: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCronJob(state: CronState, job: CronJob) {
|
export async function runCronJob(state: CronState, job: CronJob, mode: "force" | "due" = "force") {
|
||||||
if (!state.client || !state.connected || state.cronBusy) {
|
if (!state.client || !state.connected || state.cronBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.cronBusy = true;
|
state.cronBusy = true;
|
||||||
state.cronError = null;
|
state.cronError = null;
|
||||||
try {
|
try {
|
||||||
await state.client.request("cron.run", { id: job.id, mode: "force" });
|
await state.client.request("cron.run", { id: job.id, mode });
|
||||||
if (state.cronRunsScope === "all") {
|
if (state.cronRunsScope === "all") {
|
||||||
await loadCronRuns(state, null);
|
await loadCronRuns(state, null);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -482,12 +482,14 @@ export type CronPayload =
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
|
lightContext?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronDelivery = {
|
export type CronDelivery = {
|
||||||
mode: "none" | "announce" | "webhook";
|
mode: "none" | "announce" | "webhook";
|
||||||
channel?: string;
|
channel?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
accountId?: string;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -511,6 +513,7 @@ export type CronJobState = {
|
|||||||
export type CronJob = {
|
export type CronJob = {
|
||||||
id: string;
|
id: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type CronFormState = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
sessionKey: string;
|
||||||
clearAgent: boolean;
|
clearAgent: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
deleteAfterRun: boolean;
|
deleteAfterRun: boolean;
|
||||||
@@ -36,9 +37,11 @@ export type CronFormState = {
|
|||||||
payloadText: string;
|
payloadText: string;
|
||||||
payloadModel: string;
|
payloadModel: string;
|
||||||
payloadThinking: string;
|
payloadThinking: string;
|
||||||
|
payloadLightContext: boolean;
|
||||||
deliveryMode: "none" | "announce" | "webhook";
|
deliveryMode: "none" | "announce" | "webhook";
|
||||||
deliveryChannel: string;
|
deliveryChannel: string;
|
||||||
deliveryTo: string;
|
deliveryTo: string;
|
||||||
|
deliveryAccountId: string;
|
||||||
deliveryBestEffort: boolean;
|
deliveryBestEffort: boolean;
|
||||||
failureAlertMode: "inherit" | "disabled" | "custom";
|
failureAlertMode: "inherit" | "disabled" | "custom";
|
||||||
failureAlertAfter: string;
|
failureAlertAfter: string;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ function createProps(overrides: Partial<CronProps> = {}): CronProps {
|
|||||||
thinkingSuggestions: [],
|
thinkingSuggestions: [],
|
||||||
timezoneSuggestions: [],
|
timezoneSuggestions: [],
|
||||||
deliveryToSuggestions: [],
|
deliveryToSuggestions: [],
|
||||||
|
accountSuggestions: [],
|
||||||
onFormChange: () => undefined,
|
onFormChange: () => undefined,
|
||||||
onRefresh: () => undefined,
|
onRefresh: () => undefined,
|
||||||
onAdd: () => undefined,
|
onAdd: () => undefined,
|
||||||
@@ -423,6 +424,7 @@ describe("cron view", () => {
|
|||||||
expect(container.textContent).toContain("Advanced");
|
expect(container.textContent).toContain("Advanced");
|
||||||
expect(container.textContent).toContain("Exact timing (no stagger)");
|
expect(container.textContent).toContain("Exact timing (no stagger)");
|
||||||
expect(container.textContent).toContain("Stagger window");
|
expect(container.textContent).toContain("Stagger window");
|
||||||
|
expect(container.textContent).toContain("Light context");
|
||||||
expect(container.textContent).toContain("Model");
|
expect(container.textContent).toContain("Model");
|
||||||
expect(container.textContent).toContain("Thinking");
|
expect(container.textContent).toContain("Thinking");
|
||||||
expect(container.textContent).toContain("Best effort delivery");
|
expect(container.textContent).toContain("Best effort delivery");
|
||||||
@@ -671,7 +673,7 @@ describe("cron view", () => {
|
|||||||
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
|
||||||
expect(onToggle).toHaveBeenCalledWith(job, false);
|
expect(onToggle).toHaveBeenCalledWith(job, false);
|
||||||
expect(onRun).toHaveBeenCalledWith(job);
|
expect(onRun).toHaveBeenCalledWith(job, "force");
|
||||||
expect(onRemove).toHaveBeenCalledWith(job);
|
expect(onRemove).toHaveBeenCalledWith(job);
|
||||||
expect(onLoadRuns).toHaveBeenCalledTimes(3);
|
expect(onLoadRuns).toHaveBeenCalledTimes(3);
|
||||||
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions");
|
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions");
|
||||||
@@ -679,6 +681,31 @@ describe("cron view", () => {
|
|||||||
expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions");
|
expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wires Run if due action with due mode", () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const onRun = vi.fn();
|
||||||
|
const onLoadRuns = vi.fn();
|
||||||
|
const job = createJob("job-due");
|
||||||
|
render(
|
||||||
|
renderCron(
|
||||||
|
createProps({
|
||||||
|
jobs: [job],
|
||||||
|
onRun,
|
||||||
|
onLoadRuns,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
const runDueButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.textContent?.trim() === "Run if due",
|
||||||
|
);
|
||||||
|
expect(runDueButton).not.toBeUndefined();
|
||||||
|
runDueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
|
||||||
|
expect(onRun).toHaveBeenCalledWith(job, "due");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders suggestion datalists for agent/model/thinking/timezone", () => {
|
it("renders suggestion datalists for agent/model/thinking/timezone", () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
render(
|
render(
|
||||||
@@ -690,6 +717,7 @@ describe("cron view", () => {
|
|||||||
thinkingSuggestions: ["low"],
|
thinkingSuggestions: ["low"],
|
||||||
timezoneSuggestions: ["UTC"],
|
timezoneSuggestions: ["UTC"],
|
||||||
deliveryToSuggestions: ["+15551234567"],
|
deliveryToSuggestions: ["+15551234567"],
|
||||||
|
accountSuggestions: ["default"],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
container,
|
container,
|
||||||
@@ -700,10 +728,14 @@ describe("cron view", () => {
|
|||||||
expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull();
|
expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull();
|
||||||
expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull();
|
expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull();
|
||||||
expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull();
|
expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull();
|
||||||
|
expect(container.querySelector("datalist#cron-delivery-account-suggestions")).not.toBeNull();
|
||||||
expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull();
|
expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull();
|
expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull();
|
expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull();
|
expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull();
|
||||||
expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull();
|
expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
container.querySelector('input[list="cron-delivery-account-suggestions"]'),
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export type CronProps = {
|
|||||||
thinkingSuggestions: string[];
|
thinkingSuggestions: string[];
|
||||||
timezoneSuggestions: string[];
|
timezoneSuggestions: string[];
|
||||||
deliveryToSuggestions: string[];
|
deliveryToSuggestions: string[];
|
||||||
|
accountSuggestions: string[];
|
||||||
onFormChange: (patch: Partial<CronFormState>) => void;
|
onFormChange: (patch: Partial<CronFormState>) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -68,7 +69,7 @@ export type CronProps = {
|
|||||||
onClone: (job: CronJob) => void;
|
onClone: (job: CronJob) => void;
|
||||||
onCancelEdit: () => void;
|
onCancelEdit: () => void;
|
||||||
onToggle: (job: CronJob, enabled: boolean) => void;
|
onToggle: (job: CronJob, enabled: boolean) => void;
|
||||||
onRun: (job: CronJob) => void;
|
onRun: (job: CronJob, mode?: "force" | "due") => void;
|
||||||
onRemove: (job: CronJob) => void;
|
onRemove: (job: CronJob) => void;
|
||||||
onLoadRuns: (jobId: string) => void;
|
onLoadRuns: (jobId: string) => void;
|
||||||
onLoadMoreJobs: () => void;
|
onLoadMoreJobs: () => void;
|
||||||
@@ -1037,6 +1038,21 @@ export function renderCron(props: CronProps) {
|
|||||||
<span class="field-checkbox__label">${t("cron.form.clearAgentOverride")}</span>
|
<span class="field-checkbox__label">${t("cron.form.clearAgentOverride")}</span>
|
||||||
<div class="cron-help">${t("cron.form.clearAgentHelp")}</div>
|
<div class="cron-help">${t("cron.form.clearAgentHelp")}</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field cron-span-2">
|
||||||
|
${renderFieldLabel("Session key")}
|
||||||
|
<input
|
||||||
|
id="cron-session-key"
|
||||||
|
.value=${props.form.sessionKey}
|
||||||
|
@input=${(e: Event) =>
|
||||||
|
props.onFormChange({
|
||||||
|
sessionKey: (e.target as HTMLInputElement).value,
|
||||||
|
})}
|
||||||
|
placeholder="agent:main:main"
|
||||||
|
/>
|
||||||
|
<div class="cron-help">
|
||||||
|
Optional routing key for job delivery and wake routing.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
${
|
${
|
||||||
isCronSchedule
|
isCronSchedule
|
||||||
? html`
|
? html`
|
||||||
@@ -1098,6 +1114,37 @@ export function renderCron(props: CronProps) {
|
|||||||
${
|
${
|
||||||
isAgentTurn
|
isAgentTurn
|
||||||
? html`
|
? html`
|
||||||
|
<label class="field cron-span-2">
|
||||||
|
${renderFieldLabel("Account ID")}
|
||||||
|
<input
|
||||||
|
id="cron-delivery-account-id"
|
||||||
|
.value=${props.form.deliveryAccountId}
|
||||||
|
list="cron-delivery-account-suggestions"
|
||||||
|
?disabled=${selectedDeliveryMode !== "announce"}
|
||||||
|
@input=${(e: Event) =>
|
||||||
|
props.onFormChange({
|
||||||
|
deliveryAccountId: (e.target as HTMLInputElement).value,
|
||||||
|
})}
|
||||||
|
placeholder="default"
|
||||||
|
/>
|
||||||
|
<div class="cron-help">
|
||||||
|
Optional channel account ID for multi-account setups.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="field checkbox cron-checkbox cron-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${props.form.payloadLightContext}
|
||||||
|
@change=${(e: Event) =>
|
||||||
|
props.onFormChange({
|
||||||
|
payloadLightContext: (e.target as HTMLInputElement).checked,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span class="field-checkbox__label">Light context</span>
|
||||||
|
<div class="cron-help">
|
||||||
|
Use lightweight bootstrap context for this agent job.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
${renderFieldLabel(t("cron.form.model"))}
|
${renderFieldLabel(t("cron.form.model"))}
|
||||||
<input
|
<input
|
||||||
@@ -1311,6 +1358,7 @@ export function renderCron(props: CronProps) {
|
|||||||
${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)}
|
${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)}
|
||||||
${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)}
|
${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)}
|
||||||
${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)}
|
${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)}
|
||||||
|
${renderSuggestionList("cron-delivery-account-suggestions", props.accountSuggestions)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1476,11 +1524,21 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
selectAnd(() => props.onRun(job));
|
selectAnd(() => props.onRun(job, "force"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${t("cron.jobList.run")}
|
${t("cron.jobList.run")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
?disabled=${props.busy}
|
||||||
|
@click=${(event: Event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
selectAnd(() => props.onRun(job, "due"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Run if due
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
|
|||||||
Reference in New Issue
Block a user