Skip to content

fix(webui): report a failed revert/reapply instead of relabelling it a missing capability - #20

Merged
modacker merged 5 commits into
webuifrom
fix/webui-diff-mutation-error
Oct 5, 2026
Merged

modacker merged 5 commits into
webuifrom
fix/webui-diff-mutation-error

Conversation

@antianqi

@antianqi antianqi commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What this covers

Roadmap E 区, 安天齐. Five rows; this PR takes all five.

Row Roadmap said This PR
Diff 展示 ✅ untouched — already passing
修复建议 + 跳转 ➖ 未逐项实测 built — diff line → editor jump
Revert / Reapply 🟡 fixed, both halves — see the correction below
Review 审查模式 ❌ 设置 tab 禁用 built
工作树隔离 ❌ 设置 tab 禁用 built

Base: e34e538. origin/webui was not moved by this PR. Three commits.

The two dark tabs were not a gate

Both were recorded as "设置 tab 禁用", which reads like a capability gate. It was not. SettingsModal's render chain is a series of active === … ternaries with branches for desktop, usage, account and archived; 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 defect, wearing a different hat. So both pages were built rather than unlocked.

代码审查 — the capability was already wired end to end and unused from this entry point: getWorkspaceReviewSummary / listWorkspaceReviewFileDiffs / searchWorkspaceReviewDiffs are registered, 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 it with per-file stats, loads each file's unified diff on expand in batches of five, searches inside it, and turns a diff line into a real editor jump. A deleted line gets no jump target, because it is not in the file the editor will open.

工作树 — creating an isolated worktree already worked from the rail's context menu (「复制到新工作树」; the server answers with worktreeVisible / worktreeUnavailableReason). This adds the other half: seeing which parallel experiment branches exist and switching into one. A worktree is not a first-class session field, so it is inferred from a distinct workspaceDir with isDefaultWorkspace as the runtime's own statement of which checkout is primary.

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

Revert / Reapply — and a correction to the roadmap

The row asks for "UI 提示和服务端判据", describing the server as "以 plan-not-safe 跳过工作区外文件恢复". Both parts are wrong against the current tree, and the difference decides what to build:

  • There is no partial skip. applyLocalTurnDiffSnapshotMutation walks record.undo and the moment safeCapturedPath refuses one file returns { success: false, reason: 'unsafe_path' } — before writing anything. applyGitPatch is the same shape: git apply exits non-zero and nothing lands. There is no skipped-file list to surface and no partial-success state to represent.
  • The token is unsafe_path, not plan-not-safe; that string occurs nowhere in the repository.
  • The server already reports a truthful reason and it reaches the client intact — body.error → v2's assertMutationSucceeded throws an AppError carrying it → the transport rejects.

So the real gap was the last step: turning a token into a sentence. describeWebuiDiffFailure maps every reason the mutation path can produce, with conflict worded to say the file changed after the turn — the part that tells a user to look before retrying. Unknown reasons pass through unchanged on purpose: git apply failures are free-form stderr, and replacing a real message with a guess is worse.

Alongside that, the failure is no longer relabelled as a missing capability, the runtime's reason is carried into the card instead of discarded, an empty reapply result no longer deletes the card silently, and the controls stay usable so a failed revert can be retried.

What is deliberately not in this PR

语音 / 快捷键 / 个性化 / 连接 stay disabled — they still have no content behind them, and that gate is what keeps them from being clickable labels over an empty pane.

Boundary with K / P

Touches the settings modal, which is the surface izzy's K-area claim and the unclaimed P area also name. The overlap is confined to SettingsModal.tsx and the coding / worktree tab definitions. K and P are about unlocking different tabs; this PR only builds content for two of them and leaves the other four gated. If P later takes 代码审查 or 工作树, this PR is the thing to merge first.

Validation

Gate Result
pnpm check:source exit 0
pnpm typecheck:webui exit 0
pnpm typecheck:webui-full exit 0
pnpm build:webui exit 0
run-vitest-suite.mjs webui 9 failed / 1642 passed / 4 skipped
playwright test test/webui-browser/ 79 passed

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

Test evidence

98 new tests across six files, plus one browser test. Negative injection 35 mutations across the three commits, 0 survivors, every implementation file restored byte-for-byte.

Three things the injections caught that a green suite did not:

  • webuiReviewLineTarget had a guard nothing could observe. projectWebuiReviewLines only ever assigns a newLine to additions and context rows, so deleting the kind-check changed nothing. The guard is gone and the invariant is asserted in the test instead — a projection that starts numbering deletions now fails 4 tests.
  • 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. Now covered twice: a source-level assertion with the reasoning written down (the shape rail-pin-affordance.test.ts already uses), and a browser test that clicks both tabs and fails if either lands on the empty-pane placeholder.
  • A harness bug that produced a false green. The first injection harness matched a hardcoded /Tests\s+13 passed/. When the suite grew, the regex stopped matching, the pass flag went permanently false, and every mutation was reported "killed" regardless of what it did. It now counts failures out of the summary line and classifies a crashed run separately from a red one. A later version also scored a stale anchor as a survivor, which reports a script-maintenance problem as a test-quality problem; the two are now separate and only the latter affects the exit code.

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

…a missing capability

A revert or reapply that did not apply reduced to the same state as a
runtime that never offered the capability, so the card told the user
"当前运行时未提供 session diff 能力" and then went dead for good.

Three defects, all reachable from one click on 撤销 / 重新应用:

1. `mutation-failed` shared a reducer branch with `unsupported`
   (contracts.ts). The runtime had answered — it had declined — but the UI
   reported the capability as absent.

2. Because `buildWebuiDiffMutationRequest` refuses every request once
   `unsupported` is set, and nothing ever reset it, the first failure left
   the card permanently unable to revert, reapply, or retry. There is no
   way back: the reload effect depends on `[getTurnDiff, request]`, so it
   never refires.

3. `WebuiRevertTurnDiffResult.error` was never read. The client referenced
   the result type in exactly two places and both only looked at the view,
   so the runtime's own explanation was dropped on the floor.

A fourth surfaced while fixing the third. `reapplyTurnDiff` answers with
the view itself, so a reapply that applied nothing arrived as a view with
no file list — and the card renders `null` for an empty file list, so that
path deleted the card with no message at all. A different silent failure
from the same button.

What a transport result means now lives in one pure function,
`resolveWebuiDiffMutation`, checked against two independent things:
whether the runtime said it worked, and whether it handed back an applied
file list. A failure carries the runtime's reason into a banner on the live
card. The card and its controls stay usable, and the reason clears on the
next attempt, on success, or on dismiss.

`unsupported` keeps its original meaning — a runtime that genuinely never
offers the capability — because "never say unsupported again" would be a
different bug.

Scope: the Revert / Reapply row of roadmap E 区. The other three E-area
rows are untouched. Review 审查模式 and 工作树隔离 are disabled settings
tabs, and unlocking them lands on the same settings page as izzy's K-area
claim; see the PR body for the boundary.

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

The 9 failures are the pre-existing Windows baseline, not this change:
7 webui-boundary-check path assertions, 1 webui-design-tokens path
assertion, 1 webui-service shutdown timeout. Verified by reverting all five
files of this change and re-running the same three test files on a clean
2f064db tree: the same 9 tests fail there, with identical names.

Test evidence
New: packages/webui/test/unit/diff-mutation-error.test.tsx, 17 tests.
Red before the fix: 12 of 13 failed, and the failures named the defects —
`expected true to be false` on `unsupported`, `expected undefined to
deeply equal { id: 's', changeSetId: 'changes-1' }` on the dead retry path,
and a missing `turn-diff-mutation-error` marker on the render.

Negative injection: 14 mutations, 0 survivors, implementation restored
byte-for-byte after every run. Reaching that number took two corrections
to the tests and the harness, both recorded here because both would
otherwise have produced a false green:

- One assertion was vacuous. "a genuinely unavailable capability still
  reduces to unsupported" started from a freshly loaded card whose
  `mutationError` was already `undefined`, so it passed whether or not the
  transition cleared anything. It now seeds a failure first. That mutation
  survived the first run.
- The first harness matched `/Tests\s+13 passed/`. When the suite grew to
  15, that regex stopped matching, the pass flag went permanently false,
  and all 12 mutations were reported "killed" no matter what they did —
  a false kill covering the whole run. The harness now counts the failures
  reported in the summary line and classifies a crashed run separately
  from a red one.
- With a working harness, two real coverage gaps appeared: every
  reason-carrying case used `success: false`, which returns early, so the
  fallback that reads `result.error` was never exercised. `success` is
  optional on `WebuiRevertTurnDiffResult`, so a payload carrying only
  `error` is contract-legal. Covered now.

The one mutation that needed a contract-illegal payload to die is noted in
the test: a reapply that claims `success: true` while also naming a
reason. `success` is required on `WebuiReapplyTurnDiffResult`, so the
correct and mutated code agree on every legal input. It is pinned anyway
because this is a socket boundary and a runtime that says both is better
answered with the reason than with silence.
@antianqi
antianqi force-pushed the fix/webui-diff-mutation-error branch from 6013234 to 8062b1c Compare October 5, 2026 07:29

@modacker modacker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:可以合,方向和实现都对。但描述里有一处要说准。

我拿 origin/webui 和这个分支各构建了一次 bundle,用同一份临时 spec 实跑对比,行为差异正是你要修的那处。

一处措辞:不只是「静默无效果」,实际更糟

PR 描述和提交信息里写的是「静默无效果」。实测不是静默 —— 改之前:

  • 失败会落进 case "unsupported": case "mutation-failed":(旧 DiffCard.tsx:47-49),两个完全不同的原因被并成一档
  • 于是 unsupported: true 触发 :165 的中性卡分支,用户看到的是**「文件改动暂不可用 / 当前运行时未提供 session diff 能力。」** —— 请求其实发出去了(我实测 revertCalls=1),但这句是在撒谎
  • 卡片连同撤销按钮一起被替换掉(实测 cardCount=0)
  • 而 buildWebuiDiffMutationRequest 首行 if (state.busy || state.unsupported || …) return undefined(旧 :66)让之后每一次请求都被拒 —— 不是失败一次,是永久死卡

「新构建 → 原因入 DOM、卡片存活、按钮仍可点、关掉告警后重试真的再发一次请求(revertCalls 1→2)」。建议把这句改准,因为它决定了别人有多认真对待这个修复。

你可能担心的后端形状:没漏

409 走的是 assertMutationSucceeded 抛错,不是 {success:false}。我专门查了这条 —— 你的 thrown 分支接住了,实测新构建带出 WebUI request refused,旧构建同形状直接误报成能力缺失。

成功路径没被弄坏

  • 浏览器实测 {success:true,turnDiff:reverted} → state=reverted,按钮变「重新应用」,无告警条
  • mutationError 是可选成员,WebuiDiffState 的消费者只有 DiffCard 自己和既有验收测试
  • 变异测试:把核心那行改回 unsupported: true,3 条用例立刻红 —— 你的测试真能咬住回归,不是摆设
  • run-vitest-suite webui:78 文件 / 1590 用例全绿
  • +293 测试 vs +76 组件 / +12 契约 / +46 CSS,比例健康

一个不阻塞的建议

test/webui-browser/fixture.mjs 目前对 diff 能力零支持 —— 我这次是自己在 setup 里套了一层 WebSocket 才跑起来的。E 区后面还要测 diff 的成功态、空态、多文件,建议给 fixture 补一个 getTurnDiff / revertTurnDiff 分支,省得下一个人重复造。这个 PR 里可以不加。

我唯一没验的

confirm 点「取消」的分支。Playwright 只能 accept/dismiss 整个 dialog 事件,覆盖不了「用户点了取消」这个语义。要么接受这个盲区,要么留给手工验一次。

另外说明一下我的探针边界:我所有浏览器结论都基于仓库的合成夹具,没有跑过真实 mcode 后端。夹具里那个 changeSetId: "" 的静默分支真实后端产不出来(ports.ts:236 是必填 string),所以我没拿它当产品行为证据。

…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.
…erson can act on

Closes the server half of roadmap E 区's Revert / Reapply row — by correcting
what that half actually is.

The roadmap records the defect as "服务端以 `plan-not-safe` 跳过工作区外文件
恢复" and asks for "UI 提示和服务端判据". Both parts of that are wrong against
the current tree, and the difference matters for what gets built:

1. There is no partial skip. `applyLocalTurnDiffSnapshotMutation` walks
   `record.undo` and, the moment `safeCapturedPath` refuses one file, returns
   `{ success: false, reason: 'unsafe_path' }` — before writing anything. The
   operation is all-or-nothing. `applyGitPatch` is the same shape: `git apply`
   exits non-zero and nothing lands. So there is no skipped-file list to
   surface, and no partial-success state to represent.

2. The token is `unsafe_path`, not `plan-not-safe`; the string does not occur
   anywhere in the repository.

3. The server already reports a truthful, machine-readable reason, and it
   reaches the client intact: `mutateLocalTurnDiff` puts it in `body.error`,
   v2's `assertMutationSucceeded` throws an `AppError` carrying it, and the
   transport rejects. What was missing was not a server field — it was the
   last step, turning a token into a sentence.

So this adds `describeWebuiDiffFailure`, which maps every reason the mutation
path can produce to copy that says what happened and what to do:

- `unsafe_path` — a captured file is outside the workspace, so nothing was
  touched. Worth naming precisely, because it is a safety refusal the user
  cannot work around by retrying.
- `conflict` — the file changed after the turn. The token does not say *when*,
  and that is the part that tells the user to look before retrying.
- `not_undoable` — the turn left no snapshot to undo.
- the three literal messages `mutateLocalTurnDiff` returns.

A reason outside the table is passed through unchanged, deliberately: `git
apply` failures arrive as free-form stderr, and replacing a real message with a
guess would be worse than showing it.

This also let the resolver lose a branch. It used to read `error` off the
revert result only, because `WebuiReapplyTurnDiffResult` was believed to have
no such field — it does, and both result shapes carry one. The action-agnostic
`result?.error` is now correct for both, and the "reapply errors are dropped"
mutation can no longer be written at all.

Validation
- pnpm check:source                       exit 0
- pnpm typecheck:webui-full               exit 0
- pnpm build:webui                        exit 0
- run-vitest-suite.mjs webui              9 failed | 1642 passed | 4 skipped
- playwright test test/webui-browser/     79 passed

The 9 failures are the unchanged pre-existing Windows baseline.

Test evidence
diff-mutation-error.test.tsx gains 5 tests (22 total) covering the mapping, the
passthrough, the no-reason cases, and that a thrown `unsafe_path` is translated
the same way as a reported one — the two reach the resolver by different paths
and used to be easy to translate in only one.

Negative injection: 18 mutations, 0 survivors, implementation restored
byte-for-byte. Four are new for this seam — showing the raw token, dropping the
`unsafe_path` entry, discarding an unknown reason instead of passing it through,
and skipping the trim before matching a code.

Two entries in the injection script went stale and were reworked rather than
counted: an earlier version of the harness scored a missing anchor as a
survivor, which reports a script-maintenance problem as a test-quality problem
and hides the real failures. A stale anchor and a surviving mutation are now
reported separately, and the exit code depends only on the latter.
antianqi and others added 2 commits October 5, 2026 17:49
Roadmap E 区's review page refused to do anything until the selected session
already carried a workspace, and said so:

    先打开一个工作区,再来审查它的变更。

That names the problem and offers no way out of it. The route it implies —
go select a different session — is not reachable from the page: the settings
dialog is `aria-modal`, so the rail cannot be clicked while it is open. The
user is parked in a state they cannot leave, which is the same shape of defect
the two disabled tabs had before this branch built pages behind them.

The gate itself is correct and stays: `workspaceDir` is the selected session's
workspace, and it is empty whenever that session is not bound to a project,
including the default workspace, which the rail groups under 「未选项目」
regardless of whether it carries a path (SessionRail.tsx:304). Reviewing needs
a real checkout to ask about. What was missing is the way to supply one.

`WebuiReviewWorkspacePicker` lists the checkouts and takes a click. It is
presentational like `WebuiReviewPanel`, so the list is assertable without a
DOM, and it reuses `groupWebuiWorktreeWorkspaces` rather than grouping again —
one checkout spelled `C:\repo\x` by one session and `C:/repo/x` by another is
one row, because the worktree page already got that right and the two surfaces
should not disagree about what a checkout is.

The choice is an override of the prop, not a replacement for it. Folding the
pick into the prop would leave the page looking unchanged until the user went
and changed session, which is the thing this is fixing. It is component state,
so it resets when the dialog remounts — deliberate, since a stale "last
reviewed workspace" would quietly shadow the session the user is looking at.

Nothing changes for a page opened from a workspace-bound session: the prop
wins, and the session list behind the picker is not even fetched.

Validation
- pnpm check:source                          exit 0  (4717 files)
- pnpm typecheck:webui-full                  exit 0
- pnpm build:webui                           exit 0
- run-vitest-suite.mjs webui    9 failed | 1653 passed | 4 skipped
- playwright test test/webui-browser/          79 passed

The 9 failures are the unchanged pre-existing Windows baseline, same names:
7 webui-boundary-check path assertions, 1 webui-design-tokens path assertion,
1 webui-service shutdown timeout.

Test evidence
review-panel.test.tsx gains 10 tests (27 total): six on the picker's markup and
four on the wiring from a click to the review load.

Red before the implementation: all 10 failed against a stub that rendered an
empty div, and the wiring assertions named the missing pieces — no
`loadSessions`, no `workspaceDir?.trim() || pickedWorkspaceDir`, no
`effectiveWorkspaceDir` in the summary call.

Negative injection: 12 mutations across the picker and the wiring, 0
survivors, implementation restored byte-for-byte. The first run found four
real holes, which is the point of running it:

- The "nothing to review" explanation was only asserted in the state where it
  is correct to show it. Deleting `workspaces.length === 0` from the
  condition left the suite green — the message could sit above a full list.
  There is now a test that it is absent when there is something to pick.
- "shows the full path" asserted on the whole markup, and every option carries
  the path in `data-webui-workspace-dir` too, so swapping the displayed text
  for the basename still passed. It now reads the visible paragraph.
- `loadSessions={loadSessions}` was asserted unscoped, and the worktree page
  is handed the same loader, so removing it from the review page alone left
  the assertion satisfied. It is now matched within the `<SettingsReviewPage>`
  call.
- The branch that renders the picker was never asserted at all. Replacing
  `if (!effectiveWorkspaceDir)` with `if (false)` passed everything, because
  the component was only ever tested in isolation and never reached from the
  page.

The harness itself had a reporting defect carried over from the earlier runs:
an all-green summary line does not match the `failed | passed` form, so the
detail column read "all 0 still green" for a suite of 27. The verdict reads
failedCount and was never affected; only the report was misleading. Fixed
here, and a stale anchor stays STALE rather than counting as a survivor.

Scope: the entry state of the E-area review page. The line jump, the search,
the diff batching and the per-file outcomes are untouched.
…ing was touched

The user-facing copy for `unsafe_path` said the failing file was outside the
workspace and that it was left alone for safety. Neither is what the runtime
does, and both halves were wrong in the direction that matters most:

- `normalizeCapturePath` *accepts* a path resolving outside the workspace
  (file-changes.ts:657 returns the absolute path). `undefined` comes back for
  in-workspace paths the capture layer filters — `.git/`, `node_modules/`, the
  root itself. So the escape case is not refused, it is written.
- `safeCapturedPath` is checked inside the write loop of
  `applyLocalTurnDiffSnapshotMutation` (file-changes.ts:405-416), after earlier
  entries have already been `writeFile`'d or `fs.rm`'d. The run is not
  all-or-nothing.

Measured on this branch: a captured `../outside/secret.txt` reverts with
`success: true` and the file lands outside the workspace; a preceding entry in
the same batch is deleted and stays deleted when a later entry trips
`unsafe_path`.

Say what happened — the run was interrupted and earlier files may have
changed. The comments above the copy table and the test block carried the same
two false claims and are corrected to match the code.

This changes copy only. The escape-accepting `safeCapturedPath` and the
mid-loop check are real defects and are left for a separate fix; the commit
message on the original commit claims the operation refuses before writing,
which it also does not.
@modacker

modacker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

文案修正推到本分支了(3222a14),只改 copy,没碰运行时逻辑。

为什么改

unsafe_path 的界面文案是「这轮改动里有文件不在工作区内,出于安全没有动它」。两句话都与运行时实际行为相反。

读了实现,两处:

1. 逃逸路径不是被拒,是被写。 normalizeCapturePath 第 657 行 if (absolute !== root && !absolute.startsWith(prefix)) return absolute; —— 解析到工作区外的路径,直接把绝对路径返回。返回 undefined 的是落在工作区内但被采集层过滤的路径(.git/、node_modules/、等于根目录)。所以 safeCapturedPath 失败 ≠ 逃逸被拦。

2. 不是「全有或全无」。 applyLocalTurnDiffSnapshotMutation 第 405–416 行,safeCapturedPath 的判断在写循环体内,前面的条目已经 writeFile / fs.rm 完了才轮到它返回 reason: 'unsafe_path'。

本分支实测:

  • 捕获到的 ../outside/secret.txt 撤销返回 success: true,文件真的落在工作区外
  • 同一批里前一个条目被删掉后,后一个条目触发 unsafe_path —— 删掉的没回来

原 commit message 里「写入前返回、全有或全无」也是同样的假。

改了什么

  • DiffCard.tsx 的 WEBUI_DIFF_FAILURE_COPY.unsafe_path 一行 copy
  • diff-mutation-error.test.tsx 四处断言的期望串(304 / 314 / 322 / 334)
  • 两处注释:它们复述了同样的两个假说法,一并按代码改正

新文案:「这轮改动里有文件的路径无法安全定位,操作已中断,之前处理过的文件可能已改动。」

验证

  • node scripts/run-vitest-suite.mjs webui → 1660 passed / 6 skipped,0 测试失败(唯一 FAIL 的文件是 webui-design-tokens.test.ts,缺 dist-webui/client/styles.css 构建产物,与本改动无关)
  • typecheck:test 改动前后同为 17 条既有报错,零新增(那 17 条在 third_party/pi-mono,是本 worktree 符号链接 node_modules 造成的解析问题,基线一致)
  • 旧文案串全仓零残留

两个真实缺陷我没动,建议单开 issue

  1. safeCapturedPath 不做包含性检查 —— 现有单测只证明 ../outside/... 这个构造能过,没有评估可利用性。这是本地进程执行面上的事。
  2. safeCapturedPath 在写循环体内检查 —— 提到循环外做预检就能让它真的「全有或全无」,代价是要改 file-changes.ts 的行为和对应测试,超出这次授权范围。

@antianqi 这两件要不要做、按什么优先级,等莫克排。

@modacker
modacker merged commit e35940c into webui Oct 5, 2026
8 checks passed
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.

2 participants