mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +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();
|
||||
});
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user