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:
Tak Hoffman
2026-03-02 07:24:33 -06:00
committed by GitHub
parent 127217612c
commit 254bb7ceee
10 changed files with 418 additions and 7 deletions

View File

@@ -137,6 +137,53 @@ describe("applyJobPatch", () => {
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", () => {
const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL";
const cases = [

View File

@@ -564,6 +564,9 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
if (typeof patch.timeoutSeconds === "number") {
next.timeoutSeconds = patch.timeoutSeconds;
}
if (typeof patch.lightContext === "boolean") {
next.lightContext = patch.lightContext;
}
if (typeof patch.allowUnsafeExternalContent === "boolean") {
next.allowUnsafeExternalContent = patch.allowUnsafeExternalContent;
}
@@ -641,6 +644,7 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
model: patch.model,
thinking: patch.thinking,
timeoutSeconds: patch.timeoutSeconds,
lightContext: patch.lightContext,
allowUnsafeExternalContent: patch.allowUnsafeExternalContent,
deliver: patch.deliver,
channel: patch.channel,

View File

@@ -14,6 +14,7 @@ export const DEFAULT_CRON_FORM: CronFormState = {
name: "",
description: "",
agentId: "",
sessionKey: "",
clearAgent: false,
enabled: true,
deleteAfterRun: true,
@@ -32,9 +33,11 @@ export const DEFAULT_CRON_FORM: CronFormState = {
payloadText: "",
payloadModel: "",
payloadThinking: "",
payloadLightContext: false,
deliveryMode: "announce",
deliveryChannel: "last",
deliveryTo: "",
deliveryAccountId: "",
deliveryBestEffort: false,
failureAlertMode: "inherit",
failureAlertAfter: "2",

View File

@@ -214,6 +214,7 @@ export function renderApp(state: AppViewState) {
...jobToSuggestions,
...accountToSuggestions,
]);
const accountSuggestions = uniquePreserveOrder(accountToSuggestions);
const deliveryToSuggestions =
state.cronForm.deliveryMode === "webhook"
? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value))
@@ -482,6 +483,7 @@ export function renderApp(state: AppViewState) {
thinkingSuggestions: CRON_THINKING_SUGGESTIONS,
timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS,
deliveryToSuggestions,
accountSuggestions,
onFormChange: (patch) => {
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
state.cronFieldErrors = validateCronForm(state.cronForm);
@@ -492,7 +494,7 @@ export function renderApp(state: AppViewState) {
onClone: (job) => startCronClone(state, job),
onCancelEdit: () => cancelCronEdit(state),
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),
onLoadRuns: async (jobId) => {
updateCronRunsFilter(state, { cronRunsScope: "job" });

View File

@@ -7,6 +7,7 @@ import {
loadCronRuns,
loadMoreCronRuns,
normalizeCronFormState,
runCronJob,
startCronEdit,
startCronClone,
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 () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.add") {
@@ -306,12 +384,74 @@ describe("cron controller", () => {
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", () => {
const state = createState();
const job = {
id: "job-9",
name: "Weekly report",
description: "desc",
sessionKey: "agent:ops:main",
enabled: false,
createdAtMs: 0,
updatedAtMs: 0,
@@ -319,7 +459,7 @@ describe("cron controller", () => {
sessionTarget: "isolated" as const,
wakeMode: "next-heartbeat" as const,
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: {},
};
@@ -328,6 +468,7 @@ describe("cron controller", () => {
expect(state.cronEditingJobId).toBe("job-9");
expect(state.cronRunsJobId).toBe("job-9");
expect(state.cronForm.name).toBe("Weekly report");
expect(state.cronForm.sessionKey).toBe("agent:ops:main");
expect(state.cronForm.enabled).toBe(false);
expect(state.cronForm.scheduleKind).toBe("every");
expect(state.cronForm.everyAmount).toBe("2");
@@ -338,6 +479,7 @@ describe("cron controller", () => {
expect(state.cronForm.deliveryMode).toBe("announce");
expect(state.cronForm.deliveryChannel).toBe("telegram");
expect(state.cronForm.deliveryTo).toBe("123");
expect(state.cronForm.deliveryAccountId).toBe("bot-2");
});
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 () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "cron.update") {
@@ -787,4 +985,38 @@ describe("cron controller", () => {
expect(state.cronRuns[0]?.summary).toBe("newest");
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" });
});
});

View File

@@ -434,6 +434,7 @@ function jobToForm(job: CronJob, prev: CronFormState): CronFormState {
name: job.name,
description: job.description ?? "",
agentId: job.agentId ?? "",
sessionKey: job.sessionKey ?? "",
clearAgent: false,
enabled: job.enabled,
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,
payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "",
payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "",
payloadLightContext:
job.payload.kind === "agentTurn" ? job.payload.lightContext === true : false,
deliveryMode: job.delivery?.mode ?? "none",
deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST,
deliveryTo: job.delivery?.to ?? "",
deliveryAccountId: job.delivery?.accountId ?? "",
deliveryBestEffort: job.delivery?.bestEffort ?? false,
failureAlertMode:
failureAlert === false
@@ -555,6 +559,7 @@ export function buildCronPayload(form: CronFormState) {
model?: string;
thinking?: string;
timeoutSeconds?: number;
lightContext?: boolean;
} = { kind: "agentTurn", message };
const model = form.payloadModel.trim();
if (model) {
@@ -568,6 +573,9 @@ export function buildCronPayload(form: CronFormState) {
if (timeoutSeconds > 0) {
payload.timeoutSeconds = timeoutSeconds;
}
if (form.payloadLightContext) {
payload.lightContext = true;
}
return payload;
}
@@ -612,6 +620,20 @@ export async function addCronJob(state: CronState) {
const schedule = buildCronSchedule(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 delivery =
selectedDeliveryMode && selectedDeliveryMode !== "none"
@@ -622,6 +644,8 @@ export async function addCronJob(state: CronState) {
? form.deliveryChannel.trim() || "last"
: undefined,
to: form.deliveryTo.trim() || undefined,
accountId:
selectedDeliveryMode === "announce" ? form.deliveryAccountId.trim() : undefined,
bestEffort: form.deliveryBestEffort,
}
: selectedDeliveryMode === "none"
@@ -629,10 +653,13 @@ export async function addCronJob(state: CronState) {
: undefined;
const failureAlert = buildFailureAlert(form);
const agentId = form.clearAgent ? null : form.agentId.trim();
const sessionKeyRaw = form.sessionKey.trim();
const sessionKey = sessionKeyRaw || (editingJob?.sessionKey ? null : undefined);
const job = {
name: form.name.trim(),
description: form.description.trim(),
agentId: agentId === null ? null : agentId || undefined,
sessionKey,
enabled: form.enabled,
deleteAfterRun: form.deleteAfterRun,
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) {
return;
}
state.cronBusy = true;
state.cronError = null;
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") {
await loadCronRuns(state, null);
} else {

View File

@@ -482,12 +482,14 @@ export type CronPayload =
model?: string;
thinking?: string;
timeoutSeconds?: number;
lightContext?: boolean;
};
export type CronDelivery = {
mode: "none" | "announce" | "webhook";
channel?: string;
to?: string;
accountId?: string;
bestEffort?: boolean;
};
@@ -511,6 +513,7 @@ export type CronJobState = {
export type CronJob = {
id: string;
agentId?: string;
sessionKey?: string;
name: string;
description?: string;
enabled: boolean;

View File

@@ -18,6 +18,7 @@ export type CronFormState = {
name: string;
description: string;
agentId: string;
sessionKey: string;
clearAgent: boolean;
enabled: boolean;
deleteAfterRun: boolean;
@@ -36,9 +37,11 @@ export type CronFormState = {
payloadText: string;
payloadModel: string;
payloadThinking: string;
payloadLightContext: boolean;
deliveryMode: "none" | "announce" | "webhook";
deliveryChannel: string;
deliveryTo: string;
deliveryAccountId: string;
deliveryBestEffort: boolean;
failureAlertMode: "inherit" | "disabled" | "custom";
failureAlertAfter: string;

View File

@@ -57,6 +57,7 @@ function createProps(overrides: Partial<CronProps> = {}): CronProps {
thinkingSuggestions: [],
timezoneSuggestions: [],
deliveryToSuggestions: [],
accountSuggestions: [],
onFormChange: () => undefined,
onRefresh: () => undefined,
onAdd: () => undefined,
@@ -423,6 +424,7 @@ describe("cron view", () => {
expect(container.textContent).toContain("Advanced");
expect(container.textContent).toContain("Exact timing (no stagger)");
expect(container.textContent).toContain("Stagger window");
expect(container.textContent).toContain("Light context");
expect(container.textContent).toContain("Model");
expect(container.textContent).toContain("Thinking");
expect(container.textContent).toContain("Best effort delivery");
@@ -671,7 +673,7 @@ describe("cron view", () => {
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onToggle).toHaveBeenCalledWith(job, false);
expect(onRun).toHaveBeenCalledWith(job);
expect(onRun).toHaveBeenCalledWith(job, "force");
expect(onRemove).toHaveBeenCalledWith(job);
expect(onLoadRuns).toHaveBeenCalledTimes(3);
expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions");
@@ -679,6 +681,31 @@ describe("cron view", () => {
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", () => {
const container = document.createElement("div");
render(
@@ -690,6 +717,7 @@ describe("cron view", () => {
thinkingSuggestions: ["low"],
timezoneSuggestions: ["UTC"],
deliveryToSuggestions: ["+15551234567"],
accountSuggestions: ["default"],
}),
),
container,
@@ -700,10 +728,14 @@ describe("cron view", () => {
expect(container.querySelector("datalist#cron-thinking-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-account-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-thinking-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-account-suggestions"]'),
).not.toBeNull();
});
});

View File

@@ -61,6 +61,7 @@ export type CronProps = {
thinkingSuggestions: string[];
timezoneSuggestions: string[];
deliveryToSuggestions: string[];
accountSuggestions: string[];
onFormChange: (patch: Partial<CronFormState>) => void;
onRefresh: () => void;
onAdd: () => void;
@@ -68,7 +69,7 @@ export type CronProps = {
onClone: (job: CronJob) => void;
onCancelEdit: () => void;
onToggle: (job: CronJob, enabled: boolean) => void;
onRun: (job: CronJob) => void;
onRun: (job: CronJob, mode?: "force" | "due") => void;
onRemove: (job: CronJob) => void;
onLoadRuns: (jobId: string) => void;
onLoadMoreJobs: () => void;
@@ -1037,6 +1038,21 @@ export function renderCron(props: CronProps) {
<span class="field-checkbox__label">${t("cron.form.clearAgentOverride")}</span>
<div class="cron-help">${t("cron.form.clearAgentHelp")}</div>
</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
? html`
@@ -1098,6 +1114,37 @@ export function renderCron(props: CronProps) {
${
isAgentTurn
? 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">
${renderFieldLabel(t("cron.form.model"))}
<input
@@ -1311,6 +1358,7 @@ export function renderCron(props: CronProps) {
${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)}
${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)}
${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}
@click=${(event: Event) => {
event.stopPropagation();
selectAnd(() => props.onRun(job));
selectAnd(() => props.onRun(job, "force"));
}}
>
${t("cron.jobList.run")}
</button>
<button
class="btn"
?disabled=${props.busy}
@click=${(event: Event) => {
event.stopPropagation();
selectAnd(() => props.onRun(job, "due"));
}}
>
Run if due
</button>
<button
class="btn"
?disabled=${props.busy}