fix(webui): allow content-only automation edits

Avoid resubmitting unchanged schedules from the automation edit dialog so completed one-time automations can still have their content updated. Treat unchanged schedules as already-valid on the backend while preserving validation for actual schedule changes.
This commit is contained in:
chengyongru
2026-06-15 00:59:23 +08:00
parent 8b42d0760e
commit 5892c6913b
4 changed files with 156 additions and 11 deletions
+35 -8
View File
@@ -3802,6 +3802,7 @@ type AutomationEditDraft = {
tz: string;
atLocal: string;
};
type AutomationScheduleUpdate = NonNullable<AutomationUpdatePayload["schedule"]>;
const AUTOMATION_EVERY_UNITS: Array<{ value: AutomationEveryUnit; ms: number }> = [
{ value: "second", ms: 1000 },
@@ -3830,7 +3831,7 @@ function AutomationEditDialog({
setDraft(automationDraftFromJob(job));
}, [job]);
const validation = automationEditDraftError(draft, tx);
const validation = automationEditDraftError(draft, job, tx);
const scheduleOptions = [
{ value: "every", label: tx("settings.automations.scheduleTypes.every", "Interval") },
{ value: "cron", label: tx("settings.automations.scheduleTypes.cron", "Cron") },
@@ -3845,7 +3846,7 @@ function AutomationEditDialog({
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const payload = automationUpdatePayloadFromDraft(draft);
const payload = automationUpdatePayloadFromDraft(draft, job);
if (!job || typeof payload === "string") return;
void onSave(job, payload);
};
@@ -4143,6 +4144,7 @@ function formatLocalDateTimeInput(ms: number): string {
function automationEditDraftError(
draft: AutomationEditDraft,
job: SessionAutomationJob | null,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null {
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
@@ -4163,33 +4165,58 @@ function automationEditDraftError(
if (!Number.isFinite(atMs)) {
return tx("settings.automations.validation.timeRequired", "Run time is required.");
}
if (atMs <= Date.now()) {
if (atMs <= Date.now() && automationScheduleChanged(draft, job)) {
return tx("settings.automations.validation.futureRequired", "Run time must be in the future.");
}
}
return null;
}
function automationUpdatePayloadFromDraft(draft: AutomationEditDraft): AutomationUpdatePayload | string {
function automationUpdatePayloadFromDraft(
draft: AutomationEditDraft,
job: SessionAutomationJob | null,
): AutomationUpdatePayload | string {
const name = draft.name.trim();
const message = draft.message.trim();
if (!name || !message) return "invalid";
const payload: AutomationUpdatePayload = { name, message };
const schedule = automationSchedulePayloadFromDraft(draft);
if (typeof schedule === "string") return schedule;
if (automationScheduleChanged(draft, job, schedule)) {
payload.schedule = schedule;
}
return payload;
}
function automationSchedulePayloadFromDraft(draft: AutomationEditDraft): AutomationScheduleUpdate | string {
if (draft.scheduleKind === "every") {
const unit = AUTOMATION_EVERY_UNITS.find((candidate) => candidate.value === draft.everyUnit);
const value = Number(draft.everyValue);
if (!unit || !Number.isInteger(value) || value <= 0) return "invalid";
payload.schedule = { kind: "every", every_ms: value * unit.ms };
return { kind: "every", every_ms: value * unit.ms };
} else if (draft.scheduleKind === "cron") {
const expr = draft.cronExpr.trim();
if (!expr) return "invalid";
payload.schedule = { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
return { kind: "cron", expr, ...(draft.tz.trim() ? { tz: draft.tz.trim() } : {}) };
} else {
const atMs = new Date(draft.atLocal).getTime();
if (!Number.isFinite(atMs)) return "invalid";
payload.schedule = { kind: "at", at_ms: atMs };
return { kind: "at", at_ms: atMs };
}
return payload;
}
function automationScheduleChanged(
draft: AutomationEditDraft,
job: SessionAutomationJob | null,
schedule: AutomationScheduleUpdate | string = automationSchedulePayloadFromDraft(draft),
): boolean {
if (!job || typeof schedule === "string") return true;
if (schedule.kind !== job.schedule.kind) return true;
if (schedule.kind === "every") return schedule.every_ms !== job.schedule.every_ms;
if (schedule.kind === "cron") {
return schedule.expr !== (job.schedule.expr ?? "") || (schedule.tz ?? null) !== (job.schedule.tz ?? null);
}
return draft.atLocal !== formatLocalDateTimeInput(job.schedule.at_ms ?? NaN);
}
function automationSearchText(job: SessionAutomationJob): string {
+72
View File
@@ -452,6 +452,78 @@ describe("App layout", () => {
expect(document.title).toBe("Automations · nanobot");
});
it("edits a past one-time automation without resubmitting its old schedule", async () => {
const pastOneShot = {
id: "past-one-shot",
name: "Past one-shot",
enabled: true,
protected: false,
delete_after_run: true,
schedule: { kind: "at", at_ms: 1 },
payload: {
message: "Old one-shot message",
kind: "agent_turn",
session_key: "websocket:chat-a",
},
state: {
next_run_at_ms: null,
last_status: "ok",
pending: false,
run_history: [],
},
origin: {
session_key: "websocket:chat-a",
channel: "websocket",
chat_id: "chat-a",
title: "Release prep",
preview: "Check release blockers",
},
};
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Automations" }));
expect(await screen.findByText("Past one-shot")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(screen.queryByText("Run time must be in the future.")).not.toBeInTheDocument();
fireEvent.change(screen.getByDisplayValue("Old one-shot message"), {
target: { value: "Updated one-shot message" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(headers["X-Nanobot-Automation-Values"])).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("localizes the Automations surface", async () => {
await i18n.changeLanguage("zh-CN");
mockFetchRoutes({