Repository navigation
feat(webui): unlock the scheduled-task panel - #12
Andre-1998 wants to merge 3 commits into
Conversation
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:<op>")`, 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.
modacker
left a comment
There was a problem hiding this comment.
结论:先别合。这个面板接的是 v1 那套已经被迁移走的 cron 引擎,在真宿主下 listCrons() 恒返回空,而且不报错。
先说好话:WebuiCronRuntime 的 fail-closed 形状是对的(host.ts:344 把 cronRuntime 声明成可选槽,取不到就 runtime host does not expose cron,而不是半读一份 store),webui-service.test.ts:2987 那 410 行也把 registry 的语义(重复 → 409、缺失 → undefined/404)覆盖得挺细。问题不在这些地方,在槽位里装的东西。
1. 为什么列表恒为空
四步,每一步都对着 PR 的代码核过:
packages/webui/src/server/operation/cron.ts:121-129—— 你声明的WebuiCronRuntime只有ensureStarted+registry。packages/webui/src/server/host.ts:485-487——listCrons()读的是cron.registry.listAllTasks(),没有第二条路。packages/local-runtime/src/cron/api.ts:145-151——ensureStarted在cronConsumerEnabled === false时直接return Promise.resolve(),只发一条cron.runtime_start_skipped/skipReason: 'consumer_disabled'的总线事件。packages/local-runtime/src/cron/legacy-md-migration.ts:120—— registry 只在registry.start()里被填充,而这句只从第 3 步那个return之后才会执行到。
而 packages/local-runtime-v2/src/compat/v1/runtime.ts:441 把 cronConsumerEnabled: false 写死了;packages/local-runtime/src/api/host.ts:1121 那个三元(this.runtimeConversation ? options.cronConsumerEnabled : false)在这条路径上原样把 false 传下去。
我写了个探针跑真实的 createHarnessPortFromHost:consumer 关闭 + store 里有 1 条任务时,listCrons() 返回 {"tasks":[]},引擎内部事件是 start_requested:webui:listCrons | start_skipped:consumer_disabled。把 consumer 打开,同一条探针返回那条任务。所以阻塞成立,而且它在面板上完全静默。
2. 更根本的:这不是「开关没传对」,是接错系统
仓里有两套 cron:
| v1(你接的) | v2(产品真在跑的) | |
|---|---|---|
| 存储 | local_runtime_crons 表(local-runtime/src/cron/store.ts:42) |
cronDefinitions(local-runtime-v2/src/service/cron/adapters/definition.repository.ts) |
| 主键 | agentName / cronName |
cronId + schedulerId |
| 调度 | CronRegistry,被 consumer 开关门控 |
自己的 SchedulerClient |
| 任务字段 | schedule / prompt / session / timezone / activeHours | 另加 project、model(service/cron/contracts.ts:29-45) |
packages/local-runtime-v2/src/infra/db/migrations/cron/migration-0002-copy-legacy-cron-data.ts:71,77 直接 FROM local_runtime_crons —— v1 就是 legacy,数据已经被抄走了。
活体佐证(我这边 runtime,cron_read):列出 16 条任务,其中 2 条 enabled: true 的循环任务带着已算好的 nextRun(0 0 */2 * * Asia/Shanghai),并且带 project / model 字段。这些只可能是 v2 在跑 —— v1 的调度器在这个 runtime 里永远不会被启动。
所以就算把开关打开让它列出 v1 的 registry,列出来的也是另一批(空的)数据,跟用户在 MCode 里看到的定时任务不是一回事。
3. 正确的接法
packages/local-runtime-v2/src/service/cron/contracts.ts:170 的注释原话:
Shared Cron business entry point for HTTP, Agent cleanup, and other callers.
现成就有面板要的东西:listDefinitions / getDefinition / createDefinition / updateDefinition / deleteDefinition、:189 的 triggerManualRun、:193 的 listRuns(运行历史,你现在还没有)。CronMutationMetricSource 里也已经预留了 "ui"。
而且这套东西不受 consumer 开关管 —— 它不是执行角色,是一个业务门面。对照:packages/local-runtime/src/cron/api.ts:437-443 里运行时自己的 HTTP API 对 trigger 的处理是直接返回 CRON_CONSUMER_DISABLED(409),因为「立即执行」确实需要执行角色。列表/创建/改/删则都有 store 兜底(api.ts:336-338、378-380、404-406,runtime/mavis-cron-adapter.ts:52-54 同样)—— 仓里三处都做了这个分支,只有 webui 没跟。
代价我也说清楚,这个得你判断能不能接:
- webui 的
WebuiRuntimeHostHandle现在没有 cron service 的槽位,得加一个结构化声明(照WebuiCronRuntime的写法)。 CronService没从@mavis/local-runtime-v2的公开入口导出(src/index.ts零 cron 导出,package.json的exports也没有 cron 子路径),要么加子路径,要么在 webui 侧结构化声明。- 默认宿主是
packages/webui/src/server/assembly.ts:527-531的createLocalRuntimeHostV2,它返回的对象是{...v1, application, cliService, apiHost: v1.apiHost, ready}(local-runtime-v2/src/runtime.ts:445-457)—— v2 的services.cron(services.ts:225)没被露出来,cliService里也没有 cron 能力。所以还要在那个 return 里多露一个字段。 - 我看不到的一处:
options.factory是留给嵌入方自定义 host 的口子,本仓没有任何包依赖@mavis/webui,所以线上产品如果自带 factory,它长什么样我在仓里看不到。默认路径是 v2 这条是确定的。
另外 service/cron/ 包内零入参校验(CRON_NAME_RE / assertSchedulable / isValidCronName 在 v2 里 0 命中,v1 的 legacy-md-migration.ts:52,110 才用)—— 意味着下面第 3 条缺陷在换接到 v2 之后会原样跟过去。
4. 与选哪条路无关的三个缺陷
a) SchedulesPanel.tsx 里有 2 个 0x00 字节,落在第 80 行:
`${task.agentName}<NUL>${task.cronName}<NUL>${action}`;
后果是 git 把整个文件当二进制(Bin 0 -> 19364 bytes),diff / blame / review 全都失效。这个必须先清掉 —— 不然后面每次改这个文件都拿不到行级 diff。
b) 创建表单的错误被吞掉。 SchedulesPanel.tsx:179 和 :191 都是 .then(closeForm) 没有 .catch:
).then(closeForm);失败时表单照样关掉,用户看不到任何原因,用户以为创建成功。这条和接哪套系统无关,两个方向都得修。
c) agentName 只校验了非空。 SchedulesPanel.tsx:159-163:
const agentName = draft.agentName.trim();
if (!agentName || !cronName || !schedule || !prompt) { ... }仓里现成的校验可以直接引:isValidLocalAgentName(packages/local-runtime/src/api/host-helpers.ts:520)和 cron 名的 CRON_NAME_RE(legacy-md-migration.ts:52,110,/^[^\s/\\:*?"<>|]+$/ 且 ≤ 64)。现在传进去一个带斜杠或冒号的名字,要等到引擎层才炸,错误信息对用户没有意义。
5. 我验了什么 / 没验什么
验了:探针跑真实 createHarnessPortFromHost,consumer 开与关两种情形;上面每条路径都对 PR 的代码逐行核过;#12 与当前 webui(2ef578b)的合并预演干净无冲突(与 #11 只重叠 release/public-source.json,两边都是追加行)。
没验:线上产品的真实宿主形态(见第 3 节末尾那个 factory 口子);v2 的 CronService 在你的环境里装配 webui 时拿不拿得到 —— 这个我查不到,得问宿主那边。
如果你要的是一个不依赖宿主决策就能合的版本,我建议先交这三样:清掉 0x00、.then(closeForm) 补 .catch、agentName 补格式校验,再把 apiHost.cronRuntime 缺失或 consumer 不可用时显式降级成「当前宿主不提供定时任务」(现在是一个空列表冒充「你没有定时任务」)。接 CronService 那步可以等宿主确认了槽位再来。
…anel use
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.
… semantics 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.
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 awaitsensureStarted("webui:<op>"), and each port method fails closed withruntime host does not expose cronrather than failing as an undefined call. The wire types mirror the transport-agnostic mapping inlocal-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.deleteCronis idempotent by contract and reports presence in its{ success }boolean;updateCronhas 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.
startupExecutionPolicystays'quarantined'andruntimeOwnerKindstays'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.mdno 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.
Change
Describe the user-visible problem and resulting behavior. Link a public issue when applicable.
bug,enhancement,documentationordependencies) and affected product (cli,tuitogether withcli, ordesktop) where applicable; see the label guide.Validation
perf:full(see requirements); for full coverage, link a passing run for the latest PR head and intended base:Publication and contribution checks
release/public-source.json; new tests are declared intest/vitest-suites.jsonwhere applicable.Maintainer handoff
Publication scope or license changes (if any):
Shared-source port: not needed / pending / complete. Record public PR references only; keep private links and review material out of this PR.