diff --git a/Agent.md b/Agent.md index 83256a1b..f7073d53 100644 --- a/Agent.md +++ b/Agent.md @@ -54,8 +54,8 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (166: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 9 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (716: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (729: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 49 Shell + 24 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 291d7c14..a42e65b5 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -942,6 +942,14 @@ vision = false return frame.tasks || []; }); + ipcMain.handle("emrg:taskLimits", async () => { + // 间隔下限的唯一来源是 daemon 的 MIN_INTERVAL(issue #2490):渲染层问这里, + // 不再自己写一份。拿不到就走 undefined,让 daemon 的权威校验说话。 + const frame = await requireConn().sendCommandAndWait("list_tasks", {}, 5000); + const v = frame.min_interval; + return typeof v === "number" && Number.isFinite(v) ? { minInterval: v } : {}; + }); + // renderer → main 日志桥(rant 2026-08-24:打开会话链路排障)。级别白名单, // 消息截断到 2000 字符防刷屏。写同一 emrg-gui.log,时间线可与 main 日志对齐。 ipcMain.handle("emrg:log", async (_e, { level, msg } = {}) => { @@ -960,14 +968,19 @@ vision = false // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── ipcMain.handle("emrg:taskCreate", async (_e, payload) => { - const { name, type, project, interval, enabled, repo, description, sandbox } = payload || {}; + const { name, type, project, interval, enabled, repo, description, sandbox, schedule } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); - const frame = await requireConn().sendCommandAndWait("task_create", { + // `schedule` 必须原样转发:这张固定字段表此前把它丢掉,于是 GUI 永远只能建 + // 单一频次的任务,daemon 收了也白收(issue #2490)。只有 payload 真的带了这个 + // 键才发,缺席=不声明,与 task_update 的「缺省不动该字段」一致。 + const fields = { name: name.trim(), task_type: type.trim(), project: project.trim(), interval, enabled, repo, description, sandbox, - }, 8000); + }; + if ("schedule" in (payload || {})) fields.schedule = schedule; + const frame = await requireConn().sendCommandAndWait("task_create", fields, 8000); if (!frame.ok && frame.error) throw new Error(frame.error); return frame; }); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index d76d9f90..a59f8488 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -55,6 +55,8 @@ const api = { registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload), removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), + // 间隔下限由 daemon 上报(issue #2490)——渲染层不留第二份数字 + taskLimits: () => ipcRenderer.invoke("emrg:taskLimits"), triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), // Task/template CRUD (rant 2026-08-12T18:23:15 P3) taskCreate: (payload) => ipcRenderer.invoke("emrg:taskCreate", payload), diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 5e7d47d7..5fe78778 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -39,6 +39,9 @@ function mockEmrg() { const taskUpdate = vi.fn().mockResolvedValue({ ok: true }); const taskDelete = vi.fn().mockResolvedValue({ ok: true }); const taskTemplateList = vi.fn().mockResolvedValue([{ name: "journal" }]); + // issue #2490:间隔下限由 daemon 上报 —— 这里刻意给 120(不是渲染层曾写死的 60), + // 于是「表单拿到的就是上报值」这件事在接线层可断言。 + const taskLimits = vi.fn().mockResolvedValue({ minInterval: 120 }); const sendRant = vi.fn().mockResolvedValue({ ok: true, count: 11 }); const restartDaemon = vi.fn().mockResolvedValue({ ok: true }); const setModel = vi.fn().mockResolvedValue({ ok: true }); @@ -62,6 +65,7 @@ function mockEmrg() { taskUpdate, taskDelete, taskTemplateList, + taskLimits, sendRant, restartDaemon, setModel, @@ -84,6 +88,7 @@ function mockEmrg() { taskUpdate, taskDelete, taskTemplateList, + taskLimits, sendRant, restartDaemon, setModel, @@ -770,6 +775,30 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.queryByTestId("task-form-dialog")).not.toBeInTheDocument(); }); + it("表单的间隔下限来自 daemon 上报(taskLimits),不是渲染层写死的数", async () => { + const m = mockEmrg(); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-tasks")); + await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument()); + fireEvent.click(screen.getByText("+ Add task")); + await waitFor(() => expect(screen.getByTestId("task-form-dialog")).toBeInTheDocument()); + // 接线本身:表单打开时问过 daemon 的下限 + await waitFor(() => expect(m.taskLimits).toHaveBeenCalled()); + + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t3" } }); + // 上报值是 120:写成 60(渲染层曾经写死的那个数)必须被拒,且错误文案带 120 + fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: "60" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + expect(screen.getByTestId("task-form-error")).toHaveTextContent("120"); + expect(m.taskCreate).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: "120" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await waitFor(() => expect(m.taskCreate).toHaveBeenCalledTimes(1)); + expect(m.taskCreate.mock.calls[0][0].interval).toBe(120); + }); + it("任务行「Edit」→ 表单预填 + name 只读 → 保存 → taskUpdate(不改名)", async () => { const m = mockEmrg(); render(wrapper()); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index f7127a85..8015cf8a 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -69,6 +69,11 @@ interface WorkspaceBridge { triggerTask(p: { name: string }): Promise; taskCreate(p: TaskFormPayload): Promise; taskUpdate(p: TaskFormPayload): Promise; + /** + * 间隔下限(issue #2490):daemon 的 `MIN_INTERVAL`,由 `tasks_list` 帧上报。 + * 渲染层不保留第二份数字 —— 拿不到就没有预检,由 daemon 权威校验。 + */ + taskLimits?(): Promise<{ minInterval?: number }>; taskDelete(p: { name: string }): Promise; taskTemplateList(): Promise<{ name: string }[]>; sendRant(p: { message: string; project?: string }): Promise<{ ok?: boolean; count?: number }>; @@ -128,6 +133,8 @@ export function Shell() { const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState); // Batch 5 slice 8:任务表单 + Rant 对话框(vanilla openTaskForm/openRantForm 语义) const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null); + /** 间隔下限:daemon 上报的值(undefined = 还没问到,表单就不预检) */ + const [minInterval, setMinInterval] = useState(undefined); const [rantOpen, setRantOpen] = useState(false); const [taskTypes, setTaskTypes] = useState([]); // 升级横幅(rant 2026-08-27T21:54:51:React 迁移丢 upgrade 事件):dismissed 记录 @@ -372,6 +379,15 @@ export function Shell() { setTaskTypes((prev) => (prev.length ? prev : ["evolution"])); } if (!isEdit) await loadProjects(); // 新建需要项目下拉 + // 下限先问 daemon(issue #2490):问不到保持 undefined,表单不预检 + if (b?.taskLimits) { + try { + const limits = await b.taskLimits(); + if (typeof limits?.minInterval === "number") setMinInterval(limits.minInterval); + } catch { + /* 拿不到就交给 daemon 权威校验 */ + } + } setTaskForm({ task: isEdit ? task : null }); } @@ -878,6 +894,7 @@ export function Shell() { request={taskForm} types={taskTypes} projects={projects} + minInterval={minInterval} onSubmit={(payload) => void saveTask(payload)} onDismiss={() => setTaskForm(null)} /> diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx index fd6e5886..720a156b 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.test.tsx @@ -1,13 +1,16 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { I18nProvider } from "../lib/i18n"; import { TaskFormDialog, type TaskFormPayload } from "./TaskFormDialog"; -import type { ProjectRec, TaskRec } from "../lib/workspaceView"; +import type { ProjectRec, ScheduleWindow, TaskRec } from "../lib/workspaceView"; /** * TaskFormDialog.test.tsx — Batch 5 slice 8:任务表单(新建/编辑)。 - * 镜像 vanilla openTaskForm/saveTaskForm 语义:预填、name 编辑只读、间隔 ≥60 校验、 - * 提交 payload 形状(name/type/project/interval/enabled/repo/sandbox)。 + * 镜像 vanilla openTaskForm/saveTaskForm 语义:预填、name 编辑只读、间隔下限校验、 + * 提交 payload 形状(name/type/project/interval/enabled/repo/sandbox/schedule)。 + * + * issue #2490:下限不再写死 —— 每处断言都传一个**不是 60** 的 `minInterval`, + * 于是组件里若有人再藏一份 `60`,下面「低于下限被拒 / 等于下限被收」两条必然红。 */ function wrapper(node: React.ReactNode) { @@ -20,6 +23,46 @@ const PROJECTS: ProjectRec[] = [ { name: "sci", path: "/p/sci" }, ]; +/** 下限只从 daemon 上报值来(issue #2490)——刻意不是渲染层曾写死的 60。 */ +const FLOOR = 120; + +const WINDOWS: ScheduleWindow[] = [ + { days: "mon-fri", from: "09:00", to: "18:00", interval: 900 }, + { from: "20:00", to: "22:00", interval: 1800 }, +]; + +function taskWith(overrides: Partial = {}): TaskRec { + return { + name: "windowed", + type: "evolution", + interval: 10800, + enabled: true, + config: { project: "emrg" }, + ...overrides, + }; +} + +function renderForm( + task: TaskRec | null, + props: Partial> = {}, +) { + const onSubmit = (props.onSubmit ?? vi.fn().mockResolvedValue(undefined)) as Mock; + render( + wrapper( + , + ), + ); + return { onSubmit }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -28,7 +71,13 @@ describe("TaskFormDialog", () => { it("新建:渲染表单字段 + 默认值(interval 1800, enabled, sandbox workspace-write)", () => { render( wrapper( - , + , ), ); expect(screen.getByTestId("task-form-dialog")).toBeInTheDocument(); @@ -42,17 +91,21 @@ describe("TaskFormDialog", () => { }); it("编辑:预填任务字段,name 只读不可改名", () => { - const task: TaskRec = { - name: "evo", - type: "journal", - interval: 3600, - enabled: false, - config: { project: "sci" }, - } as TaskRec & { sandbox?: string }; + const task = taskWith({ type: "journal", interval: 3600, enabled: false, config: { project: "sci" } }); (task as TaskRec & { sandbox?: string }).sandbox = "read-only"; - render(wrapper()); + render( + wrapper( + , + ), + ); const name = screen.getByTestId("task-form-name") as HTMLInputElement; - expect(name.value).toBe("evo"); + expect(name.value).toBe("windowed"); expect(name.disabled).toBe(true); expect((screen.getByTestId("task-form-type") as HTMLSelectElement).value).toBe("journal"); expect((screen.getByTestId("task-form-project") as HTMLSelectElement).value).toBe("sci"); @@ -62,26 +115,40 @@ describe("TaskFormDialog", () => { }); it("空名 → 内联错误,不提交", () => { - const onSubmit = vi.fn(); - render(wrapper()); + const { onSubmit } = renderForm(null); fireEvent.click(screen.getByTestId("task-form-save")); expect(screen.getByTestId("task-form-error")).toHaveTextContent("required"); expect(onSubmit).not.toHaveBeenCalled(); }); - it("间隔 < 60 → 内联错误,不提交", () => { - const onSubmit = vi.fn(); - render(wrapper()); + it("间隔低于 daemon 上报的下限 → 内联错误,不提交(下限不是 60)", () => { + const { onSubmit } = renderForm(null); fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); - fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: "30" } }); + fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: String(FLOOR - 1) } }); fireEvent.click(screen.getByTestId("task-form-save")); - expect(screen.getByTestId("task-form-error")).toHaveTextContent("Interval"); + // 报错文案带的是上报值本身 —— 组件里若另有一份 60,这条会读到 60。 + expect(screen.getByTestId("task-form-error")).toHaveTextContent(`${FLOOR}`); expect(onSubmit).not.toHaveBeenCalled(); }); - it("提交 payload 形状正确(含 sandbox)", async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - render(wrapper()); + it("间隔等于下限 → 接受(同一次上报既定义拒绝的一侧,也定义接受的一侧)", async () => { + const { onSubmit } = renderForm(null); + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); + fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: String(FLOOR) } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect((onSubmit.mock.calls[0][0] as TaskFormPayload).interval).toBe(FLOOR); + }); + + it("标签渲染的是上报值,不是写死的 60", () => { + renderForm(null); + const label = screen.getByTestId("task-form-interval").closest("label"); + expect(label).toHaveTextContent(String(FLOOR)); + expect(label).not.toHaveTextContent("60"); + }); + + it("提交 payload 形状正确(含 sandbox,无窗口时不带 schedule 键)", async () => { + const { onSubmit } = renderForm(null); fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); fireEvent.change(screen.getByTestId("task-form-type"), { target: { value: "journal" } }); fireEvent.change(screen.getByTestId("task-form-project"), { target: { value: "sci" } }); @@ -100,6 +167,136 @@ describe("TaskFormDialog", () => { repo: undefined, sandbox: "read-only", }); + // 键本身不该出现:daemon 的 update 以「键存在」决定是否动该字段。 + expect("schedule" in payload).toBe(false); + }); + + it("编辑带窗口的任务:每个窗口一行,四个字段逐字预填", () => { + renderForm(taskWith({ schedule: WINDOWS })); + expect(screen.queryByTestId("task-form-windows-empty")).not.toBeInTheDocument(); + for (const [i, w] of WINDOWS.entries()) { + expect((screen.getByTestId(`task-form-window-${i}-days`) as HTMLInputElement).value).toBe( + w.days ?? "", + ); + expect((screen.getByTestId(`task-form-window-${i}-from`) as HTMLInputElement).value).toBe(w.from); + expect((screen.getByTestId(`task-form-window-${i}-to`) as HTMLInputElement).value).toBe(w.to); + expect((screen.getByTestId(`task-form-window-${i}-interval`) as HTMLInputElement).value).toBe( + String(w.interval), + ); + } + expect(screen.queryByTestId(`task-form-window-${WINDOWS.length}`)).not.toBeInTheDocument(); + }); + + it("无窗口的任务:显示空提示,没有行", () => { + renderForm(taskWith()); + expect(screen.getByTestId("task-form-windows-empty")).toBeInTheDocument(); + expect(screen.queryByTestId("task-form-window-0")).not.toBeInTheDocument(); + }); + + it("改一个窗口的字段 → 提交里就是改后的值(未改的窗口原样保留)", async () => { + const { onSubmit } = renderForm(taskWith({ schedule: WINDOWS })); + fireEvent.change(screen.getByTestId("task-form-window-0-from"), { target: { value: "08:30" } }); + fireEvent.change(screen.getByTestId("task-form-window-0-interval"), { target: { value: "600" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect(payload.schedule).toEqual([ + { days: "mon-fri", from: "08:30", to: "18:00", interval: 600 }, + { from: "20:00", to: "22:00", interval: 1800 }, + ]); + }); + + it("添加一个窗口 → 提交里多一条;days 留空时不带 days 键", async () => { + const { onSubmit } = renderForm(taskWith({ schedule: WINDOWS })); + fireEvent.click(screen.getByTestId("task-form-window-add")); + fireEvent.change(screen.getByTestId("task-form-window-2-from"), { target: { value: "23:00" } }); + fireEvent.change(screen.getByTestId("task-form-window-2-to"), { target: { value: "23:30" } }); + fireEvent.change(screen.getByTestId("task-form-window-2-interval"), { target: { value: "600" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect(payload.schedule).toHaveLength(3); + expect(payload.schedule?.[2]).toEqual({ from: "23:00", to: "23:30", interval: 600 }); + expect("days" in (payload.schedule?.[2] ?? {})).toBe(false); + }); + + it("删掉一个窗口 → 提交里少一条", async () => { + const { onSubmit } = renderForm(taskWith({ schedule: WINDOWS })); + fireEvent.click(screen.getByTestId("task-form-window-0-remove")); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect(payload.schedule).toEqual([{ from: "20:00", to: "22:00", interval: 1800 }]); + }); + + it("原本有窗口而全删 → schedule: null(daemon 语义=全天单一频次)", async () => { + const { onSubmit } = renderForm(taskWith({ schedule: WINDOWS })); + fireEvent.click(screen.getByTestId("task-form-window-0-remove")); + fireEvent.click(screen.getByTestId("task-form-window-0-remove")); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect("schedule" in payload).toBe(true); + expect(payload.schedule).toBeNull(); + }); + + it("只点「添加」不填的空行被忽略,不产生窗口", async () => { + const { onSubmit } = renderForm(null); + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); + fireEvent.click(screen.getByTestId("task-form-window-add")); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect("schedule" in payload).toBe(false); + }); + + it("窗口填一半 → 内联错误,不提交", () => { + const { onSubmit } = renderForm(null); + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); + fireEvent.click(screen.getByTestId("task-form-window-add")); + fireEvent.change(screen.getByTestId("task-form-window-0-from"), { target: { value: "09:00" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + expect(screen.getByTestId("task-form-error")).toHaveTextContent("HH:MM"); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("窗口间隔低于上报下限 → 内联错误;等于下限 → 接受", async () => { + const { onSubmit } = renderForm(null); + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); + fireEvent.click(screen.getByTestId("task-form-window-add")); + fireEvent.change(screen.getByTestId("task-form-window-0-from"), { target: { value: "09:00" } }); + fireEvent.change(screen.getByTestId("task-form-window-0-to"), { target: { value: "18:00" } }); + fireEvent.change(screen.getByTestId("task-form-window-0-interval"), { + target: { value: String(FLOOR - 1) }, + }); + fireEvent.click(screen.getByTestId("task-form-save")); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByTestId("task-form-window-0-interval"), { target: { value: String(FLOOR) } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const payload = onSubmit.mock.calls[0][0] as TaskFormPayload; + expect(payload.schedule).toEqual([{ from: "09:00", to: "18:00", interval: FLOOR }]); + }); + + it("未拿到下限(undefined)→ 不做客户端预检,把判断留给 daemon", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + wrapper( + , + ), + ); + fireEvent.change(screen.getByTestId("task-form-name"), { target: { value: "t1" } }); + fireEvent.change(screen.getByTestId("task-form-interval"), { target: { value: "1" } }); + fireEvent.click(screen.getByTestId("task-form-save")); + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect((onSubmit.mock.calls[0][0] as TaskFormPayload).interval).toBe(1); }); it("closed (request=null) → 不渲染", () => { diff --git a/emrg/gui/renderer/src/components/TaskFormDialog.tsx b/emrg/gui/renderer/src/components/TaskFormDialog.tsx index 8a8a4d8a..0bed89ea 100644 --- a/emrg/gui/renderer/src/components/TaskFormDialog.tsx +++ b/emrg/gui/renderer/src/components/TaskFormDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useI18n } from "../lib/i18n"; -import type { ProjectRec, TaskRec } from "../lib/workspaceView"; +import type { ProjectRec, ScheduleWindow, TaskRec } from "../lib/workspaceView"; import { Dialog } from "./Dialog"; /** @@ -9,8 +9,11 @@ import { Dialog } from "./Dialog"; * * - 编辑时 name 只读(决策点:daemon 以 name 定位任务 → 不可改名); * - 类型下拉 = 内置 + 自定义类型(taskTemplateList,调用方注入);项目下拉 = 仅已注册项目; - * - 间隔默认 1800s,客户端校验 ≥60(决策点⑤,daemon 兜底); + * - 间隔默认 1800s;**下限由 daemon 上报**(`minInterval`,见 issue #2490),本组件不写死数值; * - sandbox 下拉 workspace-write / read-only / danger-full-access(vanilla 同款); + * - 分时段频次窗口(`schedule`,issue #2490):编辑时逐窗口预填,可增行、改行、删行; + * 提交时全空行忽略;原本有窗口而全删 → `schedule: null`(daemon 语义=全天单一频次); + * 从未有过窗口且没填 → 不带 `schedule` 键(保持旧行为,daemon 不动该字段); * - 保存 → onSubmit(payload)(调用方接线 taskCreate/taskUpdate + 刷新 + 反馈)。 */ export interface TaskFormPayload { @@ -21,6 +24,8 @@ export interface TaskFormPayload { enabled: boolean; repo?: string; sandbox?: string; + /** 窗口列表;`null` = 显式清空;`undefined` = 不声明(无该键) */ + schedule?: ScheduleWindow[] | null; } export interface TaskFormRequest { @@ -34,16 +39,49 @@ export interface TaskFormDialogProps { types?: string[]; /** 项目下拉选项(仅已注册项目) */ projects?: ProjectRec[]; + /** + * 间隔下限,由 daemon 上报(`tasks_list` 帧的 `min_interval`)。 + * 缺省(未拿到)时不做客户端预检,交给 daemon 权威校验 —— 这样渲染层 + * 永远没有第二份下限数字(issue #2490)。 + */ + minInterval?: number; onSubmit?: (payload: TaskFormPayload) => void | Promise; onDismiss?: () => void; } const SANDBOXES = ["workspace-write", "read-only", "danger-full-access"]; +/** 表单里一行窗口的编辑态:输入框原样字符串,提交时才成形。 */ +interface WindowRow { + days: string; + from: string; + to: string; + interval: string; +} + +const EMPTY_ROW: WindowRow = { days: "", from: "", to: "", interval: "" }; + +function toRow(w: ScheduleWindow): WindowRow { + const raw = w as ScheduleWindow & { days?: unknown; interval?: unknown }; + return { + days: typeof raw.days === "string" ? raw.days : "", + from: String(raw.from ?? ""), + to: String(raw.to ?? ""), + interval: raw.interval === undefined || raw.interval === null ? "" : String(raw.interval), + }; +} + +/** 记录里的窗口(容忍字段缺失或形状不对:读不出就当作没有)。 */ +function windowsOf(task: TaskRec | null): ScheduleWindow[] { + const s = task?.schedule; + return Array.isArray(s) ? s : []; +} + export function TaskFormDialog({ request, types = [], projects = [], + minInterval, onSubmit, onDismiss, }: TaskFormDialogProps) { @@ -52,6 +90,9 @@ export function TaskFormDialog({ const task = request?.task ?? null; const editing = Boolean(task?.name); const [err, setErr] = useState(null); + const [rows, setRows] = useState([]); + /** 打开时该任务本就带窗口 —— 决定「全删」是清空(null)还是「本来就没有」(不声明) */ + const [hadWindows, setHadWindows] = useState(false); const nameRef = useRef(null); const typeRef = useRef(null); @@ -81,10 +122,43 @@ export function TaskFormDialog({ if (enabledRef.current) enabledRef.current.checked = task ? task.enabled !== false : true; if (repoRef.current) repoRef.current.value = String(cfg.repo || ""); if (sandboxRef.current) sandboxRef.current.value = (task as TaskRec & { sandbox?: string })?.sandbox || "workspace-write"; + const existing = windowsOf(task); + setRows(existing.map(toRow)); + setHadWindows(existing.length > 0); nameRef.current?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, task, types.length, projects.length]); + const patchRow = (index: number, field: keyof WindowRow, value: string) => { + setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row))); + }; + const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]); + const removeRow = (index: number) => setRows((prev) => prev.filter((_, i) => i !== index)); + + /** 从编辑行成形为 daemon 的窗口;有内容但不完整 → 报错返回 null。 */ + const buildWindows = (): ScheduleWindow[] | null => { + const out: ScheduleWindow[] = []; + for (const row of rows) { + const days = row.days.trim(); + const from = row.from.trim(); + const to = row.to.trim(); + const rawInterval = row.interval.trim(); + // 全空行 = 点了「添加」但没填,忽略 + if (!days && !from && !to && !rawInterval) continue; + if (!from || !to || !rawInterval) { + setErr(t("settings.taskScheduleInvalid")); + return null; + } + const n = parseInt(rawInterval, 10); + if (!Number.isFinite(n) || (minInterval !== undefined && n < minInterval)) { + setErr(t("settings.taskScheduleInvalid")); + return null; + } + out.push(days ? { days, from, to, interval: n } : { from, to, interval: n }); + } + return out; + }; + const submit = async () => { if (!nameRef.current || !typeRef.current || !projectRef.current || !intervalRef.current) return; const name = nameRef.current.value.trim(); @@ -93,10 +167,12 @@ export function TaskFormDialog({ return; } const interval = parseInt(intervalRef.current.value, 10); - if (!Number.isFinite(interval) || interval < 60) { - setErr(t("settings.taskIntervalInvalid")); + if (!Number.isFinite(interval) || (minInterval !== undefined && interval < minInterval)) { + setErr(t("settings.taskIntervalInvalid", { n: minInterval ?? "" })); return; } + const windows = buildWindows(); + if (windows === null) return; const payload: TaskFormPayload = { name, type: typeRef.current.value, @@ -106,6 +182,10 @@ export function TaskFormDialog({ repo: repoRef.current?.value.trim() || undefined, sandbox: sandboxRef.current?.value || "workspace-write", }; + // 键的存在本身就是语义(daemon 的 update 以「键存在」决定是否动该字段): + // 有窗口才写;原本有而全删 → null(显式清空);从未有过 → 不写这个键。 + if (windows.length) payload.schedule = windows; + else if (hadWindows) payload.schedule = null; try { await onSubmit?.(payload); } finally { @@ -167,8 +247,12 @@ export function TaskFormDialog({ +
+ {t("settings.taskSchedule")} +
+ {rows.length === 0 && ( +
+ {t("settings.taskScheduleEmpty")} +
+ )} + {rows.map((row, i) => ( +
+ patchRow(i, "days", e.target.value)} + style={{ width: 90 }} + /> + patchRow(i, "from", e.target.value)} + style={{ width: 70 }} + /> + patchRow(i, "to", e.target.value)} + style={{ width: 70 }} + /> + patchRow(i, "interval", e.target.value)} + style={{ width: 90 }} + /> + +
+ ))} +
+ +
+
{t("settings.taskScheduleHint")}
+
+
); diff --git a/emrg/gui/renderer/src/lib/i18n-dicts.ts b/emrg/gui/renderer/src/lib/i18n-dicts.ts index 57011b49..8d422d21 100644 --- a/emrg/gui/renderer/src/lib/i18n-dicts.ts +++ b/emrg/gui/renderer/src/lib/i18n-dicts.ts @@ -108,7 +108,16 @@ export const ZH_DICT: Record = { "settings.taskNameRequired": "任务名是必填的哦。", "settings.taskType": "类型", "settings.taskProject": "项目", - "settings.taskInterval": "间隔(秒,≥60)", + "settings.taskInterval": "间隔(秒,≥{n})", + "settings.taskIntervalAny": "间隔(秒)", + "settings.taskIntervalShort": "窗口间隔", + "settings.taskSchedule": "分时段频次(选填)", + "settings.taskScheduleAdd": "+ 添加窗口", + "settings.taskScheduleRemove": "移除", + "settings.taskScheduleDays": "星期(如 mon-fri)", + "settings.taskScheduleEmpty": "未设置窗口 —— 全天按上面的间隔运行", + "settings.taskScheduleInvalid": "窗口需要 HH:MM 的起止时间,以及不低于下限的间隔。", + "settings.taskScheduleHint": "窗口内按该窗口的间隔;所有窗口之外按上面的间隔。全部为本地时间。", "settings.taskEnabled": "启用", "settings.taskRepo": "仓库(选填 owner/repo)", "settings.taskSandbox": "沙箱", @@ -131,7 +140,7 @@ export const ZH_DICT: Record = { "settings.templateDeleteConfirm": "删除自定义类型 {name}?", "settings.taskSaved": "任务已保存", "settings.taskDeleted": "任务已删除", - "settings.taskIntervalInvalid": "间隔必须 ≥ 60 秒", + "settings.taskIntervalInvalid": "间隔必须 ≥ {n} 秒", "settings.templateSaved": "类型已保存", "settings.templateDeleted": "类型已删除", "settings.templateAdd": "+ 添加类型", @@ -549,7 +558,16 @@ export const EN_DICT: Record = { "settings.taskNameRequired": "A task name is required.", "settings.taskType": "Type", "settings.taskProject": "Project", - "settings.taskInterval": "Interval (sec, ≥60)", + "settings.taskInterval": "Interval (sec, ≥{n})", + "settings.taskIntervalAny": "Interval (sec)", + "settings.taskIntervalShort": "window interval", + "settings.taskSchedule": "Time-of-day windows (optional)", + "settings.taskScheduleAdd": "+ Add window", + "settings.taskScheduleRemove": "Remove", + "settings.taskScheduleDays": "days (e.g. mon-fri)", + "settings.taskScheduleEmpty": "No windows — one frequency all day, at the interval above", + "settings.taskScheduleInvalid": "A window needs HH:MM start and end times and an interval at or above the minimum.", + "settings.taskScheduleHint": "Inside a window its own interval applies; outside every window the interval above applies. All times are local.", "settings.taskEnabled": "Enabled", "settings.taskRepo": "Repo (optional owner/repo)", "settings.taskSandbox": "Sandbox", @@ -572,7 +590,7 @@ export const EN_DICT: Record = { "settings.templateDeleteConfirm": "Delete custom type {name}?", "settings.taskSaved": "Task saved", "settings.taskDeleted": "Task deleted", - "settings.taskIntervalInvalid": "Interval must be at least 60 seconds", + "settings.taskIntervalInvalid": "Interval must be at least {n} seconds", "settings.templateSaved": "Type saved", "settings.templateDeleted": "Type deleted", "settings.templateAdd": "+ Add type", diff --git a/emrg/gui/renderer/src/lib/i18n.test.ts b/emrg/gui/renderer/src/lib/i18n.test.ts index 4e851af9..66c0c38a 100644 --- a/emrg/gui/renderer/src/lib/i18n.test.ts +++ b/emrg/gui/renderer/src/lib/i18n.test.ts @@ -86,11 +86,12 @@ describe("词典完整性守卫(防漂移)", () => { // `sandboxRoots.*`(12 条)——会话额外可写根的 GUI 入口与对话框,rant // 2026-10-09T09:43:39 Part 2(a)——从 413 改为 429。 // 2026-10-11 issue #2113:device flow 的两个提示串 → 431。 - it("zh/en 各 431 个 key 且完全对齐", () => { + // 2026-10-22 issue #2490:调度窗口 9 个键(含下限插值)→ 440。 + it("zh/en 各 440 个 key 且完全对齐", () => { const zhKeys = Object.keys(ZH_DICT); const enKeys = Object.keys(EN_DICT); - expect(zhKeys.length).toBe(431); - expect(enKeys.length).toBe(431); + expect(zhKeys.length).toBe(440); + expect(enKeys.length).toBe(440); expect(zhKeys.sort()).toEqual(enKeys.sort()); // DICTS 聚合结构 expect(Object.keys(DICTS)).toEqual(["zh", "en"]); diff --git a/emrg/gui/renderer/src/lib/workspaceView.ts b/emrg/gui/renderer/src/lib/workspaceView.ts index d29fccca..a9ed05b2 100644 --- a/emrg/gui/renderer/src/lib/workspaceView.ts +++ b/emrg/gui/renderer/src/lib/workspaceView.ts @@ -23,6 +23,17 @@ export interface ProjectRec { latest_session_at?: string | null; } +/** + * 一个调度窗口(issue #2490):daemon 的 `schedule` 字段逐字原样。 + * 全部本地时间;`days` 可省略=每天;`from`/`to` 为 `HH:MM`(允许 24:00)。 + */ +export interface ScheduleWindow { + days?: string; + from: string; + to: string; + interval: number; +} + export interface TaskRec { name?: string; type?: string; @@ -34,6 +45,8 @@ export interface TaskRec { last_run_at?: string | null; saturation?: { heartbeat_active?: boolean; heartbeat_interval?: number } | null; config?: { project?: string; repo?: string; sandbox?: string } | null; + /** 分时段频次窗口;缺省=全天单一 `interval`(`list_tasks()` 逐字回报) */ + schedule?: ScheduleWindow[] | null; /** 任务面板「打开会话」所需字段(daemon TaskHandler.status() 返回,vanilla #924 迁移时丢失) */ session_id?: string; project?: string; diff --git a/emrg/gui/test/boot-contract.test.js b/emrg/gui/test/boot-contract.test.js index d1f660b8..b150bea0 100644 --- a/emrg/gui/test/boot-contract.test.js +++ b/emrg/gui/test/boot-contract.test.js @@ -293,3 +293,48 @@ test("issue #2484: askSessionCwd asks, judges, and never falls back to the home "DEFAULT_CWD may be the candidate it sends, never the value it returns", ); }); + +test("issue #2490: emrg:taskCreate forwards `schedule`, and only when the payload carries it", () => { + // `schedule` is a top-level field of a task record. This handler built its + // `task_create` payload from a fixed destructured list, so the key was dropped on + // the floor and the GUI could only ever create a one-frequency task — the daemon + // accepted the field the whole time. No runtime test covers the main process, which + // is why this guard reads the source: it asserts the payload is built from a value + // it forwards, not from a list that omits the field. + const src = read("main.js"); + const start = src.indexOf('ipcMain.handle("emrg:taskCreate"'); + assert.ok(start > 0, "main.js must contain an emrg:taskCreate handler"); + const body = src.slice(start, src.indexOf('\n });\n', start) + 8); + assert.match( + body, + /schedule/, + "emrg:taskCreate must name `schedule` — dropping it means the GUI can never create " + + "a task with time-of-day windows (issue #2490)." + ); + assert.match( + body, + /"schedule" in \(payload \|\| \{\}\)/, + "the key must be forwarded only when the payload carries it: an absent key means " + + "'do not touch the field', which is what keeps a pre-existing task's windows." + ); + assert.match( + body, + /sendCommandAndWait\("task_create",\s*fields/, + "the forwarded value has to reach the daemon command, not sit in a local object." + ); +}); + +test("issue #2490: emrg:taskLimits reports the daemon's own minimum, never a literal", () => { + // The renderer's interval floor is the daemon's MIN_INTERVAL. The only honest way to + // give it to the GUI is to report it, so this handler must read the field out of the + // daemon's frame — a number written here would be the second copy the issue removes. + const src = read("main.js"); + const start = src.indexOf('ipcMain.handle("emrg:taskLimits"'); + assert.ok(start > 0, "main.js must contain an emrg:taskLimits handler"); + const body = src.slice(start, src.indexOf('\n });\n', start) + 8); + assert.match(body, /frame\.min_interval/, "the floor is read from the daemon's frame"); + assert.ok( + !/\b60\b/.test(body), + "no literal floor may live in this handler — that is the copy issue #2490 removes" + ); +}); diff --git a/emrg/gui/test/preload-api.test.js b/emrg/gui/test/preload-api.test.js index f43c9c98..de94b22a 100644 --- a/emrg/gui/test/preload-api.test.js +++ b/emrg/gui/test/preload-api.test.js @@ -61,6 +61,7 @@ const EXPECTED_INVOKE_METHODS = [ "registerProject", "removeProject", "listTasks", + "taskLimits", "triggerTask", "taskCreate", "taskUpdate", @@ -124,8 +125,8 @@ function parseApiBlock(block) { const api = parseApiBlock(extractApiBlock()); -test("window.emrg exposes the full contract (58 invoke + onEvent)", () => { - // 58 invoke 方法全部存在(防误删/改名) +test("window.emrg exposes the full contract (59 invoke + onEvent)", () => { + // 59 invoke 方法全部存在(防误删/改名) for (const name of EXPECTED_INVOKE_METHODS) { assert.ok(api.invoke.has(name), `缺少 invoke 方法 ${name}`); } diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 2aa09f8f..8ca1f64b 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -166,7 +166,12 @@ def resolve_client_tier(session, req_sandbox: str | None = None) -> str: from emrg.skills.loader import load_skills from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed from emrg.server.rants import append_rant, list_rants -from emrg.server.scheduler import TaskScheduler, read_task_run_records, task_run_files +from emrg.server.scheduler import ( + MIN_INTERVAL, + TaskScheduler, + read_task_run_records, + task_run_files, +) from emrg.server import logcontext logger = logging.getLogger(__name__) @@ -3059,6 +3064,11 @@ async def _process_message( await self._send(ws, { "type": "tasks_list", "tasks": tasks, + # The floor the daemon itself refuses below, reported rather than + # restated: the renderer used to carry its own copy of this number in + # two comparisons and two sentences, which drifts silently the moment + # the daemon's changes (issue #2490). One producer, one value. + "min_interval": MIN_INTERVAL, }) elif msg_type == "trigger_task": diff --git a/tests/test_daemon.py b/tests/test_daemon.py index fe4d9b7f..0e113fe6 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -5762,3 +5762,45 @@ def test_the_read_loop_still_accepts_a_task_carrying_a_plain_session_id( assert not [f for f in frames if "invalid session id" in str(f.get("error", ""))] assert (project / ".emrg" / "sessions" / "fresh").is_dir() + + +# ── the task list frame is the renderer's one source for the floor ─────────── + + +def test_the_tasks_list_frame_carries_the_minimum_interval(tmp_path, monkeypatch): + """Issue #2490: the GUI reads its `interval` floor from this frame. + + The renderer used to carry its own copy of the number — a comparison, an input + `min` and two sentences — so raising `MIN_INTERVAL` in Python left the GUI + refusing a value the daemon accepts (or the reverse). The frame reports the + daemon's own constant instead, and this asserts the value is that constant + rather than a second literal that happens to agree with it today. + """ + from emrg.server.scheduler import MIN_INTERVAL + + monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path) + windows = [{"days": "mon-fri", "from": "09:00", "to": "18:00", "interval": 900}] + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tmp_path / "tasks.yml" + sched._tasks_file.write_text(yaml.safe_dump([ + {"name": "windowed", "type": "evolution", "config": {"project": "emrg"}, + "interval": 10800, "enabled": True, "schedule": windows}, + {"name": "plain", "type": "evolution", "config": {"project": "emrg"}, + "interval": 1800, "enabled": True}, + ])) + + server = _make_server() + server._scheduler = sched + # `_FakeWs` decodes what the daemon writes; `_FakeWriter` keeps raw frames. + writer = _FakeWs() + asyncio.run(server._process_message({"type": "list_tasks"}, writer)) + + frames = [f for f in writer.sent if f.get("type") == "tasks_list"] + assert len(frames) == 1, writer.sent + frame = frames[0] + assert frame["min_interval"] == MIN_INTERVAL, ( + "the frame reports the daemon's own floor, not a copy of it" + ) + rows = {t["name"]: t for t in frame["tasks"]} + assert rows["windowed"]["schedule"] == windows, "the form prefills from this" + assert rows["plain"]["schedule"] is None, "absent stays absent, not []"