Skip to content

feat(webui): run the WebUI's own scheduled tasks, and record how that ends - #21

Merged
modacker merged 1 commit into
webuifrom
feat/webui-own-scheduled-tasks
Oct 5, 2026
Merged

modacker merged 1 commit into
webuifrom
feat/webui-own-scheduled-tasks

Conversation

@modacker

@modacker modacker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

The WebUI runs its own scheduled tasks until the runtime offers one. ADR 0012 carries the reasoning; this is the implementation and the tripwire that watches for the day it should end.

Why not the runtime's cron

Measured on a real WebUI host, neither engine can serve it:

  • v1 is pinned off for v2-compat hosts (local-runtime-v2/src/compat/v1/runtime.ts) and its table, local_runtime_crons, holds 0 rows — migration-0002-copy-legacy-cron-data.ts moved the data out.
  • v2 is never created here. enableCron comes from ownsElectronRuntimeCapabilities(runtimeOwnerKind), and the WebUI declares runtimeOwnerKind: "tui".
  • It would not be visible if it were. CreatedLocalRuntimeHost carries only application? and cliService?.

An earlier attempt to reach it by declaring the WebUI an Electron owner made it worse and was reverted: isV2RuntimeOwner needs capabilities.electronHost for that kind, and without it the whole v2 service group — including the cliService every other feature uses — goes away.

What this adds

A store at <dataDir>/webui/scheduled-tasks.sqlite with its own table, migrated through SQLite's own user_version (each step IF NOT EXISTS, DDL and version number in one transaction, so a restart is a no-op and an interrupted upgrade resumes). An in-process tick beside the existing heartbeat in WebuiService. And WebuiScheduledTaskPort — six methods that name operations, not an engine.

It never selects from local_runtime_crons or local_runtime_v2_cron_definitions. Reading the latter from here would be a second scheduler with none of v2's concurrency guards (claimExecution is an atomic conditional update; insertPendingScheduled converges on a trigger id) writing into the same agent queue as the desktop client.

The part that is a product decision, not a defect

The WebUI process being alive is the execution guarantee. It is started on demand, so when nobody is running it, nothing fires. Slots missed while it was down are not replayed — they are counted (missed_count, last_missed_at_ms) so the surface can say so instead of implying the task never existed.

Consequence, stated plainly: a scheduled task in the WebUI and one in the desktop client are two different things that cannot see each other.

The tripwire

ADR 0012's retirement condition is two independently checkable gates, and a test reports on them on every run. It never fails a build — a gate that goes red for something that is not a defect only teaches people to ignore red:

webui scheduled tasks: gate1=closed gate2=closed probe=ok implementation=webui-own

Silent while both are shut. A notice when the runtime moves, escalating to the retirement steps when both are open, and a distinctly-worded "this is not a retirement signal" when the probe itself cannot confirm. It writes to the real stream rather than console, because vitest's default reporter swallows console output from passing tests — which would have made the report invisible in CI.

Verification

verify --profile full 20/20 gates, exit 0
test:webui 1599 passed (79 files) — 1586 baseline + 13 new
tripwire, all four upstream states exit 0 every time, report printed
check:source PASS (4714 files)
typecheck:webui server / client / test clean

The new tests were written first and caught three real bugs in the implementation during the red-to-green pass, including a success path that never recorded its outcome and a typeof null === "object" that crashed the whole handler.

Not verified

  • No real browser run. The panel is not in this change; it is Andy's feat(webui): unlock the scheduled-task panel #12, and it rebinds the port rather than being rewritten.
  • No real host build in a test. The store and the port are covered; the wiring from WebuiService to a live tick is covered only by this PR's own suite, not by a browser or an end-to-end gate.
  • Windows is unverified locally — the native addon is resolved through a lazy createRequire, and this PR is the first to declare it in packages/webui. CI's windows-latest job is the real check.

… ends

The WebUI is a loopback process the user starts. Neither cron engine in
the tree can serve it: the v1 path is pinned off for v2-compat hosts and
its table is empty after the v2 migration moved the data out, and the v2
`CronService` is created only for an Electron owner and is not carried on
the host contract in any case. Measured on a real WebUI host; ADR 0012
carries the full chain and the decision.

So the WebUI runs its own: a store at `<dataDir>/webui/scheduled-tasks.sqlite`
with its own table, an in-process tick beside the heartbeat in
`WebuiService`, and a `WebuiScheduledTaskPort` whose six methods name
operations rather than an engine. It never selects from the runtime's cron
tables — that would be a second scheduler with none of v2's concurrency
guards writing into the same agent queue as the desktop client.

Being alive is the execution guarantee. Slots missed while the process was
down are **not** replayed; they are counted (`missed_count`,
`last_missed_at_ms`) so the surface can say so rather than imply the task
never existed.

`better-sqlite3` is declared here for the first time: the WebUI did not
persist anything before, and it had been reaching the module through the
workspace's hoisted `node_modules` without declaring it.

ADR 0012 states the retirement condition as two independently checkable
gates, and the probe test reports on them on every run — quietly while both
are shut, and with a notice when the runtime moves. It never fails a build:
a gate that goes red for something that is not a defect only teaches people
to ignore red. Retirement is the adapter, the table migration, and tearing
down this tick loop in the same change; two schedulers over one queue is
what the decision exists to prevent.
@modacker
modacker merged commit 52e902f into webui Oct 5, 2026
18 checks passed
antianqi added a commit that referenced this pull request Oct 5, 2026
…wo dark tabs

Roadmap E 区 ships five rows. PR #20 took the Revert / Reapply row. This takes
the two that the roadmap records as ❌ with the reason "设置 tab 禁用".

That reason is only half of it. Both tabs were disabled because there was
nothing behind them, not because a gate refused them: `SettingsModal`'s render
chain is a series of `active === …` ternaries with branches for `desktop`,
`usage`, `account` and `archived`, and everything else fell through to an
`webui-settings-empty-panel` placeholder. Flipping `disabled` to `false` would
have produced a clickable label above a blank pane — the same shape of defect
the row already had. So both pages are built.

代码审查 (Review 审查模式 / 修复建议+跳转)

The capability was already wired end to end and unused from here: the
workspace review operations (`getWorkspaceReviewSummary`,
`listWorkspaceReviewFileDiffs`, `searchWorkspaceReviewDiffs`) are registered,
exposed on the transport and typed — the diff card's Review button already
dispatches into the workspace panel. What was missing was a place to read a
whole change set.

The page lists the change set with per-file stats, loads each file's unified
diff on expand in batches of five, and turns a diff line into a real editor
jump: `projectWebuiReviewLines` numbers both sides from the hunk header, and a
click resolves to the new-side line. A deleted line gets no target, because it
is not in the file the editor will open — that is asserted directly rather than
guarded, so the contract lives in a test instead of in an untestable line.

Also plumbed: `workspaceDir` and `onOpenFileLine` now travel
FoundationApp → UserMenu → SettingsModal. The jump reuses the shell's existing
`#session=`/`open-file` path, so there is one navigation route rather than two.

工作树 (工作树隔离)

Creating an isolated worktree already worked from the rail's context menu
(「复制到新工作树」, with `worktreeVisible` / `worktreeUnavailableReason` on the
server). This adds the other half of the row: seeing which parallel experiment
branches exist and switching into one.

A worktree is not a first-class field on a session, so it is inferred — git
makes a second checkout with its own absolute path, and `isDefaultWorkspace` is
the runtime's own statement of which one is primary. Grouping normalises path
separators first, because a fork round-tripping a Windows path would otherwise
report one checkout twice.

Two tabs were left disabled on purpose: 语音 / 快捷键 / 个性化 / 连接 still have
no content behind them, and the `disabled` gate is what keeps them from being
clickable labels over an empty pane.

Validation
- pnpm check:source                          exit 0  (inventory +6)
- pnpm typecheck:webui                       exit 0
- pnpm typecheck:webui-full                  exit 0  (covers the new .tsx tests)
- pnpm build:webui                           exit 0
- node scripts/run-vitest-suite.mjs webui    9 failed | 1637 passed | 4 skipped
- npx playwright test test/webui-browser/    79 passed

The 9 failures are the pre-existing Windows baseline and are unchanged from the
count on the parent commit: 7 webui-boundary-check path assertions, 1
webui-design-tokens path assertion, 1 webui-service shutdown timeout. The same
9 were verified on a clean tree earlier in this branch.

Test evidence
Four new test files, 56 tests, plus one browser test:

- review-state.test.ts (21) — lifecycle, the search filter, and the diff
  projection including both-side line numbering
- review-panel.test.tsx (16) — the panel's render, and the tab-to-page wiring
- worktree-state.test.ts (13) — path normalisation and checkout grouping
- worktree-panel.test.tsx (6) — the worktree list render

Negative injection: 17 mutations across all three new seams, 0 survivors, every
implementation file restored byte-for-byte. Two of those mutations were
equivalent on the first pass and both were fixed rather than waived:

- `webuiReviewLineTarget` re-checked the line kind even though
  `projectWebuiReviewLines` only ever assigns a `newLine` to additions and
  context rows. Deleting the guard changed nothing, so nothing could observe
  it. The guard is gone and the invariant is now asserted directly in the
  test, which makes a projection that starts numbering deletions fail loudly
  (4 tests red).
- The tab-to-page routing had no coverage at all. `SettingsModal` opens on
  `desktop` and only moves tab from a click handler, so no
  `renderToStaticMarkup` call can reach the `coding` or `worktree` branch —
  replacing `{active === "coding" ? <SettingsReviewPage` with `{false ? …`
  left the whole suite green. That is now covered twice: a source-level
  assertion with the reasoning written down, following the shape
  `rail-pin-affordance.test.ts` already uses, and a real browser test that
  clicks both tabs and fails if either lands on the empty-pane placeholder.

One existing test changed rather than the code: `settings-modal.test.tsx` and
`settings-account-tab.spec.mjs` both asserted that `coding` was among the tabs
that must stay disabled. The two gates moved `coding` and `worktree` into the
implemented group and left the remaining unimplemented tabs asserted, so
neither test lost its teeth.

Scope: the Review 审查模式, 修复建议+跳转 and 工作树隔离 rows. Diff 展示 was
already ✅ and is untouched. Revert / Reapply still has its server half open —
surfacing skipped files needs a field on `LocalTurnDiffMutationBody` in
`@mavis/protocol/local`, which is the v1 layer under active rework in #21, so
it does not belong in this PR. Described in the PR body.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant