diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index cbe56e44..38c6c65d 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -1559,6 +1559,7 @@ const App = (() => {
Dialogs.initRenameDialog();
Dialogs.initGithubSection(); // Windows GCM rant Stage 2:设置页 GitHub 连接
Dialogs.initDeviceDialog(); // Windows GCM rant Stage 2b:device flow 对话框
+ Dialogs.initTaskManagement(); // rant 18:23:15 P3:定时任务/自定义类型管理
initGithubBanner(); // Windows GCM rant Stage 2:演化需 GitHub 但未认证时的连接横幅
initModelSwitcher();
initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js
index 45c556e0..bd01028d 100644
--- a/emrg/gui/renderer/js/dialogs.js
+++ b/emrg/gui/renderer/js/dialogs.js
@@ -232,6 +232,11 @@ const Dialogs = (() => {
} catch { /* 元素缺失(测试桩)时忽略 */ }
// Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新
await refreshGithubStatus();
+ // 定时任务管理(rant 2026-08-12T18:23:15 P3):任务/类型列表随设置面板打开时刷新
+ try {
+ await loadTaskMeta();
+ await renderTaskList();
+ } catch { /* 元素缺失(测试桩)时忽略 */ }
// Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows
// a one-time non-intrusive line when a newer release exists.
// rant 2026-08-11T09:18:16:手动"检查更新"按钮(force 重新检查)
@@ -611,6 +616,298 @@ const Dialogs = (() => {
} catch { /* boot-time daemon may not be ready — silent */ }
}
+ // ── 定时任务管理(rant 2026-08-12T18:23:15 P3:GUI 任务/自定义类型 CRUD) ──
+ // 决策点执行:①内置类型只读 ②被任务引用的自定义类型拒绝删除 ③项目仅限已注册
+ // ④热重载(daemon apply_tasks)⑤间隔 ≥60 ⑥内置模板只读。
+ let taskTypes = []; // [{name, builtin}] — 类型下拉选项(内置 + 自定义)
+ let taskProjects = []; // listProjects 结果 — 项目下拉仅已注册项目(决策点③)
+ let editingTask = null; // 正在编辑的任务名(null = 新增;daemon 以 name 定位 → 名称不可改)
+ let editingTemplate = null; // 正在编辑的自定义类型名(null = 新增;名称不可改)
+
+ async function loadTaskMeta() {
+ try {
+ const templates = await window.emrg.taskTemplateList();
+ taskTypes = Array.isArray(templates)
+ ? templates.map((t) => ({ name: t.name, builtin: Boolean(t.builtin) }))
+ : [];
+ } catch { taskTypes = []; }
+ try {
+ const projects = await window.emrg.listProjects();
+ taskProjects = Array.isArray(projects) ? projects : [];
+ } catch { taskProjects = []; }
+ }
+
+ async function renderTaskList() {
+ const list = $("task-list");
+ if (!list) return; // 元素缺失(测试桩)时忽略
+ list.innerHTML = "";
+ let tasks = [];
+ try {
+ tasks = await window.emrg.listTasks();
+ } catch (e) {
+ list.innerHTML = `
${_t("settings.taskEmpty")}
`;
+ return;
+ }
+ if (!tasks || tasks.length === 0) {
+ list.innerHTML = `
${_t("settings.taskEmpty")}
`;
+ return;
+ }
+ for (const t of tasks) {
+ const row = el("div", { class: "task-row" });
+ row.appendChild(el("span", { class: "task-name" }, t.name || "?"));
+ row.appendChild(el("span", { class: "task-badge" }, t.type || "evolution"));
+ const cfg = (t.config && typeof t.config === "object") ? t.config : {};
+ const hints = [];
+ if (cfg.project) hints.push(cfg.project);
+ if (t.interval != null) hints.push(_t("app.taskInterval", { n: t.interval ?? "-" }));
+ if (t.enabled === false) hints.push(_t("app.taskDisabled"));
+ row.appendChild(el("span", { class: "task-hint" }, hints.join(" · ")));
+ const actions = el("span", { class: "task-actions" });
+ // 触发(复用 /trigger 语义)
+ const trigBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.taskTrigger") }, _t("settings.taskTrigger"));
+ trigBtn.addEventListener("click", async () => {
+ try {
+ const res = await window.emrg.triggerTask({ name: t.name });
+ if (res && res.error) Chat.addSystemMessage(_t("app.triggerFailed", { msg: res.error }));
+ else Chat.addSystemMessage(_t("app.triggered", { n: t.name }));
+ } catch (e) {
+ Chat.addSystemMessage(_t("app.triggerFailed", { msg: e.message }));
+ }
+ });
+ actions.appendChild(trigBtn);
+ // 编辑(表单预填 → taskUpdate)
+ const editBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.taskEdit") }, _t("settings.taskEdit"));
+ editBtn.addEventListener("click", () => openTaskForm(t));
+ actions.appendChild(editBtn);
+ // 删除(确认弹窗 → taskDelete → 热重载由 daemon apply_tasks 完成)
+ const delBtn = el("button", { type: "button", class: "model-action-btn danger", title: _t("settings.taskDelete") }, _t("settings.taskDelete"));
+ delBtn.addEventListener("click", () => {
+ showConfirm(_t("settings.taskDelete"), _t("settings.taskDeleteConfirm", { name: t.name }), {
+ okText: _t("settings.taskDelete"),
+ danger: true,
+ onOk: async () => {
+ try {
+ await window.emrg.taskDelete({ name: t.name });
+ Chat.addSystemMessage(_t("settings.taskDeleted"));
+ await renderTaskList();
+ } catch (e) {
+ Chat.addSystemMessage(_t("app.tasksFailed", { msg: e.message }));
+ }
+ },
+ });
+ });
+ actions.appendChild(delBtn);
+ row.appendChild(actions);
+ list.appendChild(row);
+ }
+ }
+
+ function openTaskForm(task = null) {
+ editingTask = task ? task.name : null;
+ $("task-form-name").value = task ? task.name : "";
+ $("task-form-name").disabled = Boolean(task); // 决策点:daemon 以 name 定位 → 不可改名
+ // 类型下拉(内置 + 自定义;决策点⑥内置模板只读体现在模板管理区)
+ const typeSel = $("task-form-type");
+ typeSel.innerHTML = "";
+ for (const tp of taskTypes) {
+ const opt = el("option", { value: tp.name }, tp.name);
+ typeSel.appendChild(opt);
+ }
+ const curType = (task && task.type) || (taskTypes[0] && taskTypes[0].name) || "evolution";
+ typeSel.value = taskTypes.some((tp) => tp.name === curType) ? curType : (taskTypes[0] ? taskTypes[0].name : "evolution");
+ // 项目下拉(决策点③:仅已注册项目)
+ const projSel = $("task-form-project");
+ projSel.innerHTML = "";
+ for (const p of taskProjects) {
+ const pv = p.name || p.path || "";
+ projSel.appendChild(el("option", { value: pv }, pv));
+ }
+ const cfg = (task && task.config && typeof task.config === "object") ? task.config : {};
+ const curProj = cfg.project || (taskProjects[0] ? taskProjects[0].name || taskProjects[0].path || "" : "");
+ projSel.value = taskProjects.some((p) => (p.name || p.path || "") === curProj)
+ ? curProj
+ : (taskProjects[0] ? taskProjects[0].name || taskProjects[0].path || "" : "");
+ $("task-form-interval").value = task && task.interval != null ? task.interval : 1800;
+ $("task-form-enabled").checked = task ? task.enabled !== false : true;
+ $("task-form-repo").value = cfg.repo || "";
+ $("task-form").classList.remove("hidden");
+ $("task-form-name").focus();
+ }
+
+ function closeTaskForm() {
+ $("task-form").classList.add("hidden");
+ editingTask = null;
+ $("task-form-name").disabled = false;
+ }
+
+ async function saveTaskForm() {
+ const name = $("task-form-name").value.trim();
+ if (!name) {
+ showConfirm(_t("dlg.stepTitle"), _t("dlg.nameRequiredBody"), { okText: _t("dlg.gotIt"), danger: false });
+ return;
+ }
+ // 决策点⑤:间隔最小值 60s(客户端先行校验,daemon 兜底)
+ const interval = parseInt($("task-form-interval").value, 10);
+ if (!Number.isFinite(interval) || interval < 60) {
+ showConfirm(_t("dlg.stepTitle"), _t("settings.taskIntervalInvalid"), { okText: _t("dlg.gotIt"), danger: false });
+ return;
+ }
+ const payload = {
+ name,
+ type: $("task-form-type").value,
+ project: $("task-form-project").value,
+ interval,
+ enabled: $("task-form-enabled").checked,
+ repo: $("task-form-repo").value.trim() || undefined,
+ };
+ try {
+ if (editingTask === null) await window.emrg.taskCreate(payload);
+ else await window.emrg.taskUpdate(payload);
+ Chat.addSystemMessage(_t("settings.taskSaved"));
+ closeTaskForm();
+ await renderTaskList();
+ } catch (e) {
+ Chat.addSystemMessage(_t("app.tasksFailed", { msg: e.message }));
+ }
+ }
+
+ // ── 自定义类型管理(决策点①⑥:内置只读;决策点②:被引用拒绝删除) ──
+ async function renderTemplateList() {
+ const wrap = $("task-template-list");
+ if (!wrap) return;
+ wrap.innerHTML = "";
+ let templates = [];
+ try {
+ templates = await window.emrg.taskTemplateList();
+ } catch (e) {
+ wrap.innerHTML = `
${_t("settings.templateEmpty")}
`;
+ return;
+ }
+ if (!templates || templates.length === 0) {
+ wrap.innerHTML = `
${_t("settings.templateEmpty")}
`;
+ return;
+ }
+ for (const t of templates) {
+ const row = el("div", { class: "task-row" });
+ row.appendChild(el("span", { class: "task-name" }, t.name));
+ row.appendChild(el("span", { class: "task-badge" }, t.builtin ? _t("settings.taskBuiltin") : _t("settings.taskCustom")));
+ const body = t.prompt || t.template || "";
+ row.appendChild(el("span", { class: "task-hint" }, body.length > 60 ? body.slice(0, 60) + "…" : body));
+ // 内置只读(决策点①⑥):不渲染任何操作按钮
+ if (!t.builtin) {
+ const actions = el("span", { class: "task-actions" });
+ const editBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.templateEdit") }, _t("settings.templateEdit"));
+ editBtn.addEventListener("click", () => openTemplateForm(t));
+ actions.appendChild(editBtn);
+ const delBtn = el("button", { type: "button", class: "model-action-btn danger", title: _t("settings.templateDelete") }, _t("settings.templateDelete"));
+ delBtn.addEventListener("click", () => {
+ showConfirm(_t("settings.templateDelete"), _t("settings.templateDeleteConfirm", { name: t.name }), {
+ okText: _t("settings.templateDelete"),
+ danger: true,
+ onOk: async () => {
+ try {
+ await window.emrg.taskTemplateDelete({ name: t.name });
+ Chat.addSystemMessage(_t("settings.templateDeleted"));
+ await loadTaskMeta(); // 刷新类型选项(任务表单下拉)
+ await renderTemplateList();
+ } catch (e) {
+ // 决策点②:被任务引用的类型 daemon 拒绝删除(错误信息含任务数)
+ Chat.addSystemMessage(_t("settings.templateDeleteFailed", { msg: e.message }));
+ }
+ },
+ });
+ });
+ actions.appendChild(delBtn);
+ row.appendChild(actions);
+ }
+ wrap.appendChild(row);
+ }
+ // 底部"+ 添加类型"(仅自定义类型可增删改)
+ const addRow = el("div", { style: "display:flex;justify-content:center;margin-top:6px;" });
+ const addBtn = el("button", { type: "button", class: "btn btn-ghost", style: "padding:4px 12px;" }, _t("settings.templateAdd"));
+ addBtn.addEventListener("click", () => openTemplateForm(null));
+ addRow.appendChild(addBtn);
+ wrap.appendChild(addRow);
+ }
+
+ function openTemplateForm(tpl = null) {
+ editingTemplate = tpl ? tpl.name : null;
+ $("task-template-name").value = tpl ? tpl.name : "";
+ $("task-template-name").disabled = Boolean(tpl); // daemon 以 name 定位 → 不可改名
+ $("task-template-prompt").value = tpl ? (tpl.prompt || "") : "";
+ $("task-template-form").classList.remove("hidden");
+ $("task-template-name").focus();
+ }
+
+ function closeTemplateForm() {
+ $("task-template-form").classList.add("hidden");
+ editingTemplate = null;
+ $("task-template-name").disabled = false;
+ $("task-template-name").value = "";
+ $("task-template-prompt").value = "";
+ }
+
+ async function saveTemplateForm() {
+ const name = $("task-template-name").value.trim();
+ const prompt = $("task-template-prompt").value;
+ if (!name || !prompt.trim()) {
+ showConfirm(_t("dlg.stepTitle"), _t("settings.templateInvalid"), { okText: _t("dlg.gotIt"), danger: false });
+ return;
+ }
+ try {
+ if (editingTemplate === null) await window.emrg.taskTemplateCreate({ name, prompt });
+ else await window.emrg.taskTemplateUpdate({ name, prompt });
+ Chat.addSystemMessage(_t("settings.templateSaved"));
+ closeTemplateForm();
+ await loadTaskMeta();
+ await renderTemplateList();
+ } catch (e) {
+ Chat.addSystemMessage(_t("settings.templateSaveFailed", { msg: e.message }));
+ }
+ }
+
+ function initTaskManagement() {
+ const addBtn = $("task-add-btn");
+ if (addBtn) addBtn.addEventListener("click", () => openTaskForm(null));
+ const saveBtn = $("task-form-save");
+ if (saveBtn) saveBtn.addEventListener("click", saveTaskForm);
+ const cancelBtn = $("task-form-cancel");
+ if (cancelBtn) cancelBtn.addEventListener("click", closeTaskForm);
+ const tmgrBtn = $("task-template-mgr-btn");
+ if (tmgrBtn) {
+ tmgrBtn.addEventListener("click", async () => {
+ const wrap = $("task-template-list");
+ if (!wrap) return;
+ if (wrap.classList.contains("hidden")) {
+ wrap.classList.remove("hidden");
+ closeTemplateForm(); // 收起表单,避免与列表并列
+ await loadTaskMeta();
+ await renderTemplateList();
+ } else {
+ wrap.classList.add("hidden");
+ }
+ });
+ }
+ const tSave = $("task-template-save");
+ if (tSave) tSave.addEventListener("click", saveTemplateForm);
+ const tCancel = $("task-template-cancel");
+ if (tCancel) tCancel.addEventListener("click", closeTemplateForm);
+ const nameInput = $("task-form-name");
+ if (nameInput) {
+ nameInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); saveTaskForm(); }
+ else if (e.key === "Escape") { e.preventDefault(); closeTaskForm(); }
+ });
+ }
+ const tplInput = $("task-template-name");
+ if (tplInput) {
+ tplInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); saveTemplateForm(); }
+ else if (e.key === "Escape") { e.preventDefault(); closeTemplateForm(); }
+ });
+ }
+ }
+
// ── 确认对话框(替代 confirm/alert) ────
let confirmCb = null;
function showConfirm(title, message, opts = {}) {
@@ -828,6 +1125,10 @@ const Dialogs = (() => {
showOpenSessionDialog, // P5:两步打开会话
initNewSessionDialog, // P5 slice 2:新建会话对话框初始化
showNewSessionDialog, // P5 slice 2:新建会话(选项目)
+ initTaskManagement, // rant 18:23:15 P3:定时任务/自定义类型管理初始化
+ renderTaskList, // rant 18:23:15 P3:任务列表渲染(测试/刷新复用)
+ saveTaskForm, // rant 18:23:15 P3:任务表单提交(测试复用)
+ saveTemplateForm, // rant 18:23:15 P3:类型表单提交(测试复用)
showRename,
submitRename,
showSettings,
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index 1caf0ccd..a789dcc4 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -75,6 +75,40 @@ const I18N = (() => {
"settings.aboutTitle": "关于",
"settings.aboutDesc": "EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。",
"settings.recentTitle": "最近改进",
+ "settings.groupTasks": "定时任务",
+ "settings.tasksHint": "管理定时任务与自定义任务类型(内置类型只读)",
+ "settings.taskAdd": "+ 添加任务",
+ "settings.taskTemplates": "自定义类型",
+ "settings.taskName": "任务名",
+ "settings.taskType": "类型",
+ "settings.taskProject": "项目",
+ "settings.taskInterval": "间隔(秒,≥60)",
+ "settings.taskEnabled": "启用",
+ "settings.taskRepo": "仓库(选填 owner/repo)",
+ "settings.taskSave": "保存任务",
+ "settings.taskTrigger": "触发",
+ "settings.taskEdit": "编辑",
+ "settings.taskDelete": "删除",
+ "settings.taskEmpty": "暂无任务",
+ "settings.taskBuiltin": "内置",
+ "settings.taskCustom": "自定义",
+ "settings.taskDeleteConfirm": "删除任务 {name}?",
+ "settings.templateName": "类型名",
+ "settings.templatePrompt": "提示词模板",
+ "settings.templateSave": "保存类型",
+ "settings.templateEdit": "编辑",
+ "settings.templateDelete": "删除",
+ "settings.templateEmpty": "暂无自定义类型(内置类型只读)",
+ "settings.templateDeleteConfirm": "删除自定义类型 {name}?",
+ "settings.taskSaved": "任务已保存",
+ "settings.taskDeleted": "任务已删除",
+ "settings.taskIntervalInvalid": "间隔必须 ≥ 60 秒",
+ "settings.templateSaved": "类型已保存",
+ "settings.templateDeleted": "类型已删除",
+ "settings.templateAdd": "+ 添加类型",
+ "settings.templateInvalid": "类型名与提示词模板不能为空",
+ "settings.templateSaveFailed": "类型保存失败:{msg}",
+ "settings.templateDeleteFailed": "类型删除失败:{msg}",
"settings.saveModel": "保存模型",
"settings.defaultHint": "点左侧圆点可设为默认模型,切换后下一条消息即生效",
"settings.groupWorkdir": "工作目录",
@@ -427,6 +461,40 @@ const I18N = (() => {
"settings.aboutTitle": "About",
"settings.aboutDesc": "EMRG is a self-evolving AI agent — it reports every improvement, and you can watch it grow right here.",
"settings.recentTitle": "Recent improvements",
+ "settings.groupTasks": "Scheduled tasks",
+ "settings.tasksHint": "Manage scheduled tasks and custom task types (builtin types are read-only)",
+ "settings.taskAdd": "+ Add task",
+ "settings.taskTemplates": "Custom types",
+ "settings.taskName": "Task name",
+ "settings.taskType": "Type",
+ "settings.taskProject": "Project",
+ "settings.taskInterval": "Interval (sec, ≥60)",
+ "settings.taskEnabled": "Enabled",
+ "settings.taskRepo": "Repo (optional owner/repo)",
+ "settings.taskSave": "Save task",
+ "settings.taskTrigger": "Trigger",
+ "settings.taskEdit": "Edit",
+ "settings.taskDelete": "Delete",
+ "settings.taskEmpty": "No tasks",
+ "settings.taskBuiltin": "builtin",
+ "settings.taskCustom": "custom",
+ "settings.taskDeleteConfirm": "Delete task {name}?",
+ "settings.templateName": "Type name",
+ "settings.templatePrompt": "Prompt template",
+ "settings.templateSave": "Save type",
+ "settings.templateEdit": "Edit",
+ "settings.templateDelete": "Delete",
+ "settings.templateEmpty": "No custom types (builtin types are read-only)",
+ "settings.templateDeleteConfirm": "Delete custom type {name}?",
+ "settings.taskSaved": "Task saved",
+ "settings.taskDeleted": "Task deleted",
+ "settings.taskIntervalInvalid": "Interval must be at least 60 seconds",
+ "settings.templateSaved": "Type saved",
+ "settings.templateDeleted": "Type deleted",
+ "settings.templateAdd": "+ Add type",
+ "settings.templateInvalid": "Type name and prompt template must not be empty",
+ "settings.templateSaveFailed": "Failed to save type: {msg}",
+ "settings.templateDeleteFailed": "Failed to delete type: {msg}",
"settings.saveModel": "Save model",
"settings.defaultHint": "Click the dot on the left to set the default model — takes effect on the next message",
"settings.groupWorkdir": "Working directory",
diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js
index b504a12a..4c33341d 100644
--- a/emrg/gui/test/daemon_client.test.js
+++ b/emrg/gui/test/daemon_client.test.js
@@ -693,6 +693,58 @@ test("RESPONSE_TYPES 映射表与 daemon 命令名一致(修正 clear/rename/t
assert.strictEqual(r10.content, "hi");
});
+test("rant 18:23:15 P2/P3:RESPONSE_TYPES 覆盖任务/模板 CRUD(task_result / templates_list / template_result)", async () => {
+ const client = new DaemonClient({ projectDir: tmpHome });
+ await connectClient(client);
+ const send = (obj) => currentMockWs.emit("message", Buffer.from(JSON.stringify(obj)));
+ // task_create → task_result
+ const p1 = client.sendCommandAndWait("task_create", { name: "t1", type: "evolution", project: "emrg", interval: 600 }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "task_result", ok: true, task: { name: "t1" }, summary: { added: ["t1"] } });
+ const r1 = await p1;
+ assert.strictEqual(r1.type, "task_result");
+ assert.strictEqual(r1.ok, true);
+ assert.deepStrictEqual(r1.summary.added, ["t1"]);
+ // task_update → task_result
+ const p2 = client.sendCommandAndWait("task_update", { name: "t1", interval: 1200 }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "task_result", ok: true, task: { name: "t1", interval: 1200 } });
+ const r2 = await p2;
+ assert.strictEqual(r2.type, "task_result");
+ assert.strictEqual(r2.task.interval, 1200);
+ // task_delete → task_result
+ const p3 = client.sendCommandAndWait("task_delete", { name: "t1" }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "task_result", ok: true, summary: { removed: ["t1"] } });
+ const r3 = await p3;
+ assert.strictEqual(r3.type, "task_result");
+ assert.deepStrictEqual(r3.summary.removed, ["t1"]);
+ // task_template_list → templates_list
+ const p4 = client.sendCommandAndWait("task_template_list", {}, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "templates_list", templates: [{ name: "evolution", builtin: true }, { name: "sync", builtin: false }] });
+ const r4 = await p4;
+ assert.strictEqual(r4.type, "templates_list");
+ assert.strictEqual(r4.templates.length, 2);
+ // task_template_create / update / delete → template_result
+ const p5 = client.sendCommandAndWait("task_template_create", { name: "sync", prompt: "# s" }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "template_result", ok: true });
+ const r5 = await p5;
+ assert.strictEqual(r5.type, "template_result");
+ assert.strictEqual(r5.ok, true);
+ const p6 = client.sendCommandAndWait("task_template_update", { name: "sync", prompt: "# s2" }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ send({ type: "template_result", ok: true });
+ const r6 = await p6;
+ assert.strictEqual(r6.type, "template_result");
+ const p7 = client.sendCommandAndWait("task_template_delete", { name: "sync" }, 2000);
+ await new Promise((r) => setTimeout(r, 10));
+ // G103:带 error 键的帧 reject 未决命令(daemon template_result 错误形态)
+ send({ type: "template_result", ok: false, error: "builtin task type is read-only" });
+ await assert.rejects(p7, /read-only/);
+});
+
test("命令-响应配对超时 → reject(G93)", async () => {
const client = new DaemonClient({ projectDir: tmpHome });
await connectClient(client);
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index d5e00f9e..bb14f822 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -131,6 +131,13 @@ const ELEMENT_IDS = [
"growth-card", "growth-count", "about-recent",
"about-update", "about-update-check-btn",
"github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss",
+ // rant 18:23:15 P3:定时任务/自定义类型管理(settings 区)
+ "task-list", "task-add-btn", "task-template-mgr-btn",
+ "task-form", "task-form-name", "task-form-type", "task-form-project",
+ "task-form-interval", "task-form-enabled", "task-form-repo",
+ "task-form-cancel", "task-form-save",
+ "task-template-form", "task-template-name", "task-template-prompt",
+ "task-template-cancel", "task-template-save", "task-template-list",
];
/** 构造浏览器沙箱(win 即全局对象) */
@@ -2438,3 +2445,158 @@ test("rant 12:10:downloaded_version == 当前版本 → 无安装按钮(退
assert.ok(!child.className.includes("btn"), "not a button when downloaded == current");
assert.ok((child.attributes.href || "").includes("releases"), "falls back to the Releases link");
});
+
+// ── rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) ──
+test("P3:设置面板打开 → 任务列表渲染(名称/类型/项目/间隔)+ 编辑预填 → taskUpdate", async () => {
+ let updated = null;
+ const { ctx, els } = makeSandbox({
+ listTasks: async () => [
+ { name: "daily-report", type: "evolution", config: { project: "emrg" }, interval: 1800, enabled: true },
+ { name: "nightly-sync", type: "sync", config: { project: "docs", repo: "acme/docs" }, interval: 3600, enabled: false },
+ ],
+ taskTemplateList: async () => [
+ { name: "evolution", builtin: true, template: "evolution_prompt.md" },
+ { name: "sync", builtin: false, template: "sync.md", prompt: "# sync\nrun the sync" },
+ ],
+ listProjects: async () => [{ name: "emrg", path: "/p/emrg" }, { name: "docs", path: "/p/docs" }],
+ taskUpdate: async (payload) => { updated = payload; return { ok: true }; },
+ triggerTask: async () => ({}),
+ });
+ await tick();
+ await vm.runInContext("EMRG_Dialogs.showSettings()", ctx);
+ await tick();
+ // 任务行渲染:2 行 + 空态兜底(无)
+ const rows = vm.runInContext('document.getElementById("task-list").children.length', ctx);
+ assert.strictEqual(rows, 2, `任务列表应渲染 2 行,实际 ${rows}`);
+ const names = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => r.querySelector(".task-name") ? r.querySelector(".task-name").textContent : "")`, ctx);
+ assert.deepStrictEqual(names, ["daily-report", "nightly-sync"]);
+ const hints = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => r.querySelector(".task-hint") ? r.querySelector(".task-hint").textContent : "")`, ctx);
+ assert.ok(hints[0].includes("emrg") && hints[0].includes("1800"), `任务1 hint: ${hints[0]}`);
+ assert.ok(hints[1].includes("docs") && hints[1].includes("3600") && hints[1].includes("已停用"), `任务2 hint: ${hints[1]}`);
+ // 点击编辑 → 表单预填(名称只读、类型/项目下拉、间隔、enabled)
+ els["task-form"].classList.add("hidden"); // 镜像 index.html 初始态(task-form hidden)
+ await vm.runInContext(`(function() {
+ const row = document.getElementById("task-list").children[0];
+ const btns = row.querySelectorAll(".model-action-btn");
+ btns[1].click(); // 0=触发 1=编辑
+ })()`, ctx);
+ assert.strictEqual(vm.runInContext('document.getElementById("task-form").classList.contains("hidden")', ctx), false, "编辑应展开表单");
+ assert.strictEqual(els["task-form-name"].value, "daily-report");
+ assert.strictEqual(els["task-form-name"].disabled, true, "编辑时名称只读");
+ assert.strictEqual(els["task-form-interval"].value, 1800);
+ assert.strictEqual(els["task-form-enabled"].checked, true);
+ assert.strictEqual(els["task-form-repo"].value, "");
+ // 修改后保存 → taskUpdate
+ vm.runInContext('document.getElementById("task-form-interval").value = "600"', ctx);
+ await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx);
+ await tick();
+ assert.ok(updated, "taskUpdate 应被调用");
+ assert.strictEqual(updated.name, "daily-report");
+ assert.strictEqual(updated.interval, 600);
+ assert.strictEqual(updated.type, "evolution");
+ assert.strictEqual(updated.project, "emrg");
+ assert.strictEqual(updated.enabled, true);
+});
+
+test("P3:新增任务表单 —— 间隔 <60 客户端拒绝;≥60 提交 taskCreate", async () => {
+ let created = null;
+ const { ctx, els } = makeSandbox({
+ listTasks: async () => [],
+ taskTemplateList: async () => [
+ { name: "evolution", builtin: true, template: "evolution_prompt.md" },
+ { name: "custom-a", builtin: false, template: "custom-a.md", prompt: "# a" },
+ ],
+ listProjects: async () => [{ name: "emrg", path: "/p/emrg" }],
+ taskCreate: async (payload) => { created = payload; return { ok: true }; },
+ });
+ await tick();
+ await vm.runInContext("EMRG_Dialogs.showSettings()", ctx);
+ await tick();
+ // 点"+ 添加任务" → 空表单
+ els["task-form"].classList.add("hidden"); // 镜像 index.html 初始态(task-form hidden)
+ await vm.runInContext('document.getElementById("task-add-btn").click()', ctx);
+ assert.strictEqual(els["task-form-name"].disabled, false, "新增时名称可编辑");
+ // 类型下拉含内置 + 自定义
+ const typeOpts = vm.runInContext('Array.from(document.getElementById("task-form-type").children).map((o) => o.value)', ctx);
+ assert.deepStrictEqual(typeOpts.sort(), ["custom-a", "evolution"]);
+ const projOpts = vm.runInContext('Array.from(document.getElementById("task-form-project").children).map((o) => o.value)', ctx);
+ assert.deepStrictEqual(projOpts, ["emrg"], "项目下拉仅已注册项目(决策点③)");
+ // 间隔 30 → 拒绝(决策点⑤:≥60)
+ vm.runInContext(`(function() {
+ document.getElementById("task-form-name").value = "new-task";
+ document.getElementById("task-form-type").value = "custom-a";
+ document.getElementById("task-form-project").value = "emrg";
+ document.getElementById("task-form-interval").value = "30";
+ })()`, ctx);
+ await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx);
+ await tick();
+ assert.strictEqual(els["confirm-dialog"].open, true, "间隔 <60 应弹错误确认框");
+ assert.ok((els["confirm-message"].textContent || "").includes("60"), `错误提示含 60:${els["confirm-message"].textContent}`);
+ assert.strictEqual(created, null, "非法间隔不应提交 taskCreate");
+ await vm.runInContext("EMRG_Dialogs.closeConfirm()", ctx);
+ // 间隔 600 → 提交(含自定义类型 + 启用勾选)
+ vm.runInContext('document.getElementById("task-form-interval").value = "600"', ctx);
+ await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx);
+ await tick();
+ assert.ok(created, "合法输入应提交 taskCreate");
+ assert.strictEqual(created.name, "new-task");
+ assert.strictEqual(created.type, "custom-a");
+ assert.strictEqual(created.project, "emrg");
+ assert.strictEqual(created.interval, 600);
+ assert.strictEqual(created.enabled, true);
+ assert.strictEqual(vm.runInContext('document.getElementById("task-form").classList.contains("hidden")', ctx), true, "保存后表单收起");
+});
+
+test("P3:自定义类型管理 —— 内置只读;自定义增删改走模板 CRUD", async () => {
+ let createdTpl = null;
+ let deletedTpl = null;
+ const { ctx, els } = makeSandbox({
+ listTasks: async () => [],
+ listProjects: async () => [{ name: "emrg", path: "/p/emrg" }],
+ taskTemplateList: async () => [
+ { name: "evolution", builtin: true, template: "evolution_prompt.md" },
+ { name: "sync", builtin: false, template: "sync.md", prompt: "# sync prompt" },
+ ],
+ taskTemplateCreate: async (payload) => { createdTpl = payload; return { ok: true }; },
+ taskTemplateDelete: async (payload) => { deletedTpl = payload; return { ok: true }; },
+ });
+ await tick();
+ await vm.runInContext("EMRG_Dialogs.showSettings()", ctx);
+ await tick();
+ // 打开自定义类型列表(镜像 index.html 初始态:task-template-list hidden)
+ els["task-template-list"].classList.add("hidden");
+ await vm.runInContext('document.getElementById("task-template-mgr-btn").click()', ctx);
+ await tick();
+ const wrap = els["task-template-list"];
+ assert.strictEqual(wrap.classList.contains("hidden"), false, "类型列表应展开");
+ const rows = wrap.children.filter((c) => c.className.includes("task-row"));
+ assert.strictEqual(rows.length, 2, `内置 + 自定义各一行,实际 ${rows.length}`);
+ // 内置行无操作按钮(决策点①⑥);自定义行有编辑/删除
+ const builtinActions = rows[0].querySelectorAll(".model-action-btn").length;
+ const customActions = rows[1].querySelectorAll(".model-action-btn").length;
+ assert.strictEqual(builtinActions, 0, "内置类型只读(无操作按钮)");
+ assert.strictEqual(customActions, 2, "自定义类型有编辑+删除");
+ // 删除自定义 → 确认 → taskTemplateDelete(决策点②:daemon 拒绝被引用类型)
+ const delBtn = rows[1].querySelectorAll(".model-action-btn")[1];
+ delBtn.click();
+ await tick();
+ assert.strictEqual(els["confirm-dialog"].open, true, "删除需确认");
+ await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx);
+ await tick();
+ assert.ok(deletedTpl, "taskTemplateDelete 应被调用");
+ assert.strictEqual(deletedTpl.name, "sync");
+ // 新增自定义类型(底部"+ 添加类型")→ 表单 → taskTemplateCreate
+ const addBtn = wrap.children[wrap.children.length - 1];
+ addBtn.click();
+ await tick();
+ assert.strictEqual(els["task-template-name"].disabled, false, "新增类型名称可编辑");
+ vm.runInContext(`(function() {
+ document.getElementById("task-template-name").value = "nightly";
+ document.getElementById("task-template-prompt").value = "# nightly\\nrun at night";
+ })()`, ctx);
+ await vm.runInContext("EMRG_Dialogs.saveTemplateForm()", ctx);
+ await tick();
+ assert.ok(createdTpl, "taskTemplateCreate 应被调用");
+ assert.strictEqual(createdTpl.name, "nightly");
+ assert.ok(createdTpl.prompt.includes("nightly"), "prompt 原样提交");
+});