From da6f4d5074a98c48c06e6fb1cecf61840b70f24f Mon Sep 17 00:00:00 2001 From: Andre-1998 Date: Sun, 4 Oct 2026 18:04:59 +0800 Subject: [PATCH 1/3] feat(webui): unlock the scheduled-task panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 定时 rail row was rendered inert, so the WebUI had no surface for the cron store the terminal client already drives. This adds one. Server: five operations — listCrons, createCron, updateCron, deleteCron, triggerCron — reaching the engine through `apiHost.cronRuntime`, which the host handle already exposed but only typed as `{ close() }`. Each registry touch awaits `ensureStarted("webui:")`, and each port method fails closed with `runtime host does not expose cron` rather than failing as an undefined call. The wire types mirror the transport-agnostic mapping in `local-runtime/src/cron/contract.ts`; webui does not import `@mavis/local-runtime`, so the engine↔wire mapping is a thin local port and the dependency table stays zero-diff. `deleteCron` is idempotent by contract and reports presence in its `{ success }` boolean; `updateCron` has no such field and raises 404. The asymmetry is deliberate and now covered from both sides. Client: the rail row becomes clickable and opens `SchedulesPanel` — a grouped list plus create/edit/delete, a manual trigger, an enable toggle, and last/next run with the last result. The panel states which side is executing, because the data directory is shared. ADR 0002 gains an amendment. `startupExecutionPolicy` stays `'quarantined'` and `runtimeOwnerKind` stays `'tui'`, so a restart still never resumes persisted jobs on its own; what changes is that using the panel starts the scheduler for the life of the process. A WebUI service and the terminal client can therefore both hold the scheduler for one agent, and the engine's busy-queue does not arbitrate across the two processes. `webui-v1-scope.md` no longer lists cron as out of scope. Verified against the running service over the real WebSocket: create, list, update/pause, delete, duplicate rejection, invalid-expression rejection, and nextRun computation all behave, and the test task was removed afterwards. --- ...untime-host-with-quarantined-cold-start.md | 30 ++ docs/webui/webui-v1-scope.md | 6 +- .../src/client/components/SchedulesPanel.tsx | Bin 0 -> 19364 bytes .../components/WebuiClientFoundationApp.tsx | 24 +- packages/webui/src/client/contracts.ts | 81 +++ packages/webui/src/client/transport.ts | 5 + packages/webui/src/server/host.ts | 119 ++++- packages/webui/src/server/index.ts | 7 + packages/webui/src/server/operation/cron.ts | 479 ++++++++++++++++++ packages/webui/src/server/operation/names.ts | 5 + .../server/operation/operation-handlers.ts | 35 ++ .../webui/src/server/operation/operations.ts | 7 + packages/webui/src/server/port.ts | 80 +++ packages/webui/src/server/service.ts | 26 + .../webui/test/unit/webui-service.test.ts | 410 +++++++++++++++ packages/webui/test/unit/webui-shell.test.ts | 21 +- release/public-source.json | 2 + 17 files changed, 1329 insertions(+), 8 deletions(-) create mode 100644 packages/webui/src/client/components/SchedulesPanel.tsx create mode 100644 packages/webui/src/server/operation/cron.ts diff --git a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md index a56bd743b..681112d02 100644 --- a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md +++ b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md @@ -24,3 +24,33 @@ session is an explicit `resumeSession` with a cursor, not an automatic continuat Execution state that is not persisted — stream buffers, subscriptions, pending permission and questionnaire requests — belongs to the owner process and cannot be recovered from disk. + +## Amendment: the scheduled-task panel starts the cron scheduler on demand + +The scheduled-task panel (`定时`) reads and writes the same cron store the terminal +client uses, reached through `apiHost.cronRuntime` rather than the `services.cron` +composition that `runtimeOwnerKind: 'tui'` leaves undefined. Using it calls +`cronRuntime.ensureStarted()`, which starts the croner scheduler inside the WebUI +process. That is a deliberate change to the picture the options above describe, and +it is worth being precise about what did and did not change. + +**Unchanged.** `startupExecutionPolicy` stays `'quarantined'`. It gates only the +host's own cold-start path, so a WebUI restart still never resumes persisted jobs on +its own. `runtimeOwnerKind` stays `'tui'`; no Electron-only capability is claimed. + +**Changed.** Reaching the scheduler is a *use-time* action, not a startup one. The +first schedules operation loads persisted cron definitions from the shared store and +schedules them for the lifetime of the process. So a WebUI service that is left +running will fire cron tasks at their times, where before it would not. + +**Accepted hazard.** The data directory is shared, so a WebUI service and the +terminal client can both hold the scheduler for the same agent at the same time and +each fire the same task. The engine's busy-queue bounds overlap within one process; +it does not arbitrate across two. The panel therefore states which side is executing +rather than implying the WebUI owns execution. + +**Rejected alternative.** Restricting the panel to list plus manual trigger, and +leaving scheduling to the terminal client only. It keeps one scheduler per data +directory, but a task created in the WebUI does nothing until the user happens to run +the desktop client, which is the "builds but never runs" failure the panel exists to +avoid. diff --git a/docs/webui/webui-v1-scope.md b/docs/webui/webui-v1-scope.md index de42ae0ff..60a53c2e8 100644 --- a/docs/webui/webui-v1-scope.md +++ b/docs/webui/webui-v1-scope.md @@ -22,7 +22,7 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/). ## Out of scope for the first version -- Account, provider, plugin, cron and update panels. States that need them are +- Account, provider, plugin and update panels. States that need them are reported as messages, not as configuration interfaces. - Terminal rendering, terminal image preview, check-in - Remote or LAN access — see @@ -32,6 +32,10 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/). - Automatic resume of persisted jobs at cold start — see [ADR 0002](../adr/0002-in-process-runtime-host-with-quarantined-cold-start.md) +The scheduled-task (`定时`) panel is a later addition to this list. It manages +tasks in the shared cron store and starts the scheduler on first use, so ADR 0002 +carries an amendment describing what that does and does not change. + ## Behaviour boundaries - Shared history is readable, but live execution belongs to the runtime owner that diff --git a/packages/webui/src/client/components/SchedulesPanel.tsx b/packages/webui/src/client/components/SchedulesPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03a23117f53e32cce178dad2d676f5f4baf28682 GIT binary patch literal 19364 zcmds9|8pG0mA{|$S2SBxZr6BsEdh5#mgF)<4zBaJvc+9dI+Zoroz)Ir&2DC9EpLRb z48#c-`|OYe0_RY1jsSOrFM%pBHpc%;@JjMe`4{f<-s|q^ncWpK#@yAZT+)2$e*OBr z*YC@xdt9xm{zq?Zyz%*+n>YIxuBpGC`;AJPzKXn{_4l#*=G;Yj{mr>cs_w^05PC__ z4l85TD*mZCG{xi7NxP%sX3&YzIfzxqTlV)doqzF{o;jqNUR?!=T8{jlUsvsJqL%!c z*NuH@Lv-35KVrhMst0uywiD%_4dSFiBlJ|gjYq4yRoqVLD~#i*PSk$RuMtbC-7wT* zZv`{(mq8%vF&l% z)2aquh(yzm&^K0Rf;d=e`OIC^4MUG^{4;*kTcswu=>_w(+E#d<1S@__7hw=CGZQQU zCcPT8TFUn@E&r@v>tdooVzhVI>ya)R?G|Q2s>3YtD*}_9mRIvv{4h~*H)`NIR>e-M zyNqe9cp;`p?@E*0hirwofgF0VY6WM&Akkt;G%&u9uC+tVAZ9hITD#R<33(ziF}3RT zAlce!G7lD1H>7@1ySv=9!&Jt`f|X7?O4OQy`5f?CE$XwIZe|+|SOQ-j@mJb>al&u# z-;<;waMkPh>I9^b%(i59>#7m8S5yHCs3nEHhB`M~Q?TA{a3E@jbI{Umxt)u=FlH7^ z-%6#5xg`-O#Fgjbl!_*B2yai;ntr_tEjpb7JkNKSYcDUi{FAFe0*%?WRrj$BF;?BR zOC1OAodoLuX47hi%jHHpT4Cdhy1Xc2S%nb{wH?BqL*K8%xLikbm=2jcL{lVdn9Qz%?YN`n70WH@E#;KonOl05GR?2dExj`Q^9#A5 z$LU1)g}GJx_Nn~Lh!H){%nal1%8D1k-f4Db7_mM~OfJ;)3|pZXeWT@(l}Ou)c8ta9 zzs8SLm%F=FK^00BSno;^Vua;3RPiC|Qo_2Yc3#116DK)9I{BSvGs+r*n3yqc9 zrxqVxvKSNYz^H+UbhgPsB)vh$WrKk7EkE2v=~Ph@{IN zMLh~>+Wf_9!}WMA1nzneBdGLZj^Z9sLMTKqI0M(^A(pCQ0lYj4HbX?IxKL87?QW~C ze%cLu3dQK}A%XA}d?R8sf>`RA$nAN_9L6QRp7B~;ti7UnJT4FdpI2SXxDkYY zeL&NsHS!6F*4$S;G!17V{R`^(=hZ_;nN}r;4+bHU1sS3=O37k5b%Y$S6Z)&_DO~D4 z#!4*3qu$XXHLWcBJcDBCTj+KmY_eW2g3}QuOJuVr*@^P=QU#3=^P;3U0UO;@VB^%b z2a7V}9fV|_^}VQAS}#AmhCY^)BM4~CVo5!s#s}Wyz2%M?|6Y zX3_O}r~Tfs2F-+4^CSYg^&Dm;?sfc!UYqMk891_;Hm)*Z|Ck&*2TN=H@AMPnd+33X zAa-nMJH#>V$!F%~jvZCgpag4d!CK0VmVZ_?%TFM`MUXmO-UHk7SK@LFAp_?1To*}k zqi1fHy-s<&^0-3U0&}aETg!0x5tdqceoy7`&e;WXD^E|sB)x76`S5aiY57z@B!#(C z9XJ&)>e&{Zv*kxs;-5{*@rwMV8}k}nkz7#@i995X1ImcYNV3{dteS1SnjAd>zvuOZ zG_yY0?s&Bz>6IVfgIFo1{FA4Tot!)Mzb2e-nY#Ei$m$;E4ml?QiTB$lfAG-k;in{} zfwdOc9^j>o9aR@uied2F)2sa5NbTxW_Ou&-$RgaS07diIYoi&)Kcpc#5CQ6w^G| z5iha?DB*(^08HrAaCZ#YkKIJZ>#y1Iz@Szln_Bj;lBs|LFfFvLbLMoRS zRDlo*2S~w-28w;uR7=wSthy5KiKqW`qYY3Thr819)Di1T#(HhmkeHzeSn1C@a@Y*Zt^`=le*F37Bi;OdstZ2WRprA6L zFQ^qIx*n8`gpnVEH-1q84udS!2Xi_blhN5wi6`WrfGko)r`$|$t2W1EQuhObkUTP? zd21Tv$neafAw_YVVprrowP_f`(J7(ZirjQ|#Tn~Wk4&p^F&$LcGP1PQ!;+VqC6o## zfZCp_gUBzF-xUOi4FEVFK}<>03xN$~z!2NkJQ&hekO|vT0~IMGp@Ue;EY-7SY%XpMalVoMslT+Iudts$GS$Cw zY4g2LzB%`TY#7+vtxsO+-*|WH+WT8yf8GD;a{sl98<($azH?*i<4bp6y4AmZ{_eG# z{c9iefAQuw=U$Ygi0r|QqGBQB5_Oi6a)Cg#Xkdj!#@vY)AmWH6P5k1_LC|OpW~tBN zHyYVt)8jbh#i~#P3>Tr8E3i|+>P{##Vt3LAd>{{kKcE$Sibu`~F^r*+B_B?!4gi9G z#I1J2Vu7+EP-A1&FBQsLliTjAbURdeQ!)alqmBY!M7~d%w&Y!NrsaGgZCO~DDRF8o zqh;VgEvMA(WOYePqvJ_snwckVE2Dh(nb?5)#K?Y98@jpI_UJaX@S-dUi^3W9*Gr}~ zfHUZ>XxF@T6@x~xt&Cj>%TCHpB%HR)N!YFw!zWg-hJw>4gJR1I&4_4($Y`-yHK2b>imSJH``iJF>nw|3g6ELRUfI2c1hsa0FUAm6aW z?Mq7jRyy6d$<1veALW@P2KSXCM(GkbB>|90iqp|~(0rv*F>JuWM1c=LYe6sNN{q6$ zSe$S9jRboZ^QhOF$pXhgdQ{;Y#y^0~0Awmfep@P;zAIj*n7V}d)W!r&Vlwuwir?Vd~vVA4#XjV5rD9MWN76hf*RsD{hEve!kCA)r+r(qI%UgMMig zqo7BJm-Wg3k&~c4T`)^EE_Vo~$h29d4Zd=&#dLwnGHExXDNZ$!#UgFZ&&z^>JcZTB z>tx|U!i6R*=4US$5;WKyK{Su4POtoE!3?LAM1;)Np)vvGld z=Ewh$Fvteb-SP^;LU%@2qlu{e7f6~8sL((QkoFb~U;hILhkVvN<19Bgb^k`XUr0rt2DPzPb zHp09z@4ZYH`sY8p`^JavQ4m+$0_Cw#HFqwhly{;kl|mT);a(? zDjaQzNyipqvl}MGrds^BY6YtZIQ-c~kZS9b7a4NApJw0^vro;W^Jg&Z@e4{Or5oi4 zCXK{y*1FPsPIU~!&bld>MQ^=`y~w(gtkbl}vvo5X4nB*s8hY(Rir!hsc+j!h-l;i^ zSfp~igti^*Ng2~8H4xdo@85c@|ChJ__SIV(SN}}A%WC85B^*Aw^VM7ZDF0c9nkuJe%CZSBs6X^Lu(TI;hHSu-@#ebkr%F*U+?hy%A74$N>J4ku1U zo}@D-ZHSl(jb?IBX#qEFi}~!E5gr;!Ov6-x7sJrKHj`}_Im>Wsy3B%X z7dO9pee3IAiG{(=A2#o7!nM8#A6Sr(kny^yA~UL_A*SlV8J9s)hwMOc-oZ-Am{_So z)J1oq(vA1tr`K{&%3wPx?vLSE(X^2&@7LR6YOn(zW&;F`1a7);=j&Jd@4g7MFbG|X z6BZENfJ-j>uH6kxjl;jB2nGNXG;B>FBiFze@Wb>_k=Uw!Gv`GpaVm1sVBMAosp#?( za8f*KG*xA#W>xfkDOx*426=K5pL#HgHYG@Y*0NoA-+JEo`0~aZf7<-$+{T4JJy?C4 zi2!dQmt+j#0jPVb3Qyqb|3P|&iHX!R%ug%~`i30x_rq9?N#h2sX4qoiqkfF-gk5U9 z{P=^l0mIgVw}E@{?f;N!aENcG2R}cm?i|FS)PC5F-i@9PfQF1?M%oP1T?RN?0V#{47T2Tw||*(as0k}?uE^pZ|VdC zd42yE=ldUhnO~(l(xctw*)A#zllP{>?36O$ct|k*7H6C}J;v}*K4)_eduNo0)V%2f zEv=g*Nu$MC{*;`jL`{F?eB!quGXn5xp~S!x3e z39eRf5jalI1ntNIVa`O>GJp)9kz!!QLiYp7cmMM18=t?NQQaV>;tZW>X;305h4LzO zmj7r}j!17)x0Q#JP9Zwfn}jg(Cb8_1Wi_9bGqjc#QUxjeMOiQZ2k^52P36HQN{}<@ z(NvX;+6*#S6AOZvt%x&)pe)+YP);>(&nv?T$9WgWo{h~t~=Rv ztkBL_(cKW^x_M6andsDKih^@w#j)|}HCtQbJWOt<$tYYyXxSZ}G7swZ&uFVT;gXYe zzf>Mqoy45IhK$|KlP9^Qe7%kA$QJV46zrwF6L>~}?*+JI78>jat zqk$DjB3N@jE}dupKnF&<@xpMW52o-U$N|A z3swq3L-nKh`wZFwEn1@a!*C*o^tXF-D?RddABW>o(sE@J1so2{L=a?3-cg$d$;{Ay?}d8rhvJ>+(l_J?1#sHT#XM9Gnusqy#@; z>kTsshTncmDwA^))WOYhY;WraK#lst-N)J&E?)kd@}Ur&0lZ4eU;|G2Fl~fhadQ^**@Y! z$$; zm}s3tFxeBn=~U>-9#z$;H(cwzctPFkzz)s`J56&(ShF0_-4EjjzYixjJh-g6AW|w> zq(B8hlTnZYY+#^h^MhCWmp+qY967j11T!2x)9;3yvywLkNs|VHi9fYUi_sm}G99HF zc~H%a$(zly`nv{l2lkw6+Cr`@4$GjY`EMN99m=;3`3`iCP6vUG(U}nUW3+rz=fFhz zYPhE4=);i3`+r30ZZYS25Gh~p&w<$5`1|l5me{?x59zcM?~%6rfA2i{FaH7ihWBKW zL*wOCRihSJ%g?OHb!S^6L&WTNAVSMYX(EEI>tp7-YV&^qoJ2)I|5uQFR=s!Ze*hJx BRs#S4 literal 0 HcmV?d00001 diff --git a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx index 445881efc..afaba12a5 100644 --- a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx +++ b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx @@ -63,6 +63,7 @@ import { } from "../projection/shell-surface.js"; import { ConversationUsageBanner } from "./ConversationUsageBanner.js"; import { PluginManagement } from "./PluginManagement.js"; +import { SchedulesPanel } from "./SchedulesPanel.js"; import { WebuiComposer } from "./SessionComposer.js"; import { WebuiSessionTranscript } from "./SessionTranscript.js"; import { @@ -271,6 +272,11 @@ export function WebuiClientFoundationApp( const dispatchShellSurface = useCallback((command: WebuiShellSurfaceCommand) => { setShellSurface((current) => reduceWebuiShellSurface(current, command)); }, []); + // The 「定时」 page is a second replacement surface next to plugin management. + // It is local state rather than a third `WebuiShellSurface` variant because + // that union is read by the rail's active state and by the reducer tests; + // widening it for one page would change every reader. + const [schedulesOpen, setSchedulesOpen] = useState(false); const [selectedSessionId, setSelectedSessionId] = useSelectedSessionId(locationHash); // Rail session links are plain `#session=` anchors, so the navigation runs @@ -283,6 +289,13 @@ export function WebuiClientFoundationApp( useEffect(() => { dispatchShellSurface({ type: "show-conversation" }); }, [dispatchShellSurface, selectedSessionId]); + // The schedules page replaces the conversation the same way plugin + // management does, so it leaves on the same funnel — the rail is its only + // way back. A sibling effect rather than a second statement in the one above, + // because that effect's exact body is asserted by `shell-surface.test.ts`. + useEffect(() => { + setSchedulesOpen(false); + }, [selectedSessionId]); // Composer input history + per-session drafts (roadmap Module B: // 输入历史/草稿). One persisted store keyed by session (home has its own // slot), so a draft survives both a session switch and a reload, and ↑ in @@ -875,8 +888,15 @@ export function WebuiClientFoundationApp( const pluginManagementArea = webuiPluginManagementArea(shellSurface); const pluginManagementOpen = isPluginManagementSurface(shellSurface); const openPluginManagement = useCallback((area: WebuiPluginManagementArea) => { + // The two pages replace the same column, so opening one closes the other. + setSchedulesOpen(false); dispatchShellSurface({ type: "open-plugin-management", area }); }, [dispatchShellSurface]); + const openSchedules = useCallback(() => { + dispatchShellSurface({ type: "close-plugin-management" }); + setSchedulesOpen(true); + }, [dispatchShellSurface]); + const closeSchedules = useCallback(() => setSchedulesOpen(false), []); const [workspacePanelStates, setWorkspacePanelStates] = useState(() => new Map()); // ---- Rail activity: which sessions are running, and when each last moved. // @@ -1172,7 +1192,7 @@ export function WebuiClientFoundationApp(
} active={pluginManagementOpen} onSelect={() => openPluginManagement("plugins")} /> - } inert /> + } active={schedulesOpen} onSelect={openSchedules} /> } inert /> } inert />
@@ -1255,7 +1275,7 @@ export function WebuiClientFoundationApp( data-webui-shell-region="surface" className="relative flex min-h-0 min-w-0 flex-1 flex-row" > - {pluginManagementArea ? { + {schedulesOpen ? : pluginManagementArea ? { if (!transport?.createSession) throw new Error("当前 WebUI 未连接会话创建服务"); const created = await transport.createSession({ name }); const sessionId = created.sessionId ?? created.session?.sessionId; diff --git a/packages/webui/src/client/contracts.ts b/packages/webui/src/client/contracts.ts index 49323830b..496aeb600 100644 --- a/packages/webui/src/client/contracts.ts +++ b/packages/webui/src/client/contracts.ts @@ -423,6 +423,72 @@ export interface WebuiModelSelectionRequest { readonly sessionId?: string; } +/* Scheduled task (cron) wire shapes. + * + * These mirror the runtime's cron contract field for field. The panel is a + * projection of the registry the desktop and the CLI also write to, so a + * renamed field here would silently write somewhere else instead of failing. + * `enabled` is the wire-side polarity; the stored config's `disabled` is the + * runtime's own business and never crosses this boundary. */ + +export type WebuiCronSession = + | { readonly mode: "root" } + | { readonly mode: "sessionId"; readonly sessionId: string } + | { readonly mode: "new"; readonly keepSessions?: number | null }; + +export interface WebuiCronTask { + readonly cronName: string; + readonly agentName: string; + readonly cronId?: string; + readonly schedule: string; + readonly scheduleType: "cron" | "once"; + readonly timezone?: string; + readonly enabled: boolean; + readonly prompt: string; + readonly session: WebuiCronSession; + readonly activeHours?: { readonly start: string; readonly end: string }; + readonly status: "idle" | "running" | "skipped"; + readonly lastRun: number | null; + readonly lastResult: string | null; + readonly lastError: string | null; + readonly nextRun: number | null; +} + +export interface WebuiListCronsResult { + readonly tasks: WebuiCronTask[]; +} + +export interface WebuiCreateCronRequest { + readonly agentName: string; + readonly cronName: string; + readonly schedule: string; + readonly prompt: string; + readonly timezone?: string; + readonly enabled?: boolean; + readonly session?: WebuiCronSession; + readonly activeHours?: { readonly start: string; readonly end: string }; +} + +export interface WebuiUpdateCronRequest { + readonly agentName: string; + readonly cronName: string; + readonly schedule?: string; + readonly prompt?: string; + /** 空字符串表示清除时区。 */ + readonly timezone?: string; + readonly enabled?: boolean; +} + +export interface WebuiDeleteCronRequest { + readonly agentName: string; + readonly cronName: string; +} + +export interface WebuiTriggerCronRequest { + readonly agentName: string; + readonly cronName: string; +} + /* Transport — the single bag of methods the foundation app and the * composer consume. Every method here was previously an optional prop on * `WebuiClientFoundationAppProps`. The optional semantics are preserved: @@ -603,6 +669,21 @@ export interface WebuiTransport { readonly description?: string; }[]; }>; + /** Scheduled tasks. Mutations return `success`; the panel re-reads the list + * afterwards rather than trusting a partial echo of the new state. */ + readonly listCrons?: () => Promise; + readonly createCron?: ( + request: WebuiCreateCronRequest, + ) => Promise<{ readonly success?: boolean }>; + readonly updateCron?: ( + request: WebuiUpdateCronRequest, + ) => Promise<{ readonly success?: boolean }>; + readonly deleteCron?: ( + request: WebuiDeleteCronRequest, + ) => Promise<{ readonly success?: boolean }>; + readonly triggerCron?: ( + request: WebuiTriggerCronRequest, + ) => Promise<{ readonly success?: boolean }>; readonly pluginManagement?: (request: import("../shared/plugin-management.js").WebuiPluginManagementRequest) => Promise; readonly getPermissionMode?: () => Promise; readonly setPermissionMode?: (request: { readonly mode: "default" | "auto" | "bypassPermissions" }) => Promise; diff --git a/packages/webui/src/client/transport.ts b/packages/webui/src/client/transport.ts index 27e239a4d..591773c9b 100644 --- a/packages/webui/src/client/transport.ts +++ b/packages/webui/src/client/transport.ts @@ -429,6 +429,11 @@ export function createWebuiTransport({ deleteQueueItem: (body) => request("deleteQueueItem", body), listModels: (body) => request("listModels", body ?? {}), listSkills: (body) => request("listSkills", body ?? {}), + listCrons: () => request("listCrons", undefined), + createCron: (body) => request("createCron", body), + updateCron: (body) => request("updateCron", body), + deleteCron: (body) => request("deleteCron", body), + triggerCron: (body) => request("triggerCron", body), pluginManagement: (body) => request("pluginManagement", body), getPermissionMode: () => request("getPermissionMode", {}), setPermissionMode: (body) => request("setPermissionMode", body), diff --git a/packages/webui/src/server/host.ts b/packages/webui/src/server/host.ts index d5496cee4..516acd3ea 100644 --- a/packages/webui/src/server/host.ts +++ b/packages/webui/src/server/host.ts @@ -76,6 +76,21 @@ import type { WebuiGoalCreateRequest, WebuiGoalPatchRequest, } from "./port.js"; +import { + toWebuiCronError, + webuiCreateRequestToEngineConfig, + webuiCronTaskFromEngineState, + webuiUpdateRequestToEngineUpdate, + WebuiCronError, + type WebuiCronRuntime, +} from "./operation/cron.js"; +import { + CREATE_CRON_OPERATION_NAME, + DELETE_CRON_OPERATION_NAME, + LIST_CRONS_OPERATION_NAME, + TRIGGER_CRON_OPERATION_NAME, + UPDATE_CRON_OPERATION_NAME, +} from "./operation/names.js"; /** * The exact surface of the runtime `cliService` the WebUI talks to. The @@ -317,7 +332,17 @@ export interface WebuiRuntimeCliService { } export interface WebuiRuntimeHostHandle { - readonly apiHost: { close(): Promise }; + readonly apiHost: { + close(): Promise; + /** + * Scheduled-task engine, borrowed from the runtime host. Optional on + * purpose: the WebUI only reads it, and every host that predates it + * (including the test doubles) must keep type-checking. Each cron port + * method fails closed with `runtime host does not expose cron` when the + * slot is empty — the same nested-guard shape as `requestCompaction`. + */ + readonly cronRuntime?: WebuiCronRuntime; + }; readonly appVersion?: string; readonly dataDir?: string; readonly invalidateAuth?: () => void; @@ -444,6 +469,79 @@ export function createHarnessPortFromHost( async clearGoal(request) { return { success: await requireCliService(host).clearGoal(request.sessionId) }; }, + /* Scheduled tasks. + * + * The registry lives on the runtime host, not the cliService, and the + * scheduler is never started implicitly: ADR 0002 keeps the cold start + * quarantined, so each call pulls it up with an explicit, attributable + * `ensureStarted("webui:")` before reading or mutating. That + * is what makes a WebUI-initiated run distinguishable in the runtime's + * logs from a host-initiated one. + * + * Tasks persist in the shared `~/.minimax` store the desktop and the CLI + * also read and write — the WebUI is one more writer on it, not an owner. + */ + async listCrons() { + const cron = await requireCronRuntime(host, LIST_CRONS_OPERATION_NAME); + return { + tasks: cron.registry.listAllTasks().map(webuiCronTaskFromEngineState), + }; + }, + async createCron(request) { + const cron = await requireCronRuntime(host, CREATE_CRON_OPERATION_NAME); + const config = webuiCreateRequestToEngineConfig(request); + try { + await cron.registry.createTask(request.agentName, request.cronName, config); + } catch (error) { + // A duplicate name is the case the panel has to explain, so it keeps + // the runtime's own wording and the derived status. + throw toWebuiCronError(error); + } + return { success: true }; + }, + async updateCron(request) { + const cron = await requireCronRuntime(host, UPDATE_CRON_OPERATION_NAME); + try { + const state = await cron.registry.updateConfig( + request.agentName, + request.cronName, + webuiUpdateRequestToEngineUpdate(request), + ); + if (!state) + throw new WebuiCronError( + 404, + `cron task not found: ${request.agentName}/${request.cronName}`, + "CRON_TASK_NOT_FOUND", + ); + } catch (error) { + throw toWebuiCronError(error); + } + return { success: true }; + }, + async deleteCron(request) { + const cron = await requireCronRuntime(host, DELETE_CRON_OPERATION_NAME); + try { + // Idempotent by contract: `deleteCron` answers `{ success }` where the + // boolean reports whether a task was actually there, so a name that is + // not registered yields `success: false` rather than a 404. That is the + // one deliberate asymmetry with `updateCron`, whose result carries no + // such boolean and therefore raises 404 through the state check. + return { + success: await cron.registry.deleteTask(request.agentName, request.cronName), + }; + } catch (error) { + throw toWebuiCronError(error); + } + }, + async triggerCron(request) { + const cron = await requireCronRuntime(host, TRIGGER_CRON_OPERATION_NAME); + try { + await cron.registry.triggerTask(request.agentName, request.cronName); + } catch (error) { + throw toWebuiCronError(error); + } + return { success: true }; + }, async listWorkspaceFileTree(request) { const tree = await requireCliService(host).listWorkspaceFileTree!(request) as readonly WebuiWorkspaceFile[]; // The runtime reports names and shape but no file facts, while the port @@ -760,6 +858,25 @@ function requireCliService(host: WebuiRuntimeHostHandle): WebuiRuntimeCliService return host.cliService; } +/** + * Resolve the runtime host's scheduled-task engine and pull the scheduler up. + * + * Same nested-guard shape as `requireCliService`, one level deeper: the host + * is checked first, then the engine slot. Failing closed here means a WebUI + * without a cron-capable runtime answers + * `runtime host does not expose cron` on all five operations instead of + * half-reading a store the scheduler never owns. + */ +async function requireCronRuntime( + host: WebuiRuntimeHostHandle, + operation: string, +): Promise { + if (!host.apiHost.cronRuntime) + throw new Error("runtime host does not expose cron"); + await host.apiHost.cronRuntime.ensureStarted(`webui:${operation}`); + return host.apiHost.cronRuntime; +} + function permissionReplyValue(reply: WebuiPermissionDecision): number { switch (reply) { case "allowOnce": diff --git a/packages/webui/src/server/index.ts b/packages/webui/src/server/index.ts index 7734ea9f2..0703c6cee 100644 --- a/packages/webui/src/server/index.ts +++ b/packages/webui/src/server/index.ts @@ -95,6 +95,13 @@ export type { WebuiPermissionDecision, WebuiQueueItem, WebuiModelEntry, + WebuiCronSession, + WebuiCronTask, + WebuiListCronsResult, + WebuiCreateCronRequest, + WebuiUpdateCronRequest, + WebuiDeleteCronRequest, + WebuiTriggerCronRequest, } from "./port.js"; export { createHarnessPortFromHost, diff --git a/packages/webui/src/server/operation/cron.ts b/packages/webui/src/server/operation/cron.ts new file mode 100644 index 000000000..fdb20a0a3 --- /dev/null +++ b/packages/webui/src/server/operation/cron.ts @@ -0,0 +1,479 @@ +/** + * Scheduled tasks (`定时任务`) — the five WebUI operations plus the thin + * mapping layer between the wire shapes in `../port.ts` and the runtime cron + * engine's own model. + * + * Why the mapping lives here rather than being imported: the engine's + * `CronTaskState` / `CronConfig` / `SessionConfig` belong to `@mavis/cron` + * and the equivalent contract glue to `@mavis/local-runtime`, and neither is + * a dependency of this package (ADR 0005 keeps the WebUI build graph narrow). + * So the engine side is declared structurally, exactly as `host.ts` declares + * the runtime host, and the semantics are ported 1:1 from + * `packages/local-runtime/src/cron/contract.ts`: + * + * - wire `enabled` ↔ engine `disabled` (polarity flip), + * - wire `session` (tagged union) ↔ engine `session` (tagged union), + * - wire `activeHours` / `timezone` pass-through, empty timezone clears, + * - duplicate cron → 409, not-found → 404, validation → 400. + * + * Cron expression *parsing* stays with the engine: `registry.createTask` / + * `updateConfig` both run their own scheduler check before touching the + * store, so a bad expression is rejected once, by the same code path the + * desktop and the CLI use. What this module does check up front is the + * structural part (field count, character set) so an obviously malformed + * expression never reaches the store as a 500. + */ + +import { WebuiErrorCode } from "../envelope.js"; +import { + invalidBody, + requireNonEmptyString, + requireRecord, +} from "./operation-contract.js"; +import type { + ValidationFailure, + WebuiOperation, + WebuiOperationValidation, +} from "./operation-contract.js"; +import type { + WebuiCreateCronRequest, + WebuiCronSession, + WebuiCronTask, + WebuiDeleteCronRequest, + WebuiListCronsResult, + WebuiTriggerCronRequest, + WebuiUpdateCronRequest, +} from "../port.js"; +import { + CREATE_CRON_OPERATION_NAME, + DELETE_CRON_OPERATION_NAME, + LIST_CRONS_OPERATION_NAME, + TRIGGER_CRON_OPERATION_NAME, + UPDATE_CRON_OPERATION_NAME, +} from "./names.js"; + +const CRON_NAME_RE = /^[^\s/\\:*?"<>|]+$/; +const ACTIVE_HOURS_RE = /^\d{2}:\d{2}$/; +const MAX_CRON_NAME_LENGTH = 64; +const CRON_FIELD_COUNT_RE = /^[^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+\s+[^\s]+(\s+[^\s]+)?$/; + +/* Engine-side shapes. + * + * Structural mirrors of `@mavis/cron`'s `CronTaskState` / `CronConfig` and of + * the six `CronRegistry` methods this surface uses. `host.ts` narrows the + * runtime's `cronRuntime` onto these, so the assembly type-check is what + * proves the two models still line up. */ + +export interface WebuiCronEngineConfig { + readonly disabled?: boolean; + readonly schedule: string; + readonly scheduleType?: "cron" | "once"; + readonly prompt: string; + readonly timezone?: string; + readonly activeHours?: { readonly start: string; readonly end: string }; + /** Engine tagged union — structurally identical to the wire `session`. */ + readonly session: WebuiCronSession; +} + +export interface WebuiCronEngineConfigUpdate { + disabled?: boolean; + schedule?: string; + prompt?: string; + /** `null` clears the timezone, mirroring the engine's own update shape. */ + timezone?: string | null; +} + +export interface WebuiCronEngineTaskState { + readonly agentName: string; + readonly cronName: string; + readonly cronId?: string; + readonly config: WebuiCronEngineConfig; + readonly enabled: boolean; + readonly lastRun: number | null; + readonly lastResult: string | null; + readonly lastError: string | null; + readonly nextRun: number | null; + readonly status: "idle" | "running" | "skipped"; +} + +export interface WebuiCronEngineRegistry { + listAllTasks(): readonly WebuiCronEngineTaskState[]; + getTask(agentName: string, cronName: string): WebuiCronEngineTaskState | undefined; + createTask( + agentName: string, + cronName: string, + config: WebuiCronEngineConfig, + ): Promise; + updateConfig( + agentName: string, + cronName: string, + update: WebuiCronEngineConfigUpdate, + ): Promise; + deleteTask(agentName: string, cronName: string): Promise; + triggerTask(agentName: string, cronName: string): Promise; +} + +/** + * The runtime's scheduled-task engine. Optional on the host handle: a host + * that predates it (and every existing test double) keeps type-checking, and + * `host.ts` fails each cron port method closed with one clear message. + */ +export interface WebuiCronRuntime { + /** + * Starts the scheduler on demand. The WebUI never relies on a cold-start + * restore (ADR 0002 keeps `startupExecutionPolicy: 'quarantined'`), so this + * call is what pulls the scheduler up before the registry is read. + */ + ensureStarted(reason?: string): Promise; + readonly registry: WebuiCronEngineRegistry; +} + +/* ── Error mapping ────────────────────────────────────────────────────────── */ + +/** + * A cron failure with the status the desktop contract would have returned. + * The WebUI envelope has one `harness_error` code, so the status travels in + * the message and on this class; the panel branches on the wording the + * runtime itself produced. + */ +export class WebuiCronError extends Error { + constructor( + readonly status: number, + message: string, + readonly cronCode?: string, + ) { + super(message); + this.name = "WebuiCronError"; + } +} + +/** Mirror of `local-runtime`'s `toCronContractError`. */ +export function toWebuiCronError(error: unknown): WebuiCronError { + if (error instanceof WebuiCronError) return error; + const candidate = (error ?? {}) as { + statusCode?: unknown; + status?: unknown; + code?: unknown; + message?: unknown; + }; + const message = + typeof candidate.message === "string" ? candidate.message : String(error); + const code = typeof candidate.code === "string" ? candidate.code : undefined; + let status = + typeof candidate.statusCode === "number" + ? candidate.statusCode + : typeof candidate.status === "number" + ? candidate.status + : 500; + // The registry raises duplicates as `CRON_TASK_EXISTS` / statusCode 409, + // but older store paths throw a plain Error that would otherwise land on + // 500. Both have to read as "this name is taken". + const duplicate = + code === "CRON_TASK_EXISTS" || + /already exists|already registered/i.test(message); + if (duplicate) { + status = 409; + } else if (status === 500 && /not found/i.test(message)) { + status = 404; + } + return new WebuiCronError(status, message, code); +} + +/* ── Mapping ──────────────────────────────────────────────────────────────── */ + +/** Engine tagged union → wire session. */ +export function webuiSessionFromEngineSession( + session: WebuiCronSession, +): WebuiCronSession { + if (session.mode === "new") { + return session.keepSessions === undefined || session.keepSessions === null + ? { mode: "new" } + : { mode: "new", keepSessions: session.keepSessions }; + } + return session.mode === "sessionId" + ? { mode: "sessionId", sessionId: session.sessionId } + : { mode: "root" }; +} + +/** Engine task state → wire task. */ +export function webuiCronTaskFromEngineState( + state: WebuiCronEngineTaskState, +): WebuiCronTask { + return { + cronName: state.cronName, + agentName: state.agentName, + ...(state.cronId ? { cronId: state.cronId } : {}), + schedule: state.config.schedule, + scheduleType: state.config.scheduleType === "once" ? "once" : "cron", + ...(state.config.timezone !== undefined + ? { timezone: state.config.timezone } + : {}), + enabled: state.enabled, + prompt: state.config.prompt, + session: webuiSessionFromEngineSession(state.config.session), + ...(state.config.activeHours + ? { + activeHours: { + start: state.config.activeHours.start, + end: state.config.activeHours.end, + }, + } + : {}), + status: state.status, + lastRun: state.lastRun, + lastResult: state.lastResult, + lastError: state.lastError, + nextRun: state.nextRun, + }; +} + +/** Wire create request → engine config (`enabled` ↔ `disabled` flip). */ +export function webuiCreateRequestToEngineConfig( + request: WebuiCreateCronRequest, +): WebuiCronEngineConfig { + return { + schedule: request.schedule, + scheduleType: "cron", + prompt: request.prompt, + ...(request.timezone ? { timezone: request.timezone } : {}), + ...(request.activeHours + ? { + activeHours: { + start: request.activeHours.start, + end: request.activeHours.end, + }, + } + : {}), + session: request.session ?? { mode: "new" }, + disabled: request.enabled === undefined ? false : !request.enabled, + }; +} + +/** Wire update request → engine config update (absent field = untouched). */ +export function webuiUpdateRequestToEngineUpdate( + request: WebuiUpdateCronRequest, +): WebuiCronEngineConfigUpdate { + const update: { + disabled?: boolean; + schedule?: string; + prompt?: string; + timezone?: string | null; + } = {}; + if (request.enabled !== undefined) update.disabled = !request.enabled; + if (request.schedule !== undefined) update.schedule = request.schedule; + if (request.prompt !== undefined) update.prompt = request.prompt; + // Empty string is the wire's "clear the timezone" signal, not a zone. + if (request.timezone !== undefined) + update.timezone = request.timezone === "" ? null : request.timezone; + return update; +} + +/* ── Validation ───────────────────────────────────────────────────────────── */ + +export const listCronsOperation: WebuiOperation = + { + name: LIST_CRONS_OPERATION_NAME, + validate: (body) => + body === undefined + ? { ok: true, body: undefined } + : { + ok: false, + code: WebuiErrorCode.invalidBody, + message: `${LIST_CRONS_OPERATION_NAME} does not accept a body`, + }, + }; + +export const createCronOperation: WebuiOperation = { + name: CREATE_CRON_OPERATION_NAME, + validate: (body) => { + const record = requireRecord(CREATE_CRON_OPERATION_NAME, body); + if (!record.ok) return record; + const value = record.body; + const agentName = requireNonEmptyString( + CREATE_CRON_OPERATION_NAME, + value, + "agentName", + ); + if (typeof agentName !== "string") return agentName; + const cronName = cronNameField(CREATE_CRON_OPERATION_NAME, value); + if (typeof cronName !== "string") return cronName; + const schedule = requireNonEmptyString( + CREATE_CRON_OPERATION_NAME, + value, + "schedule", + ); + if (typeof schedule !== "string") return schedule; + const scheduleProblem = cronScheduleProblem(schedule); + if (scheduleProblem) + return invalidBody(`${CREATE_CRON_OPERATION_NAME} ${scheduleProblem}`); + const prompt = requireNonEmptyString(CREATE_CRON_OPERATION_NAME, value, "prompt"); + if (typeof prompt !== "string") return prompt; + if (value.timezone !== undefined && typeof value.timezone !== "string") + return invalidBody(`${CREATE_CRON_OPERATION_NAME} timezone must be a string`); + if (value.enabled !== undefined && typeof value.enabled !== "boolean") + return invalidBody(`${CREATE_CRON_OPERATION_NAME} enabled must be a boolean`); + const activeHours = activeHoursField(value); + if (activeHours === null) + return invalidBody( + `${CREATE_CRON_OPERATION_NAME} activeHours must be { start, end } in HH:MM format`, + ); + const session = sessionField(value); + if (session === null) + return invalidBody( + `${CREATE_CRON_OPERATION_NAME} session must be { mode: "root" }, { mode: "sessionId", sessionId } or { mode: "new", keepSessions }`, + ); + return { + ok: true, + body: { + agentName, + cronName, + schedule, + prompt, + ...(value.timezone ? { timezone: value.timezone as string } : {}), + ...(value.enabled !== undefined + ? { enabled: value.enabled as boolean } + : {}), + ...(session ? { session } : {}), + ...(activeHours ? { activeHours } : {}), + }, + }; + }, +}; + +export const updateCronOperation: WebuiOperation = { + name: UPDATE_CRON_OPERATION_NAME, + validate: (body) => { + const record = requireRecord(UPDATE_CRON_OPERATION_NAME, body); + if (!record.ok) return record; + const value = record.body; + const agentName = requireNonEmptyString( + UPDATE_CRON_OPERATION_NAME, + value, + "agentName", + ); + if (typeof agentName !== "string") return agentName; + const cronName = cronNameField(UPDATE_CRON_OPERATION_NAME, value); + if (typeof cronName !== "string") return cronName; + if (value.schedule !== undefined) { + if (typeof value.schedule !== "string" || !value.schedule.trim()) + return invalidBody( + `${UPDATE_CRON_OPERATION_NAME} schedule must be a non-empty string`, + ); + const scheduleProblem = cronScheduleProblem(value.schedule); + if (scheduleProblem) return invalidBody(`${UPDATE_CRON_OPERATION_NAME} ${scheduleProblem}`); + } + if ( + value.prompt !== undefined && + (typeof value.prompt !== "string" || !value.prompt.trim()) + ) + return invalidBody(`${UPDATE_CRON_OPERATION_NAME} prompt must be a non-empty string`); + // The empty string is the documented "clear the timezone" value, so it + // passes here and becomes `null` in the engine update. + if (value.timezone !== undefined && typeof value.timezone !== "string") + return invalidBody(`${UPDATE_CRON_OPERATION_NAME} timezone must be a string`); + if (value.enabled !== undefined && typeof value.enabled !== "boolean") + return invalidBody(`${UPDATE_CRON_OPERATION_NAME} enabled must be a boolean`); + return { + ok: true, + body: { + agentName, + cronName, + ...(value.schedule !== undefined ? { schedule: value.schedule as string } : {}), + ...(value.prompt !== undefined ? { prompt: value.prompt as string } : {}), + ...(value.timezone !== undefined + ? { timezone: value.timezone as string } + : {}), + ...(value.enabled !== undefined ? { enabled: value.enabled as boolean } : {}), + }, + }; + }, +}; + +export const deleteCronOperation: WebuiOperation = { + name: DELETE_CRON_OPERATION_NAME, + validate: (body) => validateCronKeyBody(DELETE_CRON_OPERATION_NAME, body), +}; + +export const triggerCronOperation: WebuiOperation = { + name: TRIGGER_CRON_OPERATION_NAME, + validate: (body) => validateCronKeyBody(TRIGGER_CRON_OPERATION_NAME, body), +}; + +function validateCronKeyBody( + operation: string, + body: unknown, +): WebuiOperationValidation { + const record = requireRecord(operation, body); + if (!record.ok) return record; + const agentName = requireNonEmptyString(operation, record.body, "agentName"); + if (typeof agentName !== "string") return agentName; + const cronName = cronNameField(operation, record.body); + if (typeof cronName !== "string") return cronName; + return { ok: true, body: { agentName, cronName } }; +} + +/** `null` marks "present but unusable"; `undefined` means "absent". */ +function cronNameField( + operation: string, + body: Record, +): string | ValidationFailure { + const cronName = requireNonEmptyString(operation, body, "cronName"); + if (typeof cronName !== "string") return cronName; + if (cronName.length > MAX_CRON_NAME_LENGTH || !CRON_NAME_RE.test(cronName)) + return invalidBody( + `${operation} cronName must be 1-${MAX_CRON_NAME_LENGTH} characters without whitespace or / \\ : * ? " < > |`, + ); + return cronName; +} + +/** `null` marks "present but unusable"; `undefined` means "absent". */ +function activeHoursField( + body: Record, +): { readonly start: string; readonly end: string } | undefined | null { + const value = body.activeHours; + if (value === undefined) return undefined; + if (value === null || typeof value !== "object" || Array.isArray(value)) + return null; + const candidate = value as Record; + const { start, end } = candidate; + if (typeof start !== "string" || typeof end !== "string") return null; + if (!ACTIVE_HOURS_RE.test(start) || !ACTIVE_HOURS_RE.test(end)) return null; + return { start, end }; +} + +function sessionField( + body: Record, +): WebuiCronSession | undefined | null { + const value = body.session; + if (value === undefined) return undefined; + if (value === null || typeof value !== "object" || Array.isArray(value)) + return null; + const candidate = value as Record; + if (candidate.mode === "root") return { mode: "root" }; + if (candidate.mode === "sessionId") { + return typeof candidate.sessionId === "string" && candidate.sessionId + ? { mode: "sessionId", sessionId: candidate.sessionId } + : null; + } + if (candidate.mode === "new") { + const keep = candidate.keepSessions; + if (keep === undefined || keep === null) return { mode: "new" }; + if (typeof keep === "number" && Number.isInteger(keep) && keep >= 1) + return { mode: "new", keepSessions: keep }; + return null; + } + return null; +} + +/** + * Structural schedule check only. The authoritative parse is the engine's own + * `assertSchedulable`, which `createTask` / `updateConfig` run before the + * store sees the config; this keeps an obviously malformed expression from + * reaching it as a request the engine cannot interpret. + */ +function cronScheduleProblem(schedule: string): string | undefined { + const trimmed = schedule.trim(); + if (!CRON_FIELD_COUNT_RE.test(trimmed)) + return "schedule must have 5 or 6 whitespace-separated fields"; + return undefined; +} diff --git a/packages/webui/src/server/operation/names.ts b/packages/webui/src/server/operation/names.ts index af111ec1b..62eaee8b3 100644 --- a/packages/webui/src/server/operation/names.ts +++ b/packages/webui/src/server/operation/names.ts @@ -88,3 +88,8 @@ export const REVEAL_MODEL_PROVIDER_API_KEY_OPERATION_NAME = "revealModelProvider export const START_CODEX_OAUTH_LOGIN_OPERATION_NAME = "startCodexOAuthLogin" as const; export const CANCEL_CODEX_OAUTH_LOGIN_OPERATION_NAME = "cancelCodexOAuthLogin" as const; export const REFRESH_MODELS_OPERATION_NAME = "refreshModels" as const; +export const LIST_CRONS_OPERATION_NAME = "listCrons" as const; +export const CREATE_CRON_OPERATION_NAME = "createCron" as const; +export const UPDATE_CRON_OPERATION_NAME = "updateCron" as const; +export const DELETE_CRON_OPERATION_NAME = "deleteCron" as const; +export const TRIGGER_CRON_OPERATION_NAME = "triggerCron" as const; diff --git a/packages/webui/src/server/operation/operation-handlers.ts b/packages/webui/src/server/operation/operation-handlers.ts index ed845f1da..8dc2a4937 100644 --- a/packages/webui/src/server/operation/operation-handlers.ts +++ b/packages/webui/src/server/operation/operation-handlers.ts @@ -12,6 +12,13 @@ import type { } from "./operation-contract.js"; import type { WebuiTerminalManager } from "../terminal.js"; +/** + * One message for every missing-scheduler path, so a panel that has no cron + * capability reads the same whether the gap is on the port or on the runtime + * host behind it. `host.ts` throws the same string from its own nested guard. + */ +const CRON_UNAVAILABLE = "runtime host does not expose cron"; + type OperationModule = typeof import("./operations.js"); type OperationDescriptorName = Exclude< Extract, @@ -105,6 +112,11 @@ export type WebuiOperationPort = Pick< | "startCodexOAuthLogin" | "cancelCodexOAuthLogin" | "refreshModels" + | "listCrons" + | "createCron" + | "updateCron" + | "deleteCron" + | "triggerCron" | "requestCompaction" | "invalidateAuth" >; @@ -230,6 +242,29 @@ export function createOperationHandlers( startCodexOAuthLogin: async (_context, body) => ({ body: await port.startCodexOAuthLogin(body) }), cancelCodexOAuthLogin: async (_context, body) => ({ body: await port.cancelCodexOAuthLogin(body) }), refreshModels: async () => ({ body: await port.refreshModels() }), + // Scheduled tasks. The port methods are optional for the same reason + // `pluginManagement` is: the scheduler is a borrowed runtime capability, + // so a port without it answers one clear error instead of crashing. + listCrons: async () => { + if (!port.listCrons) throw new Error(CRON_UNAVAILABLE); + return { body: await port.listCrons() }; + }, + createCron: async (_context, body) => { + if (!port.createCron) throw new Error(CRON_UNAVAILABLE); + return { body: await port.createCron(body) }; + }, + updateCron: async (_context, body) => { + if (!port.updateCron) throw new Error(CRON_UNAVAILABLE); + return { body: await port.updateCron(body) }; + }, + deleteCron: async (_context, body) => { + if (!port.deleteCron) throw new Error(CRON_UNAVAILABLE); + return { body: await port.deleteCron(body) }; + }, + triggerCron: async (_context, body) => { + if (!port.triggerCron) throw new Error(CRON_UNAVAILABLE); + return { body: await port.triggerCron(body) }; + }, runCommand: async (_context, body) => ({ body: await runWebuiCommand(port, body) }), signOut: async () => { await port.invalidateAuth(); diff --git a/packages/webui/src/server/operation/operations.ts b/packages/webui/src/server/operation/operations.ts index 5679d2e17..b64ad107c 100644 --- a/packages/webui/src/server/operation/operations.ts +++ b/packages/webui/src/server/operation/operations.ts @@ -2,6 +2,7 @@ import { versionOperation, listSessionsOperation, listVisibleProjectsOperation, import { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js"; import { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js"; import { isGoalEnabledOperation, getGoalOperation, createGoalOperation, patchGoalOperation, clearGoalOperation } from "./goal.js"; +import { listCronsOperation, createCronOperation, updateCronOperation, deleteCronOperation, triggerCronOperation } from "./cron.js"; import { sendMessageOperation, enqueueMessageOperation, resumeSessionOperation } from "./interaction.js"; import { watchEventsOperation, listPendingPermissionsOperation, getPendingQuestionnaireOperation, replyPermissionOperation, replyQuestionnaireOperation, dismissQuestionnaireOperation } from "./questionnaire.js"; import { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOperation, listModelsOperation, listSkillsOperation, selectModelOperation, getSessionUsageOperation, getUsageQuotaOperation, getAccountStatusOperation } from "./queue.js"; @@ -12,6 +13,7 @@ export { versionOperation, listSessionsOperation, listVisibleProjectsOperation, export { listWorkspaceFileTreeOperation, browseWorkspaceDirsOperation, readWorkspaceFileOperation, getWorkspaceEnvironmentOperation, mutateWorkspaceGitOperation, getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation, readCanvasOperation, applyCanvasOperation, readWorkspaceArchiveOperation, extractWorkspaceArchiveOperation, createTerminalOperation, listTerminalsOperation, writeTerminalOperation, resizeTerminalOperation, disposeTerminalOperation, watchTerminalOperation } from "./workspace.js"; export { getMessagesOperation, getSessionDiffOperation, getTurnDiffOperation, revertTurnDiffOperation, reapplyTurnDiffOperation, getSessionRewindPreviewOperation, rewindSessionOperation, editSessionMessageOperation } from "./messages.js"; export { isGoalEnabledOperation, getGoalOperation, createGoalOperation, patchGoalOperation, clearGoalOperation } from "./goal.js"; +export { listCronsOperation, createCronOperation, updateCronOperation, deleteCronOperation, triggerCronOperation } from "./cron.js"; export { sendMessageOperation, enqueueMessageOperation, resumeSessionOperation } from "./interaction.js"; export { watchEventsOperation, listPendingPermissionsOperation, getPendingQuestionnaireOperation, replyPermissionOperation, replyQuestionnaireOperation, dismissQuestionnaireOperation } from "./questionnaire.js"; export { abortSessionOperation, listQueueMessagesOperation, deleteQueueItemOperation, listModelsOperation, listSkillsOperation, selectModelOperation, getSessionUsageOperation, getUsageQuotaOperation, getAccountStatusOperation } from "./queue.js"; @@ -159,6 +161,11 @@ export function createOperationRegistry( registerOperation(registry, { operation: createGoalOperation, handle: handlers.createGoal }); registerOperation(registry, { operation: patchGoalOperation, handle: handlers.patchGoal }); registerOperation(registry, { operation: clearGoalOperation, handle: handlers.clearGoal }); + registerOperation(registry, { operation: listCronsOperation, handle: handlers.listCrons }); + registerOperation(registry, { operation: createCronOperation, handle: handlers.createCron }); + registerOperation(registry, { operation: updateCronOperation, handle: handlers.updateCron }); + registerOperation(registry, { operation: deleteCronOperation, handle: handlers.deleteCron }); + registerOperation(registry, { operation: triggerCronOperation, handle: handlers.triggerCron }); registerOperation(registry, { operation: listSessionsOperation, handle: handlers.listSessions }); registerOperation(registry, { operation: listVisibleProjectsOperation, handle: handlers.listVisibleProjects }); registerOperation(registry, { operation: getSessionTreeOperation, handle: handlers.getSessionTree }); diff --git a/packages/webui/src/server/port.ts b/packages/webui/src/server/port.ts index bf5e04f25..5abb40848 100644 --- a/packages/webui/src/server/port.ts +++ b/packages/webui/src/server/port.ts @@ -475,6 +475,72 @@ export interface WebuiGoalEnabledResult { readonly enabled: boolean; } +/* Scheduled-task (cron) wire shapes. + * + * These mirror the runtime's cron contract field for field; the panel is a + * projection of the registry the desktop and the CLI also write to, so a + * renamed field here would silently write somewhere else instead of failing. + * `enabled` is the wire-side polarity — the stored config's `disabled` is the + * engine's own business and never crosses this boundary. */ + +export type WebuiCronSession = + | { readonly mode: "root" } + | { readonly mode: "sessionId"; readonly sessionId: string } + | { readonly mode: "new"; readonly keepSessions?: number | null }; + +export interface WebuiCronTask { + readonly cronName: string; + readonly agentName: string; + readonly cronId?: string; + readonly schedule: string; + readonly scheduleType: "cron" | "once"; + readonly timezone?: string; + readonly enabled: boolean; + readonly prompt: string; + readonly session: WebuiCronSession; + readonly activeHours?: { readonly start: string; readonly end: string }; + readonly status: "idle" | "running" | "skipped"; + readonly lastRun: number | null; + readonly lastResult: string | null; + readonly lastError: string | null; + readonly nextRun: number | null; +} + +export interface WebuiListCronsResult { + readonly tasks: WebuiCronTask[]; +} + +export interface WebuiCreateCronRequest { + readonly agentName: string; + readonly cronName: string; + readonly schedule: string; + readonly prompt: string; + readonly timezone?: string; + readonly enabled?: boolean; + readonly session?: WebuiCronSession; + readonly activeHours?: { readonly start: string; readonly end: string }; +} + +export interface WebuiUpdateCronRequest { + readonly agentName: string; + readonly cronName: string; + readonly schedule?: string; + readonly prompt?: string; + /** 空字符串表示清除时区。 */ + readonly timezone?: string; + readonly enabled?: boolean; +} + +export interface WebuiDeleteCronRequest { + readonly agentName: string; + readonly cronName: string; +} + +export interface WebuiTriggerCronRequest { + readonly agentName: string; + readonly cronName: string; +} + export interface WebuiWorkspaceFile { readonly path: string; readonly name: string; @@ -928,6 +994,20 @@ export interface WebuiHarnessPort { createGoal(request: WebuiGoalCreateRequest): Promise; patchGoal(request: WebuiGoalPatchRequest): Promise; clearGoal(request: WebuiGoalSessionRequest): Promise<{ readonly success: boolean }>; + /* Scheduled tasks. + * + * Optional like `pluginManagement` / `getPermissionMode` above: the + * scheduler is a runtime capability the WebUI only borrows, so a port + * implementor without it stays honest and the handler turns the gap into + * one `runtime host does not expose cron` error instead of a + * "not a function" crash. Mutations answer `success`; the panel re-reads + * the list afterwards rather than trusting a partial echo of the new + * state. */ + listCrons?(): Promise; + createCron?(request: WebuiCreateCronRequest): Promise<{ readonly success: boolean }>; + updateCron?(request: WebuiUpdateCronRequest): Promise<{ readonly success: boolean }>; + deleteCron?(request: WebuiDeleteCronRequest): Promise<{ readonly success: boolean }>; + triggerCron?(request: WebuiTriggerCronRequest): Promise<{ readonly success: boolean }>; listWorkspaceFileTree(request: { readonly workspaceDir: string; readonly path?: string }): Promise; readWorkspaceFile(request: { readonly workspaceDir: string; readonly path: string }): Promise; getWorkspaceEnvironment(request: { readonly workspaceDir: string }): Promise; diff --git a/packages/webui/src/server/service.ts b/packages/webui/src/server/service.ts index 12652fa95..37c50675b 100644 --- a/packages/webui/src/server/service.ts +++ b/packages/webui/src/server/service.ts @@ -191,6 +191,32 @@ export class WebuiService { createGoal: (request) => this.port.createGoal(request), patchGoal: (request) => this.port.patchGoal(request), clearGoal: (request) => this.port.clearGoal(request), + // The cron store is reached through `apiHost.cronRuntime`, which is an + // optional field on the runtime host handle: a test double or a host built + // without the scheduled-task surface leaves the port method undefined. Each + // forward therefore fails closed with one message rather than failing as an + // undefined call, so "this host has no cron" is legible in the wire error + // instead of a TypeError. + listCrons: () => { + if (!this.port.listCrons) throw new Error("runtime host does not expose cron"); + return this.port.listCrons(); + }, + createCron: (request) => { + if (!this.port.createCron) throw new Error("runtime host does not expose cron"); + return this.port.createCron(request); + }, + updateCron: (request) => { + if (!this.port.updateCron) throw new Error("runtime host does not expose cron"); + return this.port.updateCron(request); + }, + deleteCron: (request) => { + if (!this.port.deleteCron) throw new Error("runtime host does not expose cron"); + return this.port.deleteCron(request); + }, + triggerCron: (request) => { + if (!this.port.triggerCron) throw new Error("runtime host does not expose cron"); + return this.port.triggerCron(request); + }, listWorkspaceFileTree: (request) => this.port.listWorkspaceFileTree(request), readWorkspaceFile: (request) => this.port.readWorkspaceFile(request), getWorkspaceEnvironment: (request) => this.port.getWorkspaceEnvironment(request), diff --git a/packages/webui/test/unit/webui-service.test.ts b/packages/webui/test/unit/webui-service.test.ts index a428d38c8..1d08642a4 100644 --- a/packages/webui/test/unit/webui-service.test.ts +++ b/packages/webui/test/unit/webui-service.test.ts @@ -68,6 +68,16 @@ import type { import { createWebuiTransport } from "../../src/client/transport.js"; import { WebuiTerminalManager } from "../../src/server/terminal.js"; import { getWorkspaceReviewSummaryOperation, listWorkspaceReviewFileDiffsOperation, getWorkspaceReviewFileContentOperation, searchWorkspaceReviewDiffsOperation } from "../../src/server/operation/workspace.js"; +import { + createCronOperation, + listCronsOperation, + updateCronOperation, + WebuiCronError, + type WebuiCronEngineConfig, + type WebuiCronEngineConfigUpdate, + type WebuiCronEngineTaskState, + type WebuiCronRuntime, +} from "../../src/server/operation/cron.js"; type CloseEvent = [number, Buffer]; // `once` from `node:events` is overloaded and not generic, so @@ -2965,6 +2975,406 @@ describe("WebUI host factory", () => { }); }); +/** + * Scheduled tasks (定时任务). + * + * The WebUI never owns the cron store — it borrows the runtime host's + * scheduler through `apiHost.cronRuntime`. These tests therefore drive the + * real `createHarnessPortFromHost` adapter over a fake engine that copies the + * registry's own semantics (duplicate → 409, missing → undefined / 404), so + * the wire mapping and the polarity flips are exercised without a scheduler. + */ +describe("WebUI scheduled tasks", () => { + interface FakeCronEngine { + readonly startReasons: string[]; + readonly tasks: Map; + readonly created: WebuiCronEngineConfig[]; + readonly updates: WebuiCronEngineConfigUpdate[]; + readonly triggered: string[]; + readonly runtime: WebuiCronRuntime; + } + + function fakeCronEngine(): FakeCronEngine { + const startReasons: string[] = []; + const tasks = new Map(); + const created: WebuiCronEngineConfig[] = []; + const updates: WebuiCronEngineConfigUpdate[] = []; + const triggered: string[] = []; + const key = (agentName: string, cronName: string) => `${agentName}/${cronName}`; + const runtime: WebuiCronRuntime = { + async ensureStarted(reason?: string) { + startReasons.push(reason ?? ""); + }, + registry: { + listAllTasks: () => [...tasks.values()], + getTask: (agentName, cronName) => tasks.get(key(agentName, cronName)), + async createTask(agentName, cronName, config) { + if (tasks.has(key(agentName, cronName))) { + const error = new Error( + `Cron task already registered: ${agentName}/${cronName}`, + ) as Error & { code: string; statusCode: number }; + error.code = "CRON_TASK_EXISTS"; + error.statusCode = 409; + throw error; + } + created.push(config); + const state: WebuiCronEngineTaskState = { + agentName, + cronName, + cronId: `cron-${tasks.size + 1}`, + config, + enabled: !config.disabled, + lastRun: null, + lastResult: null, + lastError: null, + nextRun: 1893456000000, + status: "idle", + }; + tasks.set(key(agentName, cronName), state); + return state; + }, + async updateConfig(agentName, cronName, update) { + const state = tasks.get(key(agentName, cronName)); + if (!state) return undefined; + updates.push(update); + const next: WebuiCronEngineTaskState = { + ...state, + config: { + ...state.config, + ...(update.disabled !== undefined ? { disabled: update.disabled } : {}), + ...(update.schedule !== undefined ? { schedule: update.schedule } : {}), + ...(update.prompt !== undefined ? { prompt: update.prompt } : {}), + ...(update.timezone !== undefined ? { timezone: update.timezone ?? undefined } : {}), + }, + enabled: update.disabled !== undefined ? !update.disabled : state.enabled, + }; + tasks.set(key(agentName, cronName), next); + return next; + }, + async deleteTask(agentName, cronName) { + return tasks.delete(key(agentName, cronName)); + }, + async triggerTask(agentName, cronName) { + const state = tasks.get(key(agentName, cronName)); + if (!state) { + const error = new Error( + `Cron task '${cronName}' not found for agent '${agentName}'`, + ) as Error & { code: string; statusCode: number }; + error.code = "CRON_TASK_NOT_FOUND"; + error.statusCode = 404; + throw error; + } + triggered.push(key(agentName, cronName)); + return { ok: true }; + }, + }, + }; + return { startReasons, tasks, created, updates, triggered, runtime }; + } + + async function cronPort(engine?: FakeCronEngine) { + const { createHarnessPortFromHost } = + await import("../../src/server/index.js"); + return createHarnessPortFromHost({ + apiHost: { + close: async () => undefined, + ...(engine ? { cronRuntime: engine.runtime } : {}), + }, + dataDir: "/tmp/data", + appVersion: "1.2.3", + }); + } + + function seedTask( + engine: FakeCronEngine, + overrides: Partial = {}, + ): void { + engine.tasks.set("webui-agent/morning-report", { + agentName: "webui-agent", + cronName: "morning-report", + cronId: "cron-7", + config: { + disabled: false, + schedule: "0 9 * * *", + scheduleType: "cron", + prompt: "summarize yesterday", + timezone: "Asia/Shanghai", + activeHours: { start: "08:00", end: "20:00" }, + session: { mode: "new", keepSessions: 3 }, + }, + enabled: true, + lastRun: 1893369600000, + lastResult: "success", + lastError: null, + nextRun: 1893456000000, + status: "idle", + ...overrides, + }); + } + + it("lists an empty schedule without inventing a task", async () => { + const engine = fakeCronEngine(); + const port = await cronPort(engine); + await expect(port.listCrons!()).resolves.toEqual({ tasks: [] }); + // The scheduler is pulled up explicitly, attributed to the WebUI. + expect(engine.startReasons).toEqual(["webui:listCrons"]); + }); + + it("maps engine state onto the frozen wire shape", async () => { + const engine = fakeCronEngine(); + seedTask(engine); + const port = await cronPort(engine); + const result = await port.listCrons!(); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]).toEqual({ + cronName: "morning-report", + agentName: "webui-agent", + cronId: "cron-7", + schedule: "0 9 * * *", + scheduleType: "cron", + timezone: "Asia/Shanghai", + enabled: true, + prompt: "summarize yesterday", + session: { mode: "new", keepSessions: 3 }, + activeHours: { start: "08:00", end: "20:00" }, + status: "idle", + lastRun: 1893369600000, + lastResult: "success", + lastError: null, + nextRun: 1893456000000, + }); + }); + + it("maps a paused task back to enabled: false and explicit nulls", async () => { + const engine = fakeCronEngine(); + seedTask(engine, { + cronId: undefined, + enabled: false, + status: "skipped", + lastRun: null, + lastResult: null, + nextRun: null, + config: { + disabled: true, + schedule: "@daily", + scheduleType: "cron", + prompt: "check the build", + session: { mode: "root" }, + }, + }); + const port = await cronPort(engine); + const [task] = (await port.listCrons!()).tasks; + expect(task).toMatchObject({ + enabled: false, + status: "skipped", + session: { mode: "root" }, + lastRun: null, + lastResult: null, + nextRun: null, + }); + // Optional fields stay absent rather than becoming empty strings. + expect("cronId" in task).toBe(false); + expect("timezone" in task).toBe(false); + expect("activeHours" in task).toBe(false); + }); + + it("creates a task, flipping the wire's enabled onto the engine's disabled", async () => { + const engine = fakeCronEngine(); + const port = await cronPort(engine); + await expect( + port.createCron!({ + agentName: "webui-agent", + cronName: "morning-report", + schedule: "0 9 * * *", + prompt: "summarize yesterday", + timezone: "Asia/Shanghai", + session: { mode: "new", keepSessions: null }, + activeHours: { start: "08:00", end: "20:00" }, + }), + ).resolves.toEqual({ success: true }); + expect(engine.created[0]).toEqual({ + schedule: "0 9 * * *", + scheduleType: "cron", + prompt: "summarize yesterday", + timezone: "Asia/Shanghai", + activeHours: { start: "08:00", end: "20:00" }, + session: { mode: "new", keepSessions: null }, + disabled: false, + }); + expect(engine.startReasons).toEqual(["webui:createCron"]); + + await port.createCron!({ + agentName: "webui-agent", + cronName: "paused-report", + schedule: "0 9 * * *", + prompt: "summarize yesterday", + enabled: false, + }); + // Polarity flip: wire `enabled: false` is the engine's `disabled: true`. + expect(engine.created[1]?.disabled).toBe(true); + }); + + it("reports a duplicate name as 409 with the runtime's own wording", async () => { + const engine = fakeCronEngine(); + seedTask(engine); + const port = await cronPort(engine); + const failure = await port + .createCron!({ + agentName: "webui-agent", + cronName: "morning-report", + schedule: "0 9 * * *", + prompt: "duplicate", + }) + .then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(WebuiCronError); + expect((failure as WebuiCronError).status).toBe(409); + expect((failure as Error).message).toContain("already registered"); + }); + + it("updates a task, keeping the enabled ↔ disabled flip", async () => { + const engine = fakeCronEngine(); + seedTask(engine); + const port = await cronPort(engine); + await expect( + port.updateCron!({ agentName: "webui-agent", cronName: "morning-report", enabled: false }), + ).resolves.toEqual({ success: true }); + expect(engine.updates[0]).toEqual({ disabled: true }); + expect((await port.listCrons!()).tasks[0]?.enabled).toBe(false); + + // An empty timezone is the wire's "clear it" signal, not a zone. + await port.updateCron!({ + agentName: "webui-agent", + cronName: "morning-report", + timezone: "", + schedule: "30 7 * * *", + }); + expect(engine.updates[1]).toEqual({ timezone: null, schedule: "30 7 * * *" }); + expect(engine.startReasons).toContain("webui:updateCron"); + }); + + it("answers 404 for an update of a task that does not exist", async () => { + const engine = fakeCronEngine(); + const port = await cronPort(engine); + const failure = await port + .updateCron!({ agentName: "webui-agent", cronName: "ghost", enabled: true }) + .then(() => undefined, (error: unknown) => error); + expect((failure as WebuiCronError).status).toBe(404); + }); + + it("deletes a task and reports whether it was there", async () => { + const engine = fakeCronEngine(); + seedTask(engine); + const port = await cronPort(engine); + await expect( + port.deleteCron!({ agentName: "webui-agent", cronName: "morning-report" }), + ).resolves.toEqual({ success: true }); + await expect( + port.deleteCron!({ agentName: "webui-agent", cronName: "morning-report" }), + ).resolves.toEqual({ success: false }); + expect(engine.tasks.size).toBe(0); + expect(engine.startReasons).toContain("webui:deleteCron"); + }); + + it("triggers a task on demand", async () => { + const engine = fakeCronEngine(); + seedTask(engine); + const port = await cronPort(engine); + await expect( + port.triggerCron!({ agentName: "webui-agent", cronName: "morning-report" }), + ).resolves.toEqual({ success: true }); + expect(engine.triggered).toEqual(["webui-agent/morning-report"]); + expect(engine.startReasons).toEqual(["webui:triggerCron"]); + }); + + it("fails every operation closed when the host has no cron runtime", async () => { + const port = await cronPort(); + await expect(port.listCrons!()).rejects.toThrow("runtime host does not expose cron"); + await expect( + port.createCron!({ agentName: "a", cronName: "b", schedule: "0 9 * * *", prompt: "p" }), + ).rejects.toThrow("runtime host does not expose cron"); + await expect( + port.updateCron!({ agentName: "a", cronName: "b", enabled: true }), + ).rejects.toThrow("runtime host does not expose cron"); + await expect( + port.deleteCron!({ agentName: "a", cronName: "b" }), + ).rejects.toThrow("runtime host does not expose cron"); + await expect( + port.triggerCron!({ agentName: "a", cronName: "b" }), + ).rejects.toThrow("runtime host does not expose cron"); + }); + + it("registers all five operations and rejects a malformed body before the engine", async () => { + const { createOperationRegistry } = + await import("../../src/server/operation/operations.js"); + const engine = fakeCronEngine(); + const port = await cronPort(engine); + const registry = createOperationRegistry(port); + for (const name of [ + "listCrons", + "createCron", + "updateCron", + "deleteCron", + "triggerCron", + ]) { + expect(registry.has(name), `expected ${name} to be registered`).toBe(true); + } + const result = async (name: string, body: unknown) => + (await registry.get(name)?.handle({ requestId: name }, body)) as { + readonly body: unknown; + }; + + expect(await result("listCrons", undefined)).toEqual({ body: { tasks: [] } }); + expect(await result("createCron", { + agentName: "webui-agent", + cronName: "morning-report", + schedule: "0 9 * * *", + prompt: "summarize yesterday", + })).toEqual({ body: { success: true } }); + expect(await result("triggerCron", { + agentName: "webui-agent", + cronName: "morning-report", + })).toEqual({ body: { success: true } }); + + // `listCrons` takes no body, and a bad cron name or schedule never + // reaches the store. + expect(listCronsOperation.validate({})).toMatchObject({ ok: false }); + expect( + createCronOperation.validate({ + agentName: "webui-agent", + cronName: "bad/name", + schedule: "0 9 * * *", + prompt: "p", + }), + ).toMatchObject({ ok: false }); + expect( + updateCronOperation.validate({ + agentName: "webui-agent", + cronName: "morning-report", + schedule: "not-a-cron", + }), + ).toMatchObject({ ok: false }); + expect(engine.created).toHaveLength(1); + }); + + it("fails closed at the handler when a port has no cron methods", async () => { + const { createOperationRegistry } = + await import("../../src/server/operation/operations.js"); + const port = { version: () => ({ version: "stub", protocolVersion: 1 }) }; + const registry = createOperationRegistry(port as unknown as WebuiHarnessPort); + await expect( + registry.get("listCrons")?.handle({ requestId: "listCrons" }, undefined), + ).rejects.toThrow("runtime host does not expose cron"); + await expect( + registry.get("createCron")?.handle({ requestId: "createCron" }, { + agentName: "a", + cronName: "b", + schedule: "0 9 * * *", + prompt: "p", + }), + ).rejects.toThrow("runtime host does not expose cron"); + }); +}); + describe("WebUI runtime host assembly", () => { it("creates exactly one host per process with the assembly step 6 owner combination", async () => { const { createWebuiRuntimeHost } = diff --git a/packages/webui/test/unit/webui-shell.test.ts b/packages/webui/test/unit/webui-shell.test.ts index ba27c8a55..76061a02a 100644 --- a/packages/webui/test/unit/webui-shell.test.ts +++ b/packages/webui/test/unit/webui-shell.test.ts @@ -135,8 +135,8 @@ function renderSessionShell(): string { ); } -/** The four rail destinations the desktop ships that the WebUI has no feature for. */ -const INERT_NAV_LABELS = ["定时", "网站", "远程"]; +/** The two rail destinations the desktop ships that the WebUI has no feature for. */ +const INERT_NAV_LABELS = ["网站", "远程"]; describe("WebUI shell", () => { it("projects desktop workspace state and exposes only the supported add-menu tabs", () => { @@ -975,6 +975,16 @@ describe("WebUI shell — desktop anatomy", () => { } expect(html).toMatch(/data-webui-nav-item="插件"/u); + // 「定时」 now opens its own page, so its row is a live button: the 500 + // character window after the marker is the row's own markup, and + // `disabled` there would mean the nav is still gated. + const schedulesAt = html.indexOf('data-webui-nav-item="定时"'); + expect(schedulesAt, "nav row 定时 missing").toBeGreaterThan(-1); + expect( + html.slice(schedulesAt, schedulesAt + 500), + "nav row 定时 is still disabled", + ).not.toMatch(/disabled/u); + // The current destination carries the state hook, so the selected row has // something to read. expect(html).toMatch(/data-webui-nav-active="true"/u); @@ -1058,7 +1068,10 @@ describe("WebUI shell — desktop anatomy", () => { // shipped: each one changes the list, so they are bound rather than // decorative, and the count moved from 8 to 11. The favourites tab joined // them when starring shipped: it swaps the rail to the starred set, so it - // is bound on the same terms, and the count moved from 11 to 12. + // is bound on the same terms, and the count moved from 11 to 12. The + // 「定时」 row joined them when the schedules page shipped: it opens that + // page, so it is bound on the same terms, and the count moved from 12 to + // 13. const html = renderShell(); const controlTags: string[] = []; const re = /<(button|div|a|input|textarea|select)\b[^>]*>/gu; @@ -1073,7 +1086,7 @@ describe("WebUI shell — desktop anatomy", () => { !/(?:^|\s)disabled(?:=|\s|>)/u.test(tag) && !/aria-disabled="true"/u.test(tag), ); - expect(operable).toHaveLength(12); + expect(operable).toHaveLength(13); expect(html).toMatch(/data-testid="composer-add-menu"/u); expect(html).toMatch(/data-webui-sidebar-toggle="true"/u); expect(html).toMatch(/data-webui-nav-item="新建任务"/u); diff --git a/release/public-source.json b/release/public-source.json index ffacf1ce0..07636dfcc 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3532,6 +3532,7 @@ "packages/webui/src/client/components/PluginManagement.tsx", "packages/webui/src/client/components/QueuePanel.tsx", "packages/webui/src/client/components/RailRow.tsx", + "packages/webui/src/client/components/SchedulesPanel.tsx", "packages/webui/src/client/components/SessionComposer.tsx", "packages/webui/src/client/components/SessionRail.tsx", "packages/webui/src/client/components/SessionTranscript.tsx", @@ -3618,6 +3619,7 @@ "packages/webui/src/server/mcode-tools.ts", "packages/webui/src/server/oauth-core.d.ts", "packages/webui/src/server/operation/common.ts", + "packages/webui/src/server/operation/cron.ts", "packages/webui/src/server/operation/goal.ts", "packages/webui/src/server/operation/interaction.ts", "packages/webui/src/server/operation/messages.ts", From 9365922984d4deab2bb74cc2bf6f65792763d5bb Mon Sep 17 00:00:00 2001 From: Andre-1998 Date: Mon, 5 Oct 2026 14:21:57 +0800 Subject: [PATCH 2/3] fix(webui): start the cron scheduler with the service, not on first panel use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebUI is a resident local service, not a surface that runs on demand — the published `mcode-webui` bin assembles the host, prints a URL and blocks until SIGINT/SIGTERM, and the browser page is a client that attaches to it. The previous commit framed the scheduler as reached by a use-time action, and `ensureStarted` was only ever called from the five port methods, so the scheduler came up on first use of the scheduled-task panel. That is a quiet failure rather than a loud one. A schedule created from the desktop or the CLI would never fire in the resident WebUI, because nobody had opened the panel to notice that it had not — the panel would still build, edit, delete and trigger on demand while the clock did nothing. The assembly now starts the scheduler before the service accepts connections, with `ensureStarted("webui:service_start")`. The per-operation call stays: it is idempotent, it covers hosts assembled without the boot step, and it keeps each WebUI-initiated run tagged in the runtime logs. A start that fails is reported and swallowed — a locked cron store degrades the panel, it does not stop the service from serving sessions. `startupExecutionPolicy: 'quarantined'` and `runtimeOwnerKind: 'tui'` are untouched. The policy gates the host's own cold-start path; the WebUI starting the cron scheduler is a separate, explicit act. ADR 0002's amendment is rewritten around the resident-service framing, including the point that sessions and in-flight turns still do not resume: what is restored at startup is the cron schedule, not anybody's unfinished work. The comments in `host.ts` and `operation/cron.ts` that presented the on-demand start as a feature are corrected. Tests cover the three cases: the assembly starts the scheduler with no panel interaction, a host with no cron surface still assembles, and a failing `ensureStarted` does not take the service down. Re-verified end to end against the running service over the real WebSocket. --- ...untime-host-with-quarantined-cold-start.md | 66 +++++++++------- packages/webui/src/server/assembly.ts | 46 +++++++++++ packages/webui/src/server/host.ts | 13 ++-- packages/webui/src/server/operation/cron.ts | 8 +- .../webui/test/unit/webui-service.test.ts | 78 +++++++++++++++++++ 5 files changed, 173 insertions(+), 38 deletions(-) diff --git a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md index 681112d02..5614af4dd 100644 --- a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md +++ b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md @@ -25,32 +25,40 @@ Execution state that is not persisted — stream buffers, subscriptions, pending permission and questionnaire requests — belongs to the owner process and cannot be recovered from disk. -## Amendment: the scheduled-task panel starts the cron scheduler on demand - -The scheduled-task panel (`定时`) reads and writes the same cron store the terminal -client uses, reached through `apiHost.cronRuntime` rather than the `services.cron` -composition that `runtimeOwnerKind: 'tui'` leaves undefined. Using it calls -`cronRuntime.ensureStarted()`, which starts the croner scheduler inside the WebUI -process. That is a deliberate change to the picture the options above describe, and -it is worth being precise about what did and did not change. - -**Unchanged.** `startupExecutionPolicy` stays `'quarantined'`. It gates only the -host's own cold-start path, so a WebUI restart still never resumes persisted jobs on -its own. `runtimeOwnerKind` stays `'tui'`; no Electron-only capability is claimed. - -**Changed.** Reaching the scheduler is a *use-time* action, not a startup one. The -first schedules operation loads persisted cron definitions from the shared store and -schedules them for the lifetime of the process. So a WebUI service that is left -running will fire cron tasks at their times, where before it would not. - -**Accepted hazard.** The data directory is shared, so a WebUI service and the -terminal client can both hold the scheduler for the same agent at the same time and -each fire the same task. The engine's busy-queue bounds overlap within one process; -it does not arbitrate across two. The panel therefore states which side is executing -rather than implying the WebUI owns execution. - -**Rejected alternative.** Restricting the panel to list plus manual trigger, and -leaving scheduling to the terminal client only. It keeps one scheduler per data -directory, but a task created in the WebUI does nothing until the user happens to run -the desktop client, which is the "builds but never runs" failure the panel exists to -avoid. +## Amendment: the resident service owns the cron schedule + +The WebUI is a resident local service, not a surface that runs on demand. The +published `mcode-webui` bin assembles the host, prints a URL, and blocks until +SIGINT or SIGTERM; the browser page is a client that attaches to it. The +scheduled-task panel therefore manages tasks in the shared cron store and the +service itself starts the scheduler during startup +(`ensureStarted("webui:service_start")`), reached through `apiHost.cronRuntime` +rather than the `services.cron` composition that `runtimeOwnerKind: 'tui'` +leaves undefined. + +Starting with the service rather than on first use of the panel is the whole +point. A schedule created from the desktop or the CLI has to fire in the +resident WebUI whether or not anyone has opened the panel to watch it; tying +the scheduler to the panel would make "the panel was never opened" silently +mean "the schedule never runs". + +**Unchanged.** `startupExecutionPolicy` stays `'quarantined'` and +`runtimeOwnerKind` stays `'tui'`. The policy gates the host's own cold-start +path, and the WebUI starting the cron scheduler is a separate, explicit act +rather than that path. No Electron-only capability is claimed. + +**Re-opened on purpose.** The second rejected option above turned on "a WebUI +restart and the terminal client would both attempt to resume the same +persisted jobs". That hazard is now accepted for cron specifically: the data +directory is shared, so the WebUI service and the terminal client can both hold +the scheduler for one agent and fire the same task twice. The engine's +busy-queue bounds overlap within one process; it does not arbitrate across two. +The panel states which side is executing rather than implying the WebUI owns +execution exclusively. + +**What still does not resume.** The consequences above are about sessions and +in-flight turns, and they are unaffected: a restart still marks a running turn +`interrupted` rather than continuing it, and reopening a session is an +explicit `resumeSession`. What the WebUI restores at startup is the cron +*schedule* — stored definitions and their timers — not anybody's unfinished work. + diff --git a/packages/webui/src/server/assembly.ts b/packages/webui/src/server/assembly.ts index 29e331a5a..b3a9e7844 100644 --- a/packages/webui/src/server/assembly.ts +++ b/packages/webui/src/server/assembly.ts @@ -510,6 +510,9 @@ export async function createWebuiRuntimeHost( claimSignin: () => dailyCheckin.claimSignin(), }; const harnessPort = createHarnessPortFromHost(hostHandle); + await startResidentCronScheduler(hostHandle, (message) => { + console.warn(`[webui] ${message}`); + }); return { harnessPort, host: hostHandle, @@ -519,6 +522,49 @@ export async function createWebuiRuntimeHost( }; } +/** + * Structural view of the two cron members the assembly needs. `WebuiAssembledHost` + * types `apiHost` without the cron surface, so the boot-time call cannot see + * through the assembled type. Declared here rather than widened on the host + * handle because only the boot path needs it; the operation path goes through + * `requireCronRuntime`, which is where the real engine types live. + */ +type WebuiResidentCronSurface = { + readonly ensureStarted?: (reason?: string) => Promise; + readonly cronRuntime?: WebuiResidentCronSurface; +}; + +/** + * The WebUI is a resident local service, not a page that runs on demand: the + * published `mcode-webui` bin starts the host, prints a URL and blocks until + * SIGINT/SIGTERM. So the cron scheduler starts with the service rather than on + * first use of the scheduled-task panel. Waiting for the panel would mean a + * schedule created from the desktop or CLI never fires here, because nobody has + * opened the panel to notice it did not. + * + * Non-fatal on purpose. A cron store that cannot be opened degrades the + * scheduled-task panel; it does not stop the service from serving sessions. The + * per-operation `ensureStarted` in `requireCronRuntime` still runs, so a + * transient boot-time failure is retried the first time the panel is used. + */ +async function startResidentCronScheduler( + handle: { readonly apiHost?: unknown }, + report: (message: string) => void, +): Promise { + const apiHost = handle.apiHost as WebuiResidentCronSurface | undefined; + const ensureStarted = apiHost?.cronRuntime?.ensureStarted; + if (typeof ensureStarted !== "function") return; + try { + await ensureStarted("webui:service_start"); + } catch (error) { + report( + `scheduled tasks unavailable at start: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + /** * Default factory: dynamically imports the real local-runtime-v2 host * factory so the WebUI server bundle does not pull in the full harness diff --git a/packages/webui/src/server/host.ts b/packages/webui/src/server/host.ts index 516acd3ea..878833fc0 100644 --- a/packages/webui/src/server/host.ts +++ b/packages/webui/src/server/host.ts @@ -471,12 +471,13 @@ export function createHarnessPortFromHost( }, /* Scheduled tasks. * - * The registry lives on the runtime host, not the cliService, and the - * scheduler is never started implicitly: ADR 0002 keeps the cold start - * quarantined, so each call pulls it up with an explicit, attributable - * `ensureStarted("webui:")` before reading or mutating. That - * is what makes a WebUI-initiated run distinguishable in the runtime's - * logs from a host-initiated one. + * The registry lives on the runtime host, not the cliService. The + * scheduler is already up by the time an operation arrives: the assembly + * starts it with `ensureStarted("webui:service_start")` because the WebUI + * is a resident service. Each operation still calls + * `ensureStarted("webui:")` before reading or mutating, which is + * idempotent, covers the hosts assembled without that boot step, and keeps + * every WebUI-initiated run attributable in the runtime logs. * * Tasks persist in the shared `~/.minimax` store the desktop and the CLI * also read and write — the WebUI is one more writer on it, not an owner. diff --git a/packages/webui/src/server/operation/cron.ts b/packages/webui/src/server/operation/cron.ts index fdb20a0a3..779aeed07 100644 --- a/packages/webui/src/server/operation/cron.ts +++ b/packages/webui/src/server/operation/cron.ts @@ -120,9 +120,11 @@ export interface WebuiCronEngineRegistry { */ export interface WebuiCronRuntime { /** - * Starts the scheduler on demand. The WebUI never relies on a cold-start - * restore (ADR 0002 keeps `startupExecutionPolicy: 'quarantined'`), so this - * call is what pulls the scheduler up before the registry is read. + * Idempotent scheduler start. The assembly already calls it with + * `webui:service_start` because the WebUI is a resident service, so by the + * time an operation arrives this is normally a no-op; calling it per + * operation covers hosts assembled without that boot step and tags each + * WebUI-initiated run in the runtime logs. */ ensureStarted(reason?: string): Promise; readonly registry: WebuiCronEngineRegistry; diff --git a/packages/webui/test/unit/webui-service.test.ts b/packages/webui/test/unit/webui-service.test.ts index 1d08642a4..faba8575d 100644 --- a/packages/webui/test/unit/webui-service.test.ts +++ b/packages/webui/test/unit/webui-service.test.ts @@ -3583,6 +3583,84 @@ describe("WebUI runtime host assembly", () => { } }); + it("starts the cron scheduler while assembling, because the WebUI is a resident service", async () => { + const { createWebuiRuntimeHost } = + await import("../../src/server/index.js"); + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "webui-assembly-cron-start-"), + ); + const reasons: (string | undefined)[] = []; + try { + const assembled = await createWebuiRuntimeHost({ + dataDir, + factory: async (options) => ({ + apiHost: { + close: async () => undefined, + cronRuntime: { + ensureStarted: async (reason?: string) => { + reasons.push(reason); + }, + }, + }, + dataDir: options.dataDir, + }), + }); + // No panel interaction, no operation: the schedule has to be live by the + // time the service starts accepting connections, or a task created from + // the desktop or the CLI never fires here. + expect(reasons).toEqual(["webui:service_start"]); + await assembled.harnessPort.close(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("assembles a host with no cron surface, and survives a cron start that fails", async () => { + const { createWebuiRuntimeHost } = + await import("../../src/server/index.js"); + const withoutCron = await mkdtemp( + path.join(os.tmpdir(), "webui-assembly-cron-absent-"), + ); + try { + const assembled = await createWebuiRuntimeHost({ + dataDir: withoutCron, + factory: async (options) => ({ + apiHost: { close: async () => undefined }, + dataDir: options.dataDir, + }), + }); + await assembled.harnessPort.close(); + } finally { + await rm(withoutCron, { recursive: true, force: true }); + } + + // A cron store that cannot be opened degrades the scheduled-task panel; it + // does not stop the service from serving sessions. + const failing = await mkdtemp( + path.join(os.tmpdir(), "webui-assembly-cron-failing-"), + ); + try { + const assembled = await createWebuiRuntimeHost({ + dataDir: failing, + factory: async (options) => ({ + apiHost: { + close: async () => undefined, + cronRuntime: { + ensureStarted: async () => { + throw new Error("cron store is locked"); + }, + }, + }, + dataDir: options.dataDir, + }), + }); + expect(assembled.harnessPort.version).toBeTypeOf("function"); + await assembled.harnessPort.close(); + } finally { + await rm(failing, { recursive: true, force: true }); + } + }); + it("assembles tool capabilities explicitly and releases both owners on shutdown", async () => { const { createWebuiRuntimeHost } = await import("../../src/server/index.js"); From 6825ba994ee4589c62373839e3632474a6c92437 Mon Sep 17 00:00:00 2001 From: Andre-1998 Date: Mon, 5 Oct 2026 15:59:11 +0800 Subject: [PATCH 3/3] feat(webui): scheduled tasks on the v2 cron service, resident-service semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds Module L on the v2 cron service rather than the v1 engine the first attempt used. The v1 contract has no `project` or `model` and stores tasks in `local_runtime_crons`, which `migration-0002-copy-legacy-cron-data` has already drained — so a v1 panel cannot match the desktop form and its tasks would never show up where the desktop reads them. The WebUI is a resident local service: the published `mcode-webui` bin assembles the host, prints a URL and blocks until SIGINT/SIGTERM, and the browser page is a client that attaches to it. The service therefore owns the schedule. A new `enableScheduledTasks` host option composes the in-process Scheduler and the cron service, and starts the scheduler with `restorePersistedJobExecution: true` so a restart re-arms the timers for definitions already stored. Timers stay in process — nothing is handed to an operating-system scheduler, because these tasks are harness business and have to stay governable from inside it. With the process down, a schedule that comes due does not fire. Two things deliberately do not change. `runtimeOwnerKind: 'tui'` and `startupExecutionPolicy: 'quarantined'` are untouched; the capability arrives as a new field rather than by reclassifying the owner, so a restart still marks a running turn `interrupted` and reopening a session is still an explicit `resumeSession`. `recoverPersistedRuns` is untouched too: restoring a schedule and recovering in-flight work are different mechanisms. The ownership predicate is new rather than a widened existing one. The old predicate also gates `enableChannel`, so relaxing it would have handed a local web service the IM channel adapters it was never granted. The runtime publishes the capability on its own `scheduledTasks` slot rather than the whole `RuntimeServices` graph, for the same reason: the WebUI owns schedules and nothing else. On the client: the rail row opens a panel whose 创建 dropdown offers 手动创建 and 在对话中创建. The manual dialog mirrors the desktop form field for field — name, agent, prompt, run mode, project, model, and a structured 执行时间 picker (每 N 分钟 / 每 N 小时 / 每天 / 每周) that produces a schedule rather than asking the user to type a cron expression. The conversational path opens a new session, sends a guiding message, and creates the definition when the user finishes. The list groups by agent and shows last/next run with the last result, and the panel states which side is executing. Verified against the running service over the real WebSocket: twenty checks covering both creation paths' wire shapes, the agent derivation, duplicate rejection, pause, manual trigger, the delete-idempotent / update-raises asymmetry, and cleanup. Separately, a per-minute schedule was created and left alone: it fired on its own, `status=delivered`, with a real session — which also validates the panel's `*/15 * * * *` output against real croner evaluation. No operating-system scheduled task exists for this. ADR 0002 gains an amendment covering the resident-service framing and the schedule/work split; `webui-v1-scope.md` no longer lists cron as out of scope. --- ...untime-host-with-quarantined-cold-start.md | 69 +- docs/webui/webui-v1-scope.md | 8 +- .../src/local/host-contract.ts | 9 + packages/local-runtime-v2/src/runtime.ts | 34 +- .../src/service/cron/ownership.ts | 56 + .../local-runtime-v2/src/services.test.ts | 122 ++- packages/local-runtime-v2/src/services.ts | 8 +- .../client/components/CronChatCreateFlow.tsx | 137 +++ .../client/components/CronCreateDialog.tsx | 706 +++++++++++++ .../src/client/components/SchedulesPanel.tsx | Bin 19364 -> 30333 bytes .../components/WebuiClientFoundationApp.tsx | 7 +- packages/webui/src/client/contracts.ts | 183 ++-- packages/webui/src/client/transport.ts | 14 +- packages/webui/src/server/assembly.ts | 64 +- packages/webui/src/server/host.ts | 212 ++-- packages/webui/src/server/index.ts | 22 +- packages/webui/src/server/operation/agents.ts | 43 + packages/webui/src/server/operation/cron.ts | 761 +++++++------- packages/webui/src/server/operation/names.ts | 13 +- .../server/operation/operation-handlers.ts | 68 +- .../webui/src/server/operation/operations.ts | 19 +- packages/webui/src/server/port.ts | 167 ++- packages/webui/src/server/service.ts | 54 +- .../webui/test/unit/webui-service.test.ts | 978 +++++++++++------- packages/webui/test/unit/webui-shell.test.ts | 498 +++++++++ release/public-source.json | 4 + 26 files changed, 3193 insertions(+), 1063 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/cron/ownership.ts create mode 100644 packages/webui/src/client/components/CronChatCreateFlow.tsx create mode 100644 packages/webui/src/client/components/CronCreateDialog.tsx create mode 100644 packages/webui/src/server/operation/agents.ts diff --git a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md index 5614af4dd..d10374dd1 100644 --- a/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md +++ b/docs/adr/0002-in-process-runtime-host-with-quarantined-cold-start.md @@ -25,40 +25,53 @@ Execution state that is not persisted — stream buffers, subscriptions, pending permission and questionnaire requests — belongs to the owner process and cannot be recovered from disk. -## Amendment: the resident service owns the cron schedule +## Amendment: the resident service owns the scheduled-task schedule The WebUI is a resident local service, not a surface that runs on demand. The published `mcode-webui` bin assembles the host, prints a URL, and blocks until SIGINT or SIGTERM; the browser page is a client that attaches to it. The -scheduled-task panel therefore manages tasks in the shared cron store and the -service itself starts the scheduler during startup -(`ensureStarted("webui:service_start")`), reached through `apiHost.cronRuntime` -rather than the `services.cron` composition that `runtimeOwnerKind: 'tui'` -leaves undefined. - -Starting with the service rather than on first use of the panel is the whole -point. A schedule created from the desktop or the CLI has to fire in the -resident WebUI whether or not anyone has opened the panel to watch it; tying -the scheduler to the panel would make "the panel was never opened" silently -mean "the schedule never runs". +scheduled-task panel is therefore a control surface over a store the service owns, +and the service runs the schedule for as long as it is up. **Unchanged.** `startupExecutionPolicy` stays `'quarantined'` and -`runtimeOwnerKind` stays `'tui'`. The policy gates the host's own cold-start -path, and the WebUI starting the cron scheduler is a separate, explicit act -rather than that path. No Electron-only capability is claimed. +`runtimeOwnerKind` stays `'tui'`. Neither is widened; the new capability arrives as +a separate host option rather than as a reclassification of the owner. -**Re-opened on purpose.** The second rejected option above turned on "a WebUI -restart and the terminal client would both attempt to resume the same -persisted jobs". That hazard is now accepted for cron specifically: the data -directory is shared, so the WebUI service and the terminal client can both hold -the scheduler for one agent and fire the same task twice. The engine's -busy-queue bounds overlap within one process; it does not arbitrate across two. -The panel states which side is executing rather than implying the WebUI owns -execution exclusively. +**What the option does.** The host takes `enableScheduledTasks`. When set, the +process composes the in-process scheduler and the cron service, and the scheduler +starts with `restorePersistedJobExecution: true`, so a restart re-arms the timers +for definitions already in the store. The timers are in-process: nothing is handed +to an operating-system scheduler, because these tasks are harness business and +have to stay governable from inside it. With the process down, a schedule that +comes due simply does not fire. + +Re-arming at startup is the difference between a resident service and a page that +happens to run something. A schedule created from the desktop or the CLI has to +keep firing across a WebUI restart; otherwise every restart silently retires every +task, with nothing in the logs to say so. + +**The split, stated precisely.** Two mechanisms get conflated while this is being +designed, and the amendment is clearer for separating them: -**What still does not resume.** The consequences above are about sessions and -in-flight turns, and they are unaffected: a restart still marks a running turn -`interrupted` rather than continuing it, and reopening a session is an -explicit `resumeSession`. What the WebUI restores at startup is the cron -*schedule* — stored definitions and their timers — not anybody's unfinished work. +- *Restoring the schedule* — re-arming timers for stored definitions. Enabled here, + per capability. +- *Recovering in-flight work* — picking up runs that fired but did not finish. + Governed by `recoverPersistedRuns`, and **not** changed by this amendment. + +**Sessions and turns are untouched.** A restart still marks a running turn +`interrupted`; reopening a session is still an explicit `resumeSession` with a +cursor. Restoring a schedule is not resuming anybody's unfinished work. + +**Re-opened on purpose.** The second rejected option above turned on "a WebUI +restart and the terminal client would both attempt to resume the same persisted +jobs". That hazard is now accepted for scheduled tasks specifically: the data +directory is shared, so the WebUI service and the terminal client can both hold the +scheduler for one agent and fire the same task twice. The busy-queue bounds overlap +within one process; it does not arbitrate across two. The panel states which side is +executing rather than implying the WebUI owns execution exclusively. +**The predicate is not shared.** The WebUI reaches the cron service through a +cron-specific ownership check, deliberately not by widening the existing +Electron-capability predicate: that predicate also gates channel delivery, and +widening it would hand a local web service the IM adapters it was never meant to +own. diff --git a/docs/webui/webui-v1-scope.md b/docs/webui/webui-v1-scope.md index 60a53c2e8..39ac47968 100644 --- a/docs/webui/webui-v1-scope.md +++ b/docs/webui/webui-v1-scope.md @@ -32,9 +32,11 @@ assembly it needs. The reasoning behind the decisions lives in [`../adr`](adr/). - Automatic resume of persisted jobs at cold start — see [ADR 0002](../adr/0002-in-process-runtime-host-with-quarantined-cold-start.md) -The scheduled-task (`定时`) panel is a later addition to this list. It manages -tasks in the shared cron store and starts the scheduler on first use, so ADR 0002 -carries an amendment describing what that does and does not change. +The scheduled-task (`定时`) panel is a later addition to this list. The WebUI is a +resident local service, so it owns the schedule for the life of the process and +re-arms its timers on start. ADR 0002 carries an amendment describing what that +does and does not change, including the two mechanisms it is easy to conflate: +restoring a schedule is not recovering in-flight work. ## Behaviour boundaries diff --git a/packages/local-runtime-v2/src/local/host-contract.ts b/packages/local-runtime-v2/src/local/host-contract.ts index b5b768139..cc6d34020 100644 --- a/packages/local-runtime-v2/src/local/host-contract.ts +++ b/packages/local-runtime-v2/src/local/host-contract.ts @@ -72,6 +72,15 @@ interface CreateLocalRuntimeHostOptions extends V1CreateLocalRuntimeHostOptions interface CreatedLocalRuntimeHost extends V1CreatedLocalRuntimeHost { application?: LocalRuntimeApplication; cliService?: import('./cli-service.js').CliService; + /** + * The scheduled-task capability, published on its own rather than as the whole + * `RuntimeServices` graph. A host that manages schedules needs exactly this one + * service; handing over `services` instead would also hand it `channelSystem`, + * `browserUse` and the session/turn owners, which is how a surface ends up + * owning capabilities it was never granted. Absent when this host owns no + * scheduler, so every consumer can fail closed on `undefined`. + */ + scheduledTasks?: import('../services.js').RuntimeServices['cron']; } export type { diff --git a/packages/local-runtime-v2/src/runtime.ts b/packages/local-runtime-v2/src/runtime.ts index f9ecfd5b2..6ab939dec 100644 --- a/packages/local-runtime-v2/src/runtime.ts +++ b/packages/local-runtime-v2/src/runtime.ts @@ -15,6 +15,7 @@ import { createBackgroundRuntime, type BackgroundRuntime, } from "./background-runtime.js"; +import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js"; import { cleanupFailedV1Startup, createDeferredAgentRuntimeTelemetry, @@ -446,6 +447,11 @@ function createStartedHost( ...v1, ...(ownerRuntime ? { application: ownerRuntime.services.application } : {}), ...(cliService ? { cliService } : {}), + // Only the scheduled-task capability leaves this function, never the whole + // `services` graph -- see `CreatedLocalRuntimeHost.scheduledTasks`. + ...(ownerRuntime?.services.cron + ? { scheduledTasks: ownerRuntime.services.cron } + : {}), apiHost: v1.apiHost, ready, ...(ownerRuntime @@ -812,6 +818,21 @@ function createBrowserUseServiceOptions( }; } +/** + * v2 host option for scheduled tasks. `CreateLocalRuntimeHostOptions` lives in + * the host contract, so the optional field is read through a narrow widening + * here; hosts forward `enableScheduledTasks` at the factory boundary. + */ +function requestsScheduledTasks(options: CreateLocalRuntimeHostOptions): boolean { + return ( + ( + options as CreateLocalRuntimeHostOptions & { + readonly enableScheduledTasks?: boolean; + } + ).enableScheduledTasks === true + ); +} + async function initializeOwnerRuntime(input: { readonly v1: V1CreatedLocalRuntimeHost; readonly compatibility: V1RuntimeCompatibility; @@ -841,14 +862,22 @@ async function initializeOwnerRuntime(input: { options.startupExecutionPolicy, ); const electronOwner = options.runtimeOwnerKind === "electron"; + // Scheduled tasks stay process-local: an opted-in resident host owns the + // Scheduler and cron services without inheriting any Electron-only surface. + const scheduledTaskOwner = requestsScheduledTasks(options); + const scheduling = resolveScheduledTaskScheduling({ + electronOwner, + startupExecutionEnabled, + enableScheduledTasks: scheduledTaskOwner, + }); background = await createBackgroundRuntime({ db: database.db, dataDir: v1.dataDir, logger, metrics: v1.metricsClient, ...(options.nowMs ? { nowMs: options.nowMs } : {}), - restorePersistedJobExecution: startupExecutionEnabled, - enableScheduler: electronOwner, + restorePersistedJobExecution: scheduling.restorePersistedJobExecution, + enableScheduler: scheduling.schedulerOwned, }); services = await createRuntimeServices({ db: database.db, @@ -871,6 +900,7 @@ async function initializeOwnerRuntime(input: { recoverPersistedState: startupExecutionEnabled, greetingEnabled: electronOwner && startupExecutionEnabled, runtimeOwnerKind: options.runtimeOwnerKind, + enableScheduledTasks: scheduledTaskOwner, browserUse: createBrowserUseServiceOptions(options), ...(options.promptConfigKey ? { promptConfigKey: options.promptConfigKey } diff --git a/packages/local-runtime-v2/src/service/cron/ownership.ts b/packages/local-runtime-v2/src/service/cron/ownership.ts new file mode 100644 index 000000000..e986ff873 --- /dev/null +++ b/packages/local-runtime-v2/src/service/cron/ownership.ts @@ -0,0 +1,56 @@ +import { ownsElectronRuntimeCapabilities } from "../../application/agent/runtime-browser-use-composition.js"; + +/** Ownership inputs for the process-local scheduling capability. */ +export interface ScheduledTaskRuntimeOwnership { + readonly runtimeOwnerKind?: string; + /** Frozen host option: `true` requests an owned Scheduler plus `services.cron`. */ + readonly enableScheduledTasks?: boolean; +} + +/** + * Cron-specific ownership predicate. Electron owners, and resident hosts that + * explicitly opt in with `enableScheduledTasks`, own the in-process Scheduler + * and the Cron service. Every other host keeps the previous behavior. + * + * Kept separate from the `enableChannel` predicate on purpose: that call site + * shares `ownsElectronRuntimeCapabilities` with cron today, and widening it + * would open IM channel delivery as a side effect. This predicate layers the + * opt-in on top of the unchanged Electron rule instead of relaxing it. + */ +export function ownsScheduledTaskRuntime(options: ScheduledTaskRuntimeOwnership): boolean { + return ( + ownsElectronRuntimeCapabilities(options.runtimeOwnerKind) || + options.enableScheduledTasks === true + ); +} + +/** Scheduling inputs resolved once during host assembly. */ +export interface ScheduledTaskSchedulingInput { + readonly electronOwner: boolean; + /** Startup execution policy decides persisted execution for existing owners. */ + readonly startupExecutionEnabled: boolean; + readonly enableScheduledTasks?: boolean; +} + +export interface ScheduledTaskScheduling { + /** Assemble the in-process Scheduler; false omits it entirely. */ + readonly schedulerOwned: boolean; + /** Arm croner timers for persisted jobs instead of refreshing them for inspection. */ + readonly restorePersistedJobExecution: boolean; +} + +/** + * Resolves background-runtime scheduling for one host. A host that opted into + * scheduled tasks always restores persisted job execution, because a resident + * host exists to run its schedule; every other host keeps the value derived + * from the startup execution policy. + */ +export function resolveScheduledTaskScheduling( + input: ScheduledTaskSchedulingInput, +): ScheduledTaskScheduling { + const scheduledTaskOwner = input.enableScheduledTasks === true; + return { + schedulerOwned: input.electronOwner || scheduledTaskOwner, + restorePersistedJobExecution: scheduledTaskOwner || input.startupExecutionEnabled, + }; +} diff --git a/packages/local-runtime-v2/src/services.test.ts b/packages/local-runtime-v2/src/services.test.ts index f503f3028..7fd3f9384 100644 --- a/packages/local-runtime-v2/src/services.test.ts +++ b/packages/local-runtime-v2/src/services.test.ts @@ -23,7 +23,9 @@ import type { AppDb } from "./infra/db/client.js"; import { readPreferenceValue } from "./infra/db/preference-values.js"; import { migratePluginTestDatabase } from "../test/helpers/plugin-database.js"; import { EventBus } from "./infra/event-bus/index.js"; -import type { SchedulerClient } from "./infra/scheduler/index.js"; +import { createBackgroundRuntime } from "./background-runtime.js"; +import { Scheduler, type SchedulerClient } from "./infra/scheduler/index.js"; +import { resolveScheduledTaskScheduling } from "./service/cron/ownership.js"; import type { LocalAgentService } from "./service/agent/index.js"; import { resolveAgentPromptSurface } from "./service/turn-system/agent-host/preparation/agent-prompt-surface.js"; import type { InitializeTurnSystemOptions } from "./service/turn-system/index.js"; @@ -2056,6 +2058,124 @@ describe("runtime services CLI composition", () => { expect(mocked.events.at(-1)).toBe("agent:ensure"); }); + it("composes the Cron service for a resident host that opts into scheduled tasks", async () => { + const scheduler = {} as SchedulerClient; + const services = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/scheduled-tasks", + logger: noopLogger, + scheduler, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + enableScheduledTasks: true, + }); + + expect(services.cron).toBe(mocked.service); + expect(mocked.cronOptions?.scheduler).toBe(scheduler); + // The opt-in is Cron-scoped: channel delivery keeps following its own owner rule. + expect(services.channelSystem).toBeUndefined(); + expect(services.cronDelivery).toBeDefined(); + await services.close(); + }); + + it("leaves a resident host unchanged when the scheduled-task option is absent or false", async () => { + const resident = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/resident-without-opt-in", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + }); + expect(resident.cron).toBeUndefined(); + expect(resident.cronDelivery).toBeUndefined(); + expect(resident.channelSystem).toBeUndefined(); + expect(mocked.cronOptions).toBeUndefined(); + await resident.close(); + + mocked.cronOptions = undefined; + const explicitlyDisabled = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/resident-opt-out", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "tui", + capabilityProfile: "cli", + enableScheduledTasks: false, + }); + expect(explicitlyDisabled.cron).toBeUndefined(); + expect(explicitlyDisabled.channelSystem).toBeUndefined(); + expect(mocked.cronOptions).toBeUndefined(); + await explicitlyDisabled.close(); + + // Electron owners keep their pre-existing Cron capability without the option. + mocked.cronOptions = undefined; + const electron = await createRuntimeServices({ + db: {} as AppDb, + dataDir: "/data/electron-default", + logger: noopLogger, + scheduler: {} as SchedulerClient, + eventBus: new EventBus(), + compatibility: defaultCompatibility(), + agentService: localAgentService, + runtimeOwnerKind: "electron", + }); + expect(electron.cron).toBe(mocked.service); + await electron.close(); + }); + + it("starts the Scheduler with persisted job execution for an opted-in host", async () => { + const scheduling = resolveScheduledTaskScheduling({ + electronOwner: false, + startupExecutionEnabled: false, + enableScheduledTasks: true, + }); + expect(scheduling).toEqual({ + schedulerOwned: true, + restorePersistedJobExecution: true, + }); + + const start = vi + .spyOn(Scheduler.prototype, "start") + .mockImplementation(() => undefined); + try { + const background = await createBackgroundRuntime({ + db: {} as AppDb, + enableScheduler: scheduling.schedulerOwned, + restorePersistedJobExecution: scheduling.restorePersistedJobExecution, + }); + await background.start(); + + expect(start).toHaveBeenCalledWith({ restorePersistedJobExecution: true }); + await background.close(); + } finally { + start.mockRestore(); + } + + // Without the opt-in the resident host keeps the policy-derived value. + expect( + resolveScheduledTaskScheduling({ + electronOwner: false, + startupExecutionEnabled: false, + }), + ).toEqual({ schedulerOwned: false, restorePersistedJobExecution: false }); + expect( + resolveScheduledTaskScheduling({ + electronOwner: true, + startupExecutionEnabled: false, + }), + ).toEqual({ schedulerOwned: true, restorePersistedJobExecution: false }); + }); + /** * The Inspector is composed on a test build, so composition must tolerate a * product that carries no model resolver at all. Reading `.fetchImpl` off an diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index 8b62bbb32..14a589695 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -125,6 +125,7 @@ import { type CronTurnDeliveryPort, type InitializedCronService, } from "./service/cron/index.js"; +import { ownsScheduledTaskRuntime } from "./service/cron/ownership.js"; import { createRuntimeInspector, type ComposedInspector, @@ -307,6 +308,8 @@ export interface CreateRuntimeServicesOptions readonly recoverPersistedState?: boolean; /** Composition owner mode; CLI omits Electron-only capabilities. */ readonly runtimeOwnerKind?: string; + /** v2 host option: request ownership of scheduling (in-process timers + Cron service assembly). */ + readonly enableScheduledTasks?: boolean; /** Electron-owned fixed key for encrypted Desktop Prompt bundles. */ readonly promptConfigKey?: Uint8Array; /** Client capability ceiling; omitted owners retain the shared legacy surface. */ @@ -490,7 +493,10 @@ export async function createRuntimeServices( internalTurnPromptReads, writeGlobalEvent, nowMs, - enableCron: ownsElectronRuntimeCapabilities(options.runtimeOwnerKind), + enableCron: ownsScheduledTaskRuntime({ + runtimeOwnerKind: options.runtimeOwnerKind, + enableScheduledTasks: options.enableScheduledTasks, + }), runtimeOwnerIdentity: options.runtimeOwnerIdentity, planEntryEnabled, agentPlanEntryEnabled, diff --git a/packages/webui/src/client/components/CronChatCreateFlow.tsx b/packages/webui/src/client/components/CronChatCreateFlow.tsx new file mode 100644 index 000000000..b18c5c337 --- /dev/null +++ b/packages/webui/src/client/components/CronChatCreateFlow.tsx @@ -0,0 +1,137 @@ +// 定时任务 — 在对话中创建. +// +// The second creation path. Instead of filling the form first, the user gets a +// real conversation: the panel opens a session, seeds it with one guidance +// message, and the user describes the task in their own words. The closing +// action is the user's own click on 「完成并创建」 — this version is WebUI- +// orchestrated, it does not parse the transcript into a schedule. +// +// The created task targets that conversation (`sessionTarget: { mode: +// "sessionId" }`), so the run continues where the user already described it +// rather than starting cold in a new session. + +import type { ReactElement } from "react"; +import type { WebuiCreateCronDefinitionRequest } from "../contracts.js"; +import { + WebuiCronCreateDialog, + createCronRequestFromDraft, + type WebuiCronDraft, +} from "./CronCreateDialog.js"; + +/** The one message the flow seeds the new conversation with. */ +export const CHAT_CREATE_GUIDE_PROMPT = + "我想创建一个定时任务。请帮我把它说清楚:\n" + + "1. 这个 Agent 定期要做什么(例如「汇总昨天的提交并写进 CHANGELOG.md」);\n" + + "2. 多久执行一次(每天几点 / 每小时 / 每周几);\n" + + "3. 需要在哪个项目目录里执行。\n" + + "说完之后回到「定时」页面,点「完成并创建」把它保存成定时任务。"; + +/** The frozen create request for the conversational path: same fields as the + * manual dialog, but the session the user just talked in is the target. A + * session is required here — unlike 始终使用同一对话, this path always knows + * which conversation it means. */ +export function buildChatCronRequest( + draft: WebuiCronDraft, + sessionId: string, +): WebuiCreateCronDefinitionRequest | undefined { + const trimmed = sessionId.trim(); + if (!trimmed) return undefined; + return createCronRequestFromDraft({ ...draft, sessionMode: "sessionId", sessionId: trimmed }); +} + +export interface WebuiCronChatCreateFlowProps { + /** The conversation opened for this task, or undefined before the user + * starts one. Lifted by the panel so it survives the view switch into the + * conversation and back. */ + readonly sessionId?: string; + readonly draft: WebuiCronDraft; + readonly onDraftChange: (next: WebuiCronDraft) => void; + readonly agents?: Parameters[0]["agents"]; + readonly models?: Parameters[0]["models"]; + readonly projects?: Parameters[0]["projects"]; + readonly busy?: boolean; + readonly onStart: () => void; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +export function WebuiCronChatCreateFlow({ + sessionId, + draft, + onDraftChange, + agents, + models, + projects, + busy = false, + onStart, + onSubmit, + onClose, +}: WebuiCronChatCreateFlowProps): ReactElement { + if (!sessionId) { + return ( +
+

在对话中创建

+

+ 先开一个对话,把定时任务说清楚:我会新创建一个会话并把引导语发进去, + 你在对话里描述想让 Agent 定期做什么、多久执行一次, + 说完回到这里点「完成并创建」保存。 +

+
+ + +
+
+ ); + } + + return ( +
+
+

在对话中创建

+

+ 已在会话 {sessionId}{" "} + 里发起了引导。请在对话中把任务说清楚,然后回到这里补全下面的字段并点「完成并创建」; + 这个任务会继续使用该会话。 +

+
+ {CHAT_CREATE_GUIDE_PROMPT} +
+
+ +
+ ); +} + +export default WebuiCronChatCreateFlow; diff --git a/packages/webui/src/client/components/CronCreateDialog.tsx b/packages/webui/src/client/components/CronCreateDialog.tsx new file mode 100644 index 000000000..9dc96e4a8 --- /dev/null +++ b/packages/webui/src/client/components/CronCreateDialog.tsx @@ -0,0 +1,706 @@ +// 定时任务 — the desktop's create / edit dialog, field for field. +// +// `D:\temp\mmx-webui-cron\CONTRACT.md` §6 freezes the mapping from the desktop +// controls to the v2 wire fields; §3 forbids handing the user a raw cron +// expression to type. So the schedule is a structured control (周期 + 时间) that +// *produces* `WebuiCronSchedule`, and this module owns that translation in both +// directions so the panel and the tests share one implementation. +// +// The component is controlled and presentational: it owns no transport and no +// effects, so the panel owns the wire and the shell's SSR tests can render the +// real form without a DOM. + +import type { ReactElement } from "react"; +import type { + WebuiAgentRef, + WebuiCreateCronDefinitionRequest, + WebuiCronDefinition, + WebuiCronSchedule, + WebuiCronSessionTarget, + WebuiUpdateCronDefinitionRequest, +} from "../contracts.js"; +import type { WebuiModelEntry } from "../../server/port.js"; + +/** Desktop limits (CONTRACT §6): 名称 n/50, 指令 n/8000. */ +export const CRON_NAME_LIMIT = 50; +export const CRON_PROMPT_LIMIT = 8000; + +/** The runtime's own default agent, as the rest of the WebUI resolves it + * (`commands/runner.ts`, `WebuiClientFoundationApp`'s `selectedAgentName`). */ +export const DEFAULT_AGENT_NAME = "main"; + +/** The desktop's four periods. There is no `once` entry: a one-shot task is + * something the runtime can already hold, not something this form creates, so + * editing one shows it read-only instead (see `onceAtMs`). */ +export type WebuiCronPeriod = "minutes" | "hours" | "daily" | "weekly"; + +export const CRON_PERIOD_OPTIONS: readonly { readonly value: WebuiCronPeriod; readonly label: string }[] = [ + { value: "minutes", label: "每 {N} 分钟" }, + { value: "hours", label: "每 {N} 小时" }, + { value: "daily", label: "每天" }, + { value: "weekly", label: "每周" }, +]; + +/** The interval sub-selectors' value sets. Each period shows only the ones its + * expression needs: `每 N 分钟` takes one, `每 N 小时` takes two, 每天 and 每周 + * take a clock time instead. */ +export const CRON_MINUTE_INTERVALS: readonly number[] = [1, 2, 5, 10, 15, 20, 30, 45]; +export const CRON_HOUR_INTERVALS: readonly number[] = [1, 2, 3, 4, 6, 8, 12]; +export const CRON_HOUR_MINUTES: readonly number[] = [0, 5, 10, 15, 20, 30, 45]; + +/** cron day-of-week, 0 = 周日. */ +export const CRON_WEEKDAYS: readonly { readonly value: number; readonly label: string }[] = [ + { value: 1, label: "周一" }, + { value: 2, label: "周二" }, + { value: 3, label: "周三" }, + { value: 4, label: "周四" }, + { value: 5, label: "周五" }, + { value: 6, label: "周六" }, + { value: 0, label: "周日" }, +]; + +export interface WebuiCronDraft { + readonly name: string; + readonly agentName: string; + readonly prompt: string; + /** `new` → 每次新建对话;`sessionId` → 始终使用同一对话。 */ + readonly sessionMode: "new" | "sessionId"; + /** The conversation 始终使用同一对话 targets. Empty means "let the server + * bind it", which the contract allows by leaving `sessionId` off. */ + readonly sessionId: string; + /** Workspace directory, or "" for 不需要项目. */ + readonly project: string; + /** Model id, or "" for 使用当前模型. */ + readonly model: string; + readonly period: WebuiCronPeriod; + /** N in `每 N 分钟`. */ + readonly intervalMinutes: number; + /** N in `每 N 小时`. */ + readonly intervalHours: number; + /** M in `每 N 小时`'s `M` + step-`N` hour field. */ + readonly minuteOfHour: number; + /** 0 = 周日 … 6 = 周六. */ + readonly weekday: number; + /** `HH:MM`, 24-hour, for 每天 and 每周. */ + readonly time: string; + /** Set only when editing an existing `kind: "once"` task. The schedule is + * then read-only and the update request omits `schedule` entirely. */ + readonly onceAtMs?: number; + /** An expression this module did not produce, kept verbatim so editing a + * task the desktop created cannot quietly rewrite its schedule. */ + readonly rawExpression: string; +} + +const pad2 = (value: number): string => String(value).padStart(2, "0"); + +export function emptyCronDraft(agentName: string = DEFAULT_AGENT_NAME): WebuiCronDraft { + return { + name: "", + agentName, + prompt: "", + sessionMode: "new", + sessionId: "", + project: "", + model: "", + period: "daily", + intervalMinutes: 5, + intervalHours: 2, + minuteOfHour: 0, + weekday: 1, + time: "09:00", + rawExpression: "", + }; +} + +/** 「默认」 entry: the runtime's default agent when `listAgents` has not + * answered yet, otherwise the listed agent of that name. */ +export function resolveDefaultAgentName(agents: readonly WebuiAgentRef[]): string { + return agents.some((agent) => agent.agentName === DEFAULT_AGENT_NAME) + ? DEFAULT_AGENT_NAME + : agents[0]?.agentName ?? DEFAULT_AGENT_NAME; +} + +/** The wire is untyped at runtime: `listAgents` answers a bare + * `WebuiAgentRef[]`, and one entry that is not an agent ref must not empty the + * whole Agent dropdown. */ +export function readAgentRefs(value: unknown): readonly WebuiAgentRef[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is WebuiAgentRef => + typeof item === "object" && item !== null && typeof (item as WebuiAgentRef).agentName === "string", + ); +} + +const TIME_PATTERN = /^(\d{1,2}):(\d{2})$/u; + +const inRange = (value: number, min: number, max: number): boolean => + Number.isInteger(value) && value >= min && value <= max; + +/** Structured control → `WebuiCronSchedule`, per the desktop's four periods: + * `每 N 分钟` puts a step-N field in the minute column, `每 N 小时` pairs a + * minute with a step-N hour column, 每天 is `M H * * *`, and 每周 appends the + * weekday: `M H * * D`. Undefined means "not fillable yet", which is what keeps + * 确认 disabled rather than raising. */ +export function buildCronSchedule(draft: WebuiCronDraft): WebuiCronSchedule | undefined { + if (draft.onceAtMs !== undefined) return { kind: "once", runAtMs: draft.onceAtMs }; + if (draft.rawExpression.trim()) return { kind: "recurring", expression: draft.rawExpression.trim() }; + if (draft.period === "minutes") { + return inRange(draft.intervalMinutes, 1, 59) + ? { kind: "recurring", expression: `*/${draft.intervalMinutes} * * * *` } + : undefined; + } + if (draft.period === "hours") { + if (!inRange(draft.intervalHours, 1, 23) || !inRange(draft.minuteOfHour, 0, 59)) return undefined; + return { kind: "recurring", expression: `${draft.minuteOfHour} */${draft.intervalHours} * * *` }; + } + const time = TIME_PATTERN.exec(draft.time.trim()); + if (!time) return undefined; + const hour = Number(time[1]); + const minute = Number(time[2]); + if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return undefined; + const clock = `${minute} ${hour}`; + if (draft.period === "weekly") { + return inRange(draft.weekday, 0, 6) + ? { kind: "recurring", expression: `${clock} * * ${draft.weekday}` } + : undefined; + } + return { kind: "recurring", expression: `${clock} * * *` }; +} + +/** `WebuiCronSchedule` → the control's fields. A `once` schedule comes back as + * `onceAtMs` (read-only), and an expression this module cannot decompose comes + * back as `rawExpression`, so neither can be silently rewritten into a daily + * one by opening the editor. */ +export function scheduleFields(schedule: WebuiCronSchedule): { + period: WebuiCronPeriod; + intervalMinutes: number; + intervalHours: number; + minuteOfHour: number; + weekday: number; + time: string; + onceAtMs?: number; + rawExpression: string; +} { + const fallback = { rawExpression: "" }; + if (schedule.kind === "once") { + // There is no `once` period in the control, so the draft is marked instead. + return { ...fallback, period: "daily", intervalMinutes: 5, intervalHours: 2, minuteOfHour: 0, weekday: 1, time: "09:00", onceAtMs: schedule.runAtMs }; + } + const undecodable = (): ReturnType => ({ + period: "daily", + intervalMinutes: 5, + intervalHours: 2, + minuteOfHour: 0, + weekday: 1, + time: "09:00", + rawExpression: schedule.expression, + }); + const fields = schedule.expression.trim().split(/\s+/u); + if (fields.length !== 5) return undecodable(); + const base = { ...fallback, period: "daily" as WebuiCronPeriod, intervalMinutes: 5, intervalHours: 2, minuteOfHour: 0, weekday: 1, time: "09:00" }; + + // A step-N minute column with every other column wildcard → 每 N 分钟. + const step = /^\*\/(\d{1,2})$/u.exec(fields[0]); + if (step && inRange(Number(step[1]), 1, 59) && fields.slice(1).every((field) => field === "*")) + return { ...base, period: "minutes", intervalMinutes: Number(step[1]) }; + + // A minute plus a step-N hour column → 每 N 小时. + if (fields[2] === "*" && fields[3] === "*" && fields[4] === "*") { + const hourStep = /^\*\/(\d{1,2})$/u.exec(fields[1]); + const minute = Number(fields[0]); + if (hourStep && inRange(Number(hourStep[1]), 1, 23) && inRange(minute, 0, 59)) + return { ...base, period: "hours", intervalHours: Number(hourStep[1]), minuteOfHour: minute }; + // A plain hour in the same shape is 每天, not an interval. + const hour = Number(fields[1]); + if (/^\d{1,2}$/u.test(fields[1]) && inRange(hour, 0, 23) && inRange(minute, 0, 59)) + return { ...base, time: `${pad2(hour)}:${pad2(minute)}` }; + return undecodable(); + } + + // `M H * * D` → 每周, `M H * * *` → 每天. + if (fields[2] === "*" && fields[3] === "*") { + const minute = Number(fields[0]); + const hour = Number(fields[1]); + if (!inRange(minute, 0, 59) || !inRange(hour, 0, 23)) return undecodable(); + const time = `${pad2(hour)}:${pad2(minute)}`; + if (fields[4] === "*") return { ...base, time }; + const weekday = Number(fields[4]); + return inRange(weekday, 0, 6) ? { ...base, period: "weekly", time, weekday } : undecodable(); + } + return undecodable(); +} + +export function cronDraftFromDefinition(definition: WebuiCronDefinition): WebuiCronDraft { + return { + ...emptyCronDraft(definition.agentName || DEFAULT_AGENT_NAME), + name: definition.name, + agentName: definition.agentName, + prompt: definition.prompt, + sessionMode: definition.sessionTarget.mode === "sessionId" ? "sessionId" : "new", + sessionId: definition.sessionTarget.mode === "sessionId" ? definition.sessionTarget.sessionId ?? "" : "", + project: definition.project ?? "", + model: definition.model ?? "", + ...scheduleFields(definition.schedule), + }; +} + +/** Empty string when 确认 may be pressed; otherwise the reason it may not. */ +export function cronDraftIssue(draft: WebuiCronDraft): string { + if (!draft.name.trim()) return "请填写名称"; + if (draft.name.length > CRON_NAME_LIMIT) return `名称不能超过 ${CRON_NAME_LIMIT} 个字符`; + if (!draft.agentName.trim()) return "请选择 Agent"; + if (!draft.prompt.trim()) return "请填写指令"; + if (draft.prompt.length > CRON_PROMPT_LIMIT) return `指令不能超过 ${CRON_PROMPT_LIMIT} 个字符`; + if (!buildCronSchedule(draft)) return "请填写执行时间"; + return ""; +} + +/** `始终使用同一对话` with no session yet submits `sessionId` absent, which the + * contract allows and the server binds. */ +function sessionTargetFrom(draft: WebuiCronDraft): WebuiCronSessionTarget { + if (draft.sessionMode !== "sessionId") return { mode: "new" }; + const sessionId = draft.sessionId.trim(); + return sessionId ? { mode: "sessionId", sessionId } : { mode: "sessionId" }; +} + +/** The frozen `WebuiCreateCronDefinitionRequest`, or undefined while the draft + * is still incomplete — the same condition that disables 确认. A `once` draft + * is edit-only: the form cannot express a one-shot, so it cannot create one. */ +export function createCronRequestFromDraft( + draft: WebuiCronDraft, +): WebuiCreateCronDefinitionRequest | undefined { + if (draft.onceAtMs !== undefined) return undefined; + if (cronDraftIssue(draft)) return undefined; + const schedule = buildCronSchedule(draft); + if (!schedule) return undefined; + return { + name: draft.name.trim(), + agentName: draft.agentName.trim(), + schedule, + prompt: draft.prompt.trim(), + sessionTarget: sessionTargetFrom(draft), + project: draft.project.trim() || null, + model: draft.model.trim() || null, + }; +} + +/** The frozen update request. A task whose schedule the form does not own — a + * one-shot, or an expression the control could not decompose — is saved + * without a `schedule` field at all, so the stored schedule is left alone + * instead of being rewritten. */ +export function updateCronRequestFromDraft( + draft: WebuiCronDraft, + cronId: string, +): WebuiUpdateCronDefinitionRequest | undefined { + if (cronDraftIssue(draft)) return undefined; + const schedule = buildCronSchedule(draft); + const scheduleOwned = draft.onceAtMs === undefined && draft.rawExpression.trim() === ""; + return { + cronId, + name: draft.name.trim(), + ...(scheduleOwned && schedule ? { schedule } : {}), + prompt: draft.prompt.trim(), + sessionTarget: sessionTargetFrom(draft), + project: draft.project.trim() || null, + model: draft.model.trim() || null, + }; +} + +const DIALOG_MASK = "fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.25)]"; +const DIALOG_SURFACE = "w-[520px] max-w-[calc(100vw-32px)] overflow-clip rounded-[20px] bg-bg_grouped_secondary p-6"; +const FIELD_LABEL = "flex min-w-0 flex-1 flex-col gap-1 text-sm text-text_default_secondary"; +const FIELD_CONTROL = + "h-9 w-full min-w-0 rounded-[8px] border-[0.5px] border-border_default bg-bg_default_primary px-2 text-sm text-text_default_primary outline-none disabled:opacity-50"; +const GHOST_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] px-4 text-sm text-text_default_secondary transition-colors hover:bg-bg_interaction_tertiary_hover disabled:opacity-50"; +const PRIMARY_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] bg-bg_interaction_primary_default px-4 text-sm text-text_default_inverted transition-colors hover:bg-bg_interaction_primary_hover disabled:opacity-50"; + +function RequiredMark(): ReactElement { + return ( + + ); +} + +function FieldLabel({ children }: { readonly children: ReactElement | string }): ReactElement { + return {children}; +} + +/** 「每 {N} 分钟」 carries the currently chosen interval, so the dropdown label + * matches the sub-selector sitting next to it. */ +function periodLabel( + option: { readonly value: WebuiCronPeriod; readonly label: string }, + draft: WebuiCronDraft, +): string { + if (option.value === "minutes") return `每 ${draft.intervalMinutes} 分钟`; + if (option.value === "hours") return `每 ${draft.intervalHours} 小时`; + return option.label; +} + +const formatOnceAt = (runAtMs: number): string => { + const at = new Date(runAtMs); + if (Number.isNaN(at.getTime())) return EMPTY_ONCE_LABEL; + return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}`; +}; + +const EMPTY_ONCE_LABEL = "(时间未知)"; + +export interface WebuiCronCreateDialogProps { + readonly title?: string; + readonly draft: WebuiCronDraft; + readonly onDraftChange: (next: WebuiCronDraft) => void; + readonly agents?: readonly WebuiAgentRef[]; + readonly models?: readonly WebuiModelEntry[]; + readonly projects?: readonly string[]; + readonly busy?: boolean; + /** 确认 on the desktop; the 在对话中创建 flow closes with 完成并创建. */ + readonly submitLabel?: string; + /** Set by the 在对话中创建 path: the conversation session is the target and + * cannot be pointed elsewhere from the form. */ + readonly lockedSessionId?: string; + /** The session 始终使用同一对话 defaults to. The server binds one when the + * dialog has none, so this only pre-fills the field. */ + readonly currentSessionId?: string; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +/** The desktop dialog: 名称 / Agent, 指令, 运行模式 / 项目 / 模型, 执行时间, + * 取消 / 确认. 确认 stays disabled until every starred field is filled. */ +export function WebuiCronCreateDialog({ + title = "定时任务", + draft, + onDraftChange, + agents = [], + models = [], + projects = [], + busy = false, + submitLabel = "确认", + lockedSessionId, + currentSessionId, + onSubmit, + onClose, +}: WebuiCronCreateDialogProps): ReactElement { + const issue = cronDraftIssue(draft); + const patch = (next: Partial): void => onDraftChange({ ...draft, ...next }); + const defaultAgent = resolveDefaultAgentName(agents); + const agentOptions = [ + ...agents.filter((agent) => agent.agentName !== DEFAULT_AGENT_NAME), + ]; + const projectOptions = [...new Set(projects.map((dir) => dir.trim()).filter(Boolean))]; + const rawExpression = draft.rawExpression.trim(); + // A schedule the form does not own — a one-shot, or an expression the control + // could not decompose — is shown but not editable, and the update request + // leaves it out. + const onceAtMs = draft.onceAtMs; + const scheduleReadOnly = onceAtMs !== undefined || rawExpression !== ""; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") onClose(); + }} + > +
+

{title}

+ +
+ +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + +
+ +