Skip to content

feat: expose Space review auto-approval policy - #1624

Open
l-s-c wants to merge 52 commits into
Mininglamp-OSS:mainfrom
l-s-c:codex/space-review-auto-approve
Open

feat: expose Space review auto-approval policy#1624
l-s-c wants to merge 52 commits into
Mininglamp-OSS:mainfrom
l-s-c:codex/space-review-auto-approve

Conversation

@l-s-c

@l-s-c l-s-c commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Expose Space review auto-approval controls to authorized owners/admins and harden all affected review/asset UI against cross-Space stale state.

Related Issue

Refs #1623

Changes

  • add review-policy API mapping and owner/admin configuration UI
  • align member/admin/owner role behavior with the authoritative role encoding
  • clear and generation-guard review queue, policy, contacts, and all-assets state across Space switches
  • prevent stale approve/cancel/reject/delist/delete continuations from mutating a new Space
  • render frozen expert/expert-team relations so reviewers see the graph being approved
  • add deterministic unit and E2E mock coverage

Architecture / Module Boundary

  • Affected module(s): dmworkskillmarket, dmworkmcp, dmworkcontacts, dmworkbase, web E2E handlers
  • New or changed user-visible entry point: yes — Publication management policy switch and relation details
  • Shared layer touched: Components and marketplace/MCP service boundaries
  • If shared code changed, impact scope: Space role display/management and Space-switch state isolation
  • Duplicate entry point checked: yes

Testing

  • Unit tests added/updated
  • Manually verified

Commands:

  • pnpm --dir packages/dmworkbase test -- --run
  • pnpm --dir packages/dmworkskillmarket test -- --run — 255 tests
  • pnpm --dir packages/dmworkcontacts test -- --run
  • pnpm --dir packages/dmworkmcp test -- --run — 279 tests
  • pnpm i18n:check
  • pnpm build
  • git diff HEAD^ HEAD --check

Existing React act(...), Vite dynamic-import/chunk, and third-party lottie eval warnings remain; all commands pass.

Checklist

  • I have read CONTRIBUTING.md
  • PR description is in English
  • Added tests for my changes
  • Updated documentation
  • Ran pnpm i18n:check
  • Confirmed the change follows module ownership and does not add duplicate user-visible entry points
  • Described impact scope for shared components/services
  • Followed Conventional Commits

COMPREHENSION

  1. What does this change actually do?
    It loads the Space review policy and authoritative role for Publication management, limits policy changes to owners/admins, invalidates all tenant-scoped UI state before loading a new Space, and displays the frozen relation graph included in a review snapshot.

  2. What could break?
    Late old-Space requests or mutations could disclose rows, show privileged controls, or send old IDs with a new Space header; omitting frozen relations could let reviewers approve unseen membership changes. Generation guards, synchronous clearing, action-continuation checks, and relation-detail tests cover those failures.

  3. How do you know it works?
    Affected package suites, production build, and i18n checks pass. New regressions cover review queue and asset-list Space switches, stale action completion, policy request generations, role visibility, API relation mapping, and relation rendering.

lsc and others added 30 commits September 2, 2026 07:57
Uploading a skill used to publish it to the whole org immediately. The backend
now lands every tenant create as a private draft and refuses to modify a listed
plugin through the ordinary write path, so the market needs a UI for the review
workflow that replaces it.

- Publish scope is an explicit choice in NewSkillModal: keep it private, or
  submit for org review with an editable version label (pre-filled with a
  suggested bump) and changelog. A skill upgrade with a fresh zip submits via
  parse_task_id so the backend materializes the package itself.
- New 组织审核 queue (SpaceReviewPage / ReviewQueue) with 待审核 and 已处理
  sub-tabs, a detail drawer carrying the frozen snapshot preview and the
  "current vX → requested vY" callout, and a reason-required RejectReasonModal.
- MineTable derives per-row状态 badges and actions (提交审核 / 撤回 / 重新提交 /
  发布新版本) by joining the mode=mine request list onto the skill list.
  Review state stays an independent entity — no review column on Skill and no
  new Visibility member — because a listed v1 and an in-review v2 coexist.
- useSpaceRole reads space_roles from the auth context to decide whether to
  show the queue. The gate is cosmetic: the server is authoritative and answers
  403/404 regardless.
- useReviewRequests plus the six review endpoints in skillApiReal, mirrored in
  skillApiMock, with the snake_case wire shapes in pluginWire.
- i18n keys for both locales; SpaceReviewPage is re-exported from index.tsx so
  dmworkmcp can mount it without reaching into internals.
The Space review workflow covers every plugin type, so the MCP and expert
markets need the same private-draft lifecycle the skill market just got.

- Creates default to visibility "private" instead of publishing straight to the
  org; going org-wide is now a review submission.
- ReviewSubmitModal and useMyReviewState give connectors, experts and expert
  teams the submit / withdraw / resubmit / publish-new-version actions and the
  matching status badges, funnelled through api/pluginReview.ts so the coupling
  to the skill market's contract stays in one file.
- Submitting a container (expert / expert_team) sends its relation graph with
  the request, including per-member data keys, so the reviewer approves the
  membership that will actually ship rather than whatever the live graph held
  at approve time.
- MarketSidebar mounts the shared 组织审核 entry (page owned by
  @dmwork/skillmarket) and reveals it once the role probe resolves; module.tsx
  routes it alongside the skills tab so both markets share one NavRail entry.
- i18n keys for both locales.
Follows the octo-marketplace listing_state change. The market UI had three
problems this fixes together, because they share the same vocabulary.

**Publishing is one button.** The create form's 发布范围 radio ("提交组织审核" /
"仅自己可见(私有)") encoded a routing decision the server owns, and threw the
author's actual intent away — every plugin was created `private` regardless of
what they picked. It is now a real 可见范围 choice that is STORED, and the footer
offers 保存草稿 and 发布. 发布 calls /plugins/publish and the backend decides from
the stored visibility whether that lists immediately or opens a review; the
response says which, so the toast does not guess. 提交审核 / 重新提交 /
发布新版本 collapse into 发布 and 升级版本.

发布 is also a row action, not only a modal footer. Connectors are authored
through a wizard and experts through a bot flow, so "open the editor just to
press 发布" would be the only way to publish three of the four types.

**Status comes from the server.** `display_status` replaces a five-value badge
that three pages each re-derived from a client-side join of the review list, and
disagreed about: a listed plugin with a pending upgrade read 已上架 in one place
and 审核中 in another. The join survives only to look up the rejection TEXT,
which is a property of the request and has nowhere else to live.

**One table, one vocabulary.** 我的发布 was a 5-or-7 column grid (connectors hid
the counters) and 组织审核 was a headerless flex card list; they shared no markup
or CSS. Both now render the same 9-column MineTable — 名称/描述/分类/版本号/
可见范围/浏览量/下载量/状态/操作 — with 描述 and 状态 promoted to real columns.
utils/labels.ts becomes the single place an enum becomes text, which is what
stops 专家团/专家团队, 待审核/审核中 and 已上架/已通过/已发布 from naming the same
things differently depending on the page. The retired keys are deleted, not left
to rot; zh and en stay at parity (258 / 352 keys each).

Also: 组织审核 gains 下架 on a still-listed approved request, gated on the
plugin's current listing state rather than the request outcome, so it is not
offered on something already down. 我的发布 gains an 全部 tab over the
plugin_type-optional listing — the only view that answers "what am I waiting on"
without visiting four tabs. It is read-only by design: 编辑 and 升级版本 need the
owning market's modal, and reproducing that dispatch would guarantee drift.

The skills tab's four review buttons previously rendered aria-label={undefined}
because the aria keys lived only in the mcp namespace; they are in the shared
namespace now and every action carries one.

Tests: skillmarket 208/208, dmworkmcp 261/261. Fixtures that expressed "listed"
as visibility alone now say listing_state too, and gained the cases the old model
could not represent (published-private, space-intent draft, delisted — all
editable). One of those new tests caught a real bug: a single canCreate gate
disabled BOTH footer buttons when 仅本组织可见 had no changelog yet, making 保存草稿
unreachable for exactly the author who most needs it.
…ry market

SkillListPage still passed `skillMarket.card.*AriaLabel` (编辑 X) while the
connector and expert pages passed the shared `skillMarket.plugin.aria*`
(编辑「X」). One MineTable affordance, two accessible names depending on which
market you reached it from — the exact inconsistency the shared label module
exists to remove, missed on the one page that predates it.

`skillMarket.card.*AriaLabel` stays: SkillCard and SkillDetailModal are a
different affordance and legitimately keep their own wording.
In the table the value sits next to a lock / building / globe icon inside a
pill, so 仅自己可见 spent three characters restating what the column header and
the icon already say, and made the pill wide enough to crowd the row.

仅自己 / 本组织 / 全平台. The create-form radio uses the same key and still reads
clearly, because each option has an explanatory hint underneath it.

The two test regexes that located those radios spelled the old strings out; they
now match the new ones. Everything else reads the label through
utils/labels.ts, so this is a one-value change per locale.
The four type tabs mount a market page whose own content area supplies the
scroll container and the inset padding; the 全部 tab renders MineTable directly,
so it matched none of the panel's child selectors and the table sat flush
against the panel edges while every sibling tab was inset. It now has a root of
its own in that selector list, padded to match .skill-market-content.

Avatars are drawn per type as well. A skill rendered here keyed its tile off the
connector helper (id-based, two CJK characters) while the same skill in the 技能
tab keyed off the name — so one row had two different faces depending on which
tab you found it in. Each type now uses its own market's helper.
The column header said 操作 over a cell that was always empty, because the tab
was built read-only.

The three actions that do not need a market's authoring surface are now wired:
发布, 取消审核 and 删除 each go through an endpoint that takes a plugin id and
nothing else, so one implementation covers all four plugin types. 编辑 and
升级版本 stay out — a connector edits through a wizard and an expert through a
bot flow, and reproducing that dispatch here would guarantee this tab drifts
from the four that own those flows. Clicking a row opens the tab that can do
them.

Rows disable themselves until the reload settles, so a second click cannot race
an in-flight action, and the list reloads after a failure too: a 409 means the
server already knows a state this page does not.
The edit modal had no visibility control and preserved the stored value with a
comment saying so. Under the old model that was honest — visibility could not be
raised through this path at all, so a control would have been a lie. It is a
declared intent now, freely changeable on an unlisted row, and 编辑(基础信息和
可见范围)was in the spec from the start.

The hint is the part worth having: widening the audience of a published plugin
un-lists it server-side, and an author who is not told beforehand reads that as
their plugin disappearing. It shows only when the change would actually do that.

Skills only. The connector wizard and the expert bot flow have no visibility
control either, and giving them one is a separate change against two very
different authoring surfaces.
An author met two different contracts depending on whether the plugin already
existed: the create modal offered 保存草稿 and 发布, the edit modal a single 保存
that left you to find 发布 somewhere else.

The secondary action is only labelled 保存草稿 when the save actually leaves a
draft behind — either the plugin is already unlisted, or widening the audience is
about to un-list it. On a published plugin whose audience is unchanged it is a
plain 保存, and 发布 is not rendered at all rather than sitting there permanently
disabled: there is nothing to publish, and the backend would answer 409
already_published.

发布 saves first, so what gets published is what the author just wrote, and the
toast comes from the response — the backend decides whether that meant listing it
or opening a review.
…dmin rows

Two fixes in the skill write forms.

**The leave-guard no longer stacks a dialog.** It put a scrim over a scrim, and
the dialog it stacked hid the very thing it asked about — "已上传的文件和填写的
信息将丢失" covered up the file and the information it named. The footer swaps in
place instead: one layer, the form stays visible, and the answer gets made while
looking at what is at stake.

It also asks a better question. The old copy said the work would be lost, which
stopped being true when 保存草稿 shipped — and was already false whenever the
create had succeeded and only the publish failed, since the plugin was saved. The
bar offers 保存草稿并离开 as the primary action, so the honest escape is the easy
one. Mid-upload there is nothing a save could keep, so that variant offers only
继续上传 / 确认离开.

**The visibility control no longer demotes admin-managed rows.** It narrowed the
stored value to "private" | "space" and always sent it, so editing a 全平台
(`system`) plugin — or a legacy `public` row — silently demoted it to private and
delisted it from everyone. The state holds the raw value now; the radio renders
only for the two audiences a tenant may choose, and an admin-managed one is shown
read-only and carried through the save untouched.

`.skill-market-form__hint` had no CSS rule anywhere, so the "changing the audience
returns this to a draft" warning — the one thing an author must read before
saving — rendered as plain body text. It has one now, shared with the expert form.

IconCropModal is also stacked and deliberately left alone: it is a task, not a
question about the dialog underneath, and a footer bar cannot hold a cropper.
createMcp hardcoded `visibility: "private"`, so the declared audience was thrown
away on every create — 本组织 was simply unreachable from the connector form, and
nothing downstream complained because the value it discarded is the one the
backend reads to route 发布.

The wizard gains the 可见范围 radio and the same 取消 / 保存草稿-or-保存 / 发布
footer the skill form uses, publishing through the one backend door that decides
from the declared visibility whether to list immediately or open a review. 发布
saves first, so what ships is what the author just wrote, and the toast comes
from the response rather than being guessed. The state-adaptive rule carries over
too: 发布 is not rendered when a listed connector's audience is unchanged, since
there is nothing to publish and the backend would answer 409 already_published.

A legacy `system`/`public` row keeps its stored audience rather than being
defaulted into one of the two the form can express — the same demotion bug the
skill form had.

The service test pins the regression this is most vulnerable to: a declared
"space" must reach the wire rather than being flattened back to private.
They had no client-side write form at all: 编辑 jumped straight to the Bot prompt,
which authors CONTENT through octo-cli. Visibility is marketplace metadata, not
content, so making an author round-trip a Bot to change who can see their expert
would have been absurd.

编辑 now opens a small form that owns exactly that one field, with the Bot handoff
still one click away for content. Hanging it off 编辑 rather than a new row action
is deliberate: the backend's gate on WRITING visibility (409 listed_requires_review
when published+space, review_pending while a request is open) is verbatim the gate
on editing, so reusing the affordance means the two cannot drift. Folding it into
ReviewSubmitModal was rejected for the opposite reason — that dialog is reachable
only via 升级版本, i.e. exactly the population forbidden from changing visibility,
so a private draft would never have seen the control.

The load-bearing part is the write. There is no visibility-only endpoint, and
/plugins/upsert is a full replace: an omitted relation is soft-deleted. So the
save reads the record back and echoes it whole with one field changed — manifest
and package verbatim, relations carrying their `relation_id` AND `data`, or a
squad silently loses the member_key/is_leader wiring that makes it installable.
Verified against the live API on an expert with relations: ids and data survive
the round trip intact. The save is skipped entirely when the value is unchanged,
because every upsert mints a version snapshot server-side and a no-op save would
record a version that changed nothing.

A platform-published row shows its audience read-only rather than defaulting into
one the tenant path would accept, which would demote an official listing into a
single Space.
**A retry after a failed publish no longer drops what was fixed.** Both write
forms remembered the id from a create whose publish then failed, so a second
press would not mint a duplicate — but they also SKIPPED the write entirely on
that remembered id. Anything the author changed after the failure, usually the
very thing that caused it, was silently discarded while the toast reported the
draft saved. The retry now updates the row it already created. The
no-duplicate property is unchanged; only the missing write is added.

**A connector publish that opens a review now carries a changelog.** The wizard
collected one only in 升级版本 mode, so an organization review arrived with an
empty 更新说明 — the text the reviewer decides on. It is asked for exactly when
the declared audience is 本组织, which is the branch that opens a review; a
仅自己 publish has nobody to explain anything to. 发布 is gated on it, 保存草稿 is
not, so an author who is not ready to describe the change can still save.

**ReviewSubmitModal had class names and no rules at all** — the notice, both
fields, the frozen-children line and the error all rendered as undifferentiated
body text. That dialog is the 发布/升级版本 surface for three of the four plugin
types.
The 操作 track was `minmax(140px, max-content)`, but the header and every row are
SEPARATE grid containers, so a content-sized track resolves independently in each
one. A row holding 通过 + 拒绝 got a wider 操作 than the header holding the word
操作, and every column to its left shifted to compensate. Across tabs it was
worse: 全部 has no row actions at all, so its whole grid resolved differently from
技能's — which is what "表头还是不一样" was. A fixed 180px clears the widest real
combination (two labelled buttons, or one plus the 编辑/删除 icon pair) and makes
the layout independent of content.

`.wk-mcp-mine__all` also had no background. The four market pages carry
`--wk-bg-surface` on their own root, so the 全部 panel sat on the base colour
while its siblings sat on the surface one.
… server

Mirrors the backend rule so a bad label is objected to before the round trip:
three numeric parts (1.0.1), and only forward or unchanged. Parts compare
numerically — 1.10.0 follows 1.9.0, which a string comparison gets backwards.

One helper, four inputs: create, edit, the connector wizard's 升级版本 step, and
the shared ReviewSubmitModal that experts and expert teams submit through. Each
compares against the label the SERVER will compare against — the stored one — so
the form cannot disagree with the response. The message renders under the input
rather than in the form-wide banner, which has no way to point at a field.

The two exemptions are mirrored too: an unchanged label always passes, and an
unorderable stored one cannot block a well-formed replacement. Without them the
form would refuse to save the rows carrying labels minted before the format was
tightened, which is the same trap the server-side rule had to avoid.

The server remains the gate; this only saves a 400.
…ate today

The 已处理 status column answered two different questions at once. It took
pending/approved/rejected from the REQUEST, but let a later 下架 overwrite an
approval — so a decision appeared to change months after it was made, and two
approvals of the same plugin rendered identically, saying nothing about either.
A rejected record meanwhile kept reading 驳回 after the author had fixed and
republished: the same column following live state for one outcome and history for
another.

A row in 已处理 is a record. It now says what was decided — 已通过 / 驳回 /
已撤回 — and nothing else. The plugin's current listing state is still read, but
only to gate 下架, since you cannot take down what is not up.

MineTable gained an explicit statusLabel/statusTone override for this. The
alternative was merging review outcomes into PluginDisplayStatus, which is the
conflation that caused the bug: 已通过 is not a plugin state, and a plugin state
is not a decision.
The page stopped being only a review queue when it gained 下架: an admin now
approves, rejects and delists from it, which is publication management rather
than review alone. English follows as "Publication management", sitting next to
the existing "My publications" sibling.

Two keys carried the label, not one — the sidebar entry (mcp.sidebar.review) and
the page title, which the review table also uses as its accessible name
(skillMarket.review.orgTab). Moving one without the other would have made the
sidebar and the page it opens disagree.

Only the MENU label changes. The route, the component names, the API and every
other 审核 string — 待审核, 审核中, 取消审核, 提交审核, 审核人 — stay: the underlying
workflow really is a review, and renaming it would be a far larger and riskier
change for no user-visible gain. The two comments that quoted the old label as UI
copy were corrected, and the page's doc block now records why the label diverges
from the code's wording so the next reader does not "fix" it.
The 操作 column was right-aligned inside a track much wider than any real
button set, so the header word sat hard against the band's right edge with a
dead run of space between 状态 and it. 我的发布 全部 showed this worst — most of
its rows carry one or two buttons — which read as a broken header rather than
as a column. Left-align the cell (the header shares the class) so the label
sits directly over the buttons it names, matching the other eight columns.

Size the track at a fixed 160px at every breakpoint instead of 180px / a
content-sized max-content. Measured in a browser, the widest combination any
page wires is 通过 + 拒绝 at 155.3px in en-US; zh-CN's widest is
发布 + 编辑 + 删除 at 127px. The two content-sized breakpoints resolved the
header and each row independently: at 760px the header's track came out 96px
while rows came out 122–127px, tearing the columns apart, and rows carrying
different actions disagreed with each other.

下架 gains the mirror of the Upload arrow that marks 发布/升级版本, so the one
labelled action that used to render bare now reads like its siblings.
The sidebar's pending-count badge and the 待审核 list were two independent
reads of the same queue with no link between them. The badge is its own
`useReviewRequests` (a page_size=1 probe mounted by MarketSidebar's
<ReviewGateProbe />) and fetched only on mount; ReviewQueue refreshed its
own list after a decision and told nobody. So 通过 / 拒绝 emptied the list
while the badge kept showing the count from page load, and only a full
reload corrected it.

The badge cannot simply read the queue's state — it has to render while
ReviewQueue is unmounted (the user standing on 技能 / 连接器 / 我的发布),
so two reads are necessary. What they can share is the moment both become
wrong, and that moment is a review mutation. `api/reviewSignal.ts` adds
that signal and `api/skillApi.ts` attaches it to the endpoints themselves
rather than to their callers, so approve / reject / cancel / publish /
delist / plugin delete all invalidate whether they are invoked from the
queue table, the detail drawer, the applicant's own rows in three market
pages, or anything added later. Every live `useReviewRequests` re-reads.

No polling: every event that moves this Space's count in this session goes
through those endpoints. A refused call invalidates too — a 409 from a
decision endpoint is precisely the case where our copy is already stale.

Verified end to end with a new e2e case (RB1): with the fix reverted the
badge stays at 1 while the queue empties; with it, the badge clears on the
same page instance.
我的发布's chrome — the page title and the 全部/技能/连接器/专家/专家团 tab strip —
carries no responsive rule: it stays inset by var(--wk-sp-6) at every width.
Four of the five tabs' content areas hold that inset. The 技能 tab did not:
`.skill-market-content` drops to a flat var(--wk-sp-3) under 760px, so below
that width the 技能 table alone rendered 12px wider on each side than 全部,
连接器, 专家, 专家团 and than the tab strip directly above it, visibly stepping
out from under its own tabs.

Measured in Chromium at 740px before the fix: the tab strip's content edge is
x=380 and the 全部 table is x=380 w=336, while the 技能 table is x=368 w=360.
At 1600 / 1280 / 900 every tab already agreed, which is why this only shows up
on a narrow window.

The 12px is right where the rule was written — the standalone 技能市场 route,
a phone, no hero and no tab strip to line up with — so it is scoped rather
than removed: `.wk-mcp-mine__panel .skill-market-content` re-pins the inset
inside the 我的发布 panel only, next to the existing `.skill-market-topbar`
override that already re-chromes the hosted page there. /mcp-market/skills
standalone still measures 12px at 740 and 640.

Pinned unconditionally rather than in a matching media query: above 760px this
is already the computed value, so it is a no-op there, and stating it once
keeps the panel aligned if the skills page adds another breakpoint later.
…selves

`withReviewInvalidation` moved review-queue invalidation onto the seven
mutating endpoints, so every live `useReviewRequests` re-reads the moment one
of them settles. Eight call sites still poked their own review hook afterwards,
which is not free: `refresh()` aborts the in-flight fetch the signal just
started and issues an identical one, so each publish / cancel / listing outcome
made two `GET /plugins/review_requests?mode=mine&page_size=100` round trips and
threw the first away.

Observed at 我的发布 → 技能 in the e2e-mock browser before:

  POST /plugins/publish
  GET  review_requests mode=space status=pending page_size=1   (sidebar badge)
  GET  review_requests mode=mine  page_size=100                (aborted)
  GET  plugins?mode=mine
  GET  review_requests mode=mine  page_size=100                (the duplicate)
  GET  review_requests mode=mine  status=pending page_size=1

and after, without the fourth line. The row still moves 草稿 → 审核中 → 草稿
across publish and 取消审核 on all three pages, because what drives the row's
status and its 取消审核 button is `display_status` / `review_id` on the PLUGIN —
`list.refresh()` / `loadData()` / `reload()`, which stay. The review read feeds
only the rejection-reason tooltip, and the signal keeps that current.

Left alone deliberately:
  - the three `space-changed` handlers — a Space switch is not a mutation and
    `useReviewRequests` keys its fetch on mode/status/pageSize only;
  - `SkillListPage.handleCreated` — NewSkillModal's 保存草稿 branch reaches only
    `createSkill` / `updateSkill`;
  - `ExpertMarketListPage.handleListingChanged` — ExpertEditModal's 保存草稿
    branch calls only `updateExpertVisibility`, and a narrowed audience can
    un-list the record and close an open request. Commented so it does not look
    like an oversight next to its neighbours;
  - all of `ReviewQueue`, which owns private fetchers rather than
    `useReviewRequests` and is therefore not a subscriber at all.

Verified: RB1 passes, 14/15 of the skills + experts + mcp e2e suites pass with
the one failure (C40, connector delete) failing identically on the parent
commit; dmworkskillmarket 229/229 and dmworkmcp 270/270 unit tests pass; tsc
per-file error counts unchanged in both packages.
The backend tightened plugin version labels to x.y.z and added a
grandfathering exemption for the labels that reached production first
(`1.0`, `v1.2.3`, `2.0.0-beta.1`, `v999`, `1.0.0lll`): a malformed label
is accepted when it is byte-equal to the one the ROW already stores.
`versionErrorKey` mirrored the format and ordering rules but not the
exemption, and EditSkillModal seeds its version field from exactly that
stored label — so every legacy-labeled skill opened with a value its own
client rule rejected, `canSave` was false, and Save was permanently dead
with no way forward. The exemption was unreachable from the browser.

`versionErrorKey` now takes the stored label as a separate argument and
exempts it the way the server does — byte equality modulo surrounding
space, keyed on the fetched row, never on anything typed into the form.
A DIFFERENT malformed label stays refused, because the server refuses it
too; permitting it would only move the dead end to the response.

Two rules deliberately do not change. Forward-only still refuses a
downgrade between two well-formed labels, and an unparseable current
label still blocks nothing — that is what lets a row stranded on `v999`
be repaired to a real version.

发布 does not inherit the exemption: on 本组织 it routes through
SubmitReview, which gates on validVersion with no grandfathering. It is
gated separately and says why, so it does not become the next dead
button. On 仅自己 the server ignores the submitted label entirely.

`bumpPatch` no longer mangles a legacy label after a reupload. Bumping
the last dot-part of `v1.2.3` produced `v1.2.4` — neither well formed nor
the stored label, the one combination /plugins/import refuses outright.

Verified in the e2e-mock browser: all five legacy labels went from a dead
保存草稿 to a save that puts the stored label on the wire, `9.9` stays
refused, and `2.0.0` → `0.0.1` stays refused.
NewSkillModal's review mode computed `versionError` and rendered it under
the version field, but `canCreate` never consulted it — only the create
branch did. So the form could say "版本号格式为 1.0.1(三段数字)" and let
提交 through anyway, straight into a 400 from SubmitReview.

The seed made that reachable rather than theoretical: the local
`bumpPatch` bumped the last dot-part of whatever the row stored, turning
`v1.2.3` into `v1.2.4` and `2.0.0-beta.1` into `2.0.1.1`. SubmitReview
gates on validVersion with no grandfathering, so a review submission has
to be well formed even when the plugin's stored label is not. `nextPatch`
falls back to 1.0.0 for anything unorderable, which the forward-only rule
accepts precisely because the stored label cannot be compared.

The exemption added for the save path is deliberately NOT passed here.
This modal only ever writes through POST /plugins/review_requests.
Deleting a plugin settles its pending review request in the same
transaction server-side (octo-marketplace `cancelPendingReviewFor`,
reason "plugin deleted"), because a request whose plugin is gone can be
neither seen nor decided by anyone. The Space's pending count therefore
really does drop on a delete.

`deleteSkill` has been wrapped for exactly this since the invalidation
moved to the endpoints. `deleteMcp`, `deleteExpert` and `deleteSquad`
hit the SAME `POST /plugins/delete` from this package's own api modules,
so they went unwrapped and their callers refreshed nothing: deleting a
connector or an expert that had a request open left the 组织发布管理
badge and the 待审核 list counting a plugin that no longer existed.
Reproduced in the browser before the fix — the row disappeared and the
badge sat on 1.

Wrapped on the endpoint, not on the ~4 call sites, for the reason
reviewSignal.ts already gives: the row's 删除, the detail modal's inline
confirm and whatever surface comes next all inherit it without knowing
it exists.

The import is a deep one on purpose. `withReviewInvalidation` has to be
the same module instance `useReviewRequests` subscribes to, but these
two files must not reach the @dmwork/skillmarket barrel — the suites
covering them mock only `axios` + `@octo/base`, which is why
api/pluginReview.ts exists as a separate bridge. The subpath resolves to
a leaf module with no imports of its own, so it costs their graph
nothing.
The badge counts pending requests in the ACTIVE Space — every read
carries `X-Space-Id` — but nothing made it re-read when that Space
changed, so the previous Space's number stayed on screen.

Three things had to line up for this to be reachable, and they did:
`useReviewRequests` keys its fetch on mode/status/pageSize, none of
which a switch moves; `enabled` is `isReviewer`, which an owner
switching to another Space they own leaves true, so the reviewer gate
does not incidentally clear it; and the sidebar, unlike the market pages
in the right pane, is never remounted by the switch — MarketSidebar
replaces only the right pane. Reproduced in the browser first: after the
switch no `review_requests` request was issued at all and the badge held
its old count, three runs out of three.

Fixed with an explicit `refresh()` from a `space-changed` handler, which
is what SkillListPage, McpMarketListPage and ExpertMarketListPage each
already do. Deliberately NOT `notifyReviewsChanged()`: a Space switch is
not a review mutation, and telling every subscriber the queue moved
would be a different and false claim.
`onAction?: () => void` was invoked at five sites inside ReviewQueue and
passed by none of its mounts — SpaceReviewPage is the only one, and it
renders `<ReviewQueue mode="space" />`. Five decision paths announced
themselves to nobody while reading, at every call site, as if they were
wired.

Refreshing siblings is the endpoints' job now: every review mutation the
queue issues is withReviewInvalidation-wrapped, so the sidebar badge and
any other live useReviewRequests re-read on their own. Left a note on
the props interface pointing at that signal, so the next person who
needs to react to a decision subscribes instead of threading a prop back
through here.
…overflows

MineTable's hosts are scrolling column flex containers, so the table is a flex
item. Its own `overflow: hidden` (which clips rows to the rounded border) zeroes
the item's automatic minimum size, so a list taller than the viewport was shrunk
to fit instead of overflowing: nothing scrolled, the rows past the fold were
clipped away, and the 44px header — min-content 18px — absorbed the squeeze.

Reported as 我的发布's 全部 tab having a shorter header than 技能; 全部 lists all
four plugin types so it is simply the tab that overflows first. Measured at 40
rows / 1440x900: 全部 header 18px vs 技能 44px, and the 全部 scroller reported
scrollHeight == clientHeight with 30 rows unreachable. With flex-shrink: 0 both
headers measure 44px and the 全部 scroller reports 2677px against a 791px client.
The 待审核/已处理 strip painted its active tab and underline with
--wk-color-accent — the AI/link purple, reserved for AI-flavoured elements — so
it was the only tab strip in the market shell that did not render black. Switch
to --wk-brand-primary, the 选中态 token that 我的发布's 全部/技能/连接器/专家/专家团
strip and the expert strip already use, which also keeps dark-mode parity with
them (that token maps to the accent under body[theme-mode=dark]).

Measured at 1440x900, active tab: color/underline rgb(127, 59, 245) before,
rgb(28, 28, 35) after — identical to 我的发布's strip. Inactive rgb(85, 91, 97),
hover rgb(31, 35, 41) and the UA focus ring already matched and are untouched;
the active weight went 500 -> 600 to match, since every other underline strip in
the two market packages already bumps its active tab to semibold.
The MSW mock had no /plugins/review_requests handler, so the badge probe
fell through to the live proxy and the CI gate failed on the proxy error.
Add a mocked fallback that returns an empty pending set, and correct the
C40 delete-failure spec's stale aria label to match the rendered name.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
A Space change left the connector create/detail modal stack open over the
new org's list, and AllAssetsList never re-read after the switch. Close the
modal stack on space-changed and subscribe AllAssetsList to the same event.
Also guard onCancelReview so it only fires with a resolved review id.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web)

Reviewed at head 6783714, against merge-base bff055a (46 commits, 89 files, +12784/−789).

Scoping note. This branch is stacked on #1614 (head 7d478ba, already approved) but targets main, so the raw diff contains both. I reviewed the incremental delta 7d478ba..6783714 (45 files, +1244/−145) in depth, and treated the #1614 portion as covered by its existing approval since 7d478ba is a direct ancestor here. See Q1 for why the base branch itself needs a decision.


1. Spec compliance

Spec: ❌

Checked against #1623's acceptance list.

Missing

  • "Space switches cannot display stale member/role/policy state." The policy panel does not meet this. SpaceReviewPage.tsx:38 initialises enabled to true and loadPolicy() (:48) never resets it, so after a Space switch the previous Space's value keeps rendering while the new GET is in flight — and if that GET fails, loading goes false (:65) and the switch becomes interactive while showing a value that was never fetched for this Space. On first mount the same default means a failed load renders "自动审核 已开启" — the weakest posture — as though it were server truth. Detail in Q1.

    Note the contrast with the other surfaces in this same PR, which do satisfy the criterion: AllAssetsList.tsx:83-90 and McpMarketListPage.tsx:255-281 both clear their identifiers synchronously on space-changed. The policy panel is the one that does not.

Out of scope

  • useSpaceRole.ts:20 adds isSpaceOwnerRole, exported from the package barrel at index.tsx:19, with zero production consumers — only its own test. #1623 grants the policy to owners and admins, so no owner-only predicate is required. Please drop it or land it with its consumer.
  • Frozen-relation rendering (pluginWire.ts:164-172, ReviewDetailDrawer.tsx:239-262, types/skill.ts:97-105), the contacts role-badge module (Contacts/spaceRoleBadge.ts), the MCP all-assets isolation work, the e2e voice-seeding retry (settings-center-voice-support.ts:52-71) and the chat-baseline.ts MSW additions are all outside "policy API mapping + review-management UI". They may each be defensible, but they enlarge an already-stacked delta and are what makes this PR hard to land — see Q1/Q5.

Divergence — none material.


2. Code quality

Quality: Changes-Requested

First, the part that is right — please do not "fix" it

The role-encoding change at SpaceService.tsx:233-235 is correct, and it fixes a real pre-existing bug. I want to state the evidence explicitly, because reading only the diff makes it look like an inverted mapping was introduced.

octo-server is the service that answers GET /v1/space/my and GET /v1/space/:id/members:

  • modules/space/model.go:38Role int // 成员角色 0.普通成员 1.管理员 2.拥有者
  • modules/space/api.go:320 — the creator is inserted with Role: 2, // owner; new members at :347 / :808 get Role: 0
  • modules/space/api.go:663-682mySpaces returns Role: sp.Role, a raw passthrough with no remap
  • modules/space/db.go:862queryAdminsAndOwner selects role>=1; db.go:439-449 updateMemberRole is documented "仅用于非 owner 角色(0/1)" and guards role <> 2
  • modules/space/api_member_role_test.go:57,93-99 — owner fixture is role=2, and role=1 (admin) is denied permission to change roles

So the old comment (1: owner, 2: admin, 3: member) was simply wrong, and the old call sites were wrong with it. Concretely, SpaceMembers.isAdmin() was return space.role <= 2, which is true for role 0 — every ordinary member was rendered the member-management action row. And SpaceSettings.isOwner() was role === 1, i.e. it treated admins as owners. Both are fixed here, and the new canManageRoles() = isSpaceOwner(...) now matches the server, which permits role changes to owners only. Good change.

I audited the rest of the codebase for un-migrated space-role integers. The migration is complete with one exception, below (P2).

P1 — Policy panel shows an unfetched value after a failed or superseded load

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:38,48-66

enabled starts at true and is never invalidated. Two reachable states:

  1. Space A → B: A's value renders until B's GET resolves. Disabled during that window, so cosmetic — but if B's GET rejects, loading clears at :65, error renders, and the toggle is now live on A's value.
  2. First mount with a failing GET: the panel asserts "自动审核 已开启" — auto-approval on, no manual review — purely from the useState default.

For a control that decides whether org publications get human review, the UI should not be able to state a posture it has not read. Please carry an unresolved state (boolean | undefined, or a {spaceId, value} pair) and render the control only once the current generation has resolved; on error, render the error instead of a switch.

The generation guards themselves (requestGenerationRef, :46) are sound — this is about the value's initial/failed state, not about ordering.

P1 — New policy panel renders against undefined tokens, breaking dark theme

packages/dmworkskillmarket/src/index.css:3336-3351

border: 1px solid var(--wk-border-color, #e5e7eb);
background: var(--wk-bg-color, #fff);
.skill-market-review-policy p { margin: 6px 0 0; color: #6b7280; }
.skill-market-review-policy__error { color: #dc2626 !important; }
.skill-market-review-policy__toggle input { ... accent-color: #2563eb; }

--wk-bg-color and --wk-border-color are not defined in packages/dmworkbase/src/theme/{semantic,primitive,tokens}.css. Both var() calls therefore always resolve to their literal fallbacks, so this panel is a hard #fff card with #6b7280 text in both themes. semantic.css:386-466 overrides --wk-bg-surface to --wk-neutral-800 under body[theme-mode=dark], which is the token this should use (as .skill-market-detail__readme at :1538 already does). #2563eb should be var(--wk-color-accent) — already used for exactly this purpose at index.css:3244. !important at :3349 is called out at DEVELOPMENT.md:547, and var(--wk-color-error) needs no specificity boost.

The adjacent .skill-market-review-policy-confirm block in this same hunk (:3353-3392) does all of this correctly with var(--wk-color-error) / var(--wk-text-secondary) / var(--wk-sp-*) — so this is an inconsistency within one commit, not a house-style disagreement.

P1 — Branch is CONFLICTING, and the stacking needs a landing decision

gh pr view reports mergeable: CONFLICTING. The conflict is in apps/web/e2e-kit/msw-handlers/chat-baseline.ts against main@ca71616 (#1619, merged). Mechanical, but it has to be resolved.

The larger issue: this PR targets main while containing #1614's 36 commits, and #1614 is separately open and approved at 7d478ba — which is this branch's own ancestor. Whichever merges first empties the other. Please either re-base this onto #1614's branch, or state the landing order explicitly so #1614 is closed rather than left to merge into a no-op.

P2 — Aggregate handled-list finalizers skip the generation check

packages/dmworkskillmarket/src/components/ReviewQueue.tsx:255-256, 267

Each individual fetchHandledPage correctly compares spaceGeneration against spaceGenerationRef.current, but the two wrappers do not:

void Promise.all(TERMINAL_STATUSES.map((s) => fetchHandledPage(s, null, false))).finally(() => {
  setHandledLoading(false);
});

On a Space switch, handleSpaceChanged aborts the old controllers; those inner calls resolve on the next microtask (AbortError is swallowed) and this .finally clears handledLoading while the replacement fetches are still in flight — a brief "no handled requests" flash. Capturing the generation alongside the Promise.all fixes both sites.

P2 — One un-migrated space-role literal

apps/web/src/Pages/Main/index.tsx:259 still reads allSpaces.some(s => s.role === 1 || s.role === 2). {1,2} happens to select admin+owner under both the old and the new encoding, so this is behaviour-neutral today — but it is the last place a future reader can be misled. Please route it through isSpaceAdminOrOwner.

P2 — Policy read is issued for members who deep-link

SpaceReviewPage.tsx:70 calls loadPolicy() unconditionally, while the panel is gated on isReviewer (:126). A member who deep-links fires a GET /plugin_review_policies whose 403 is stored into error and never rendered. Harmless given the server is authoritative (as the file's own docstring states), but gating the load on the resolved role would drop a guaranteed-403 request.

P2 — Frozen relations are rendered as raw identifiers

ReviewDetailDrawer.tsx:246-252 renders relation.targetPluginId, targetPluginType and relationType verbatim. The stated goal is "so reviewers see the graph being approved", but a reviewer cannot tell which expert is being added from a plugin id. Resolving names would make the section actually decision-useful. (Escaping is fine — the <pre>{JSON.stringify(...)}</pre> at :256 is React-escaped.)

P2 — Blanket retry hides an e2e race

apps/web/e2e-kit/tests/settings-center/voice/settings-center-voice-support.ts:64-71 wraps the whole seed-and-open sequence in try { ... } catch { retry once }, discarding the first failure's reason. The comment identifies a real ordering race between the mock runtime and the conversation list's first snapshot; retrying makes CI green without fixing it, and silently absorbs a genuine regression the second time it passes. Unrelated to this PR's subject.

Verified as correct (noting so these are not "fixed" into regressions)

  • ReviewQueue.refreshAll (:295-303) clears error / detailId / rejectTarget / delistTarget / actingId, and handleSpaceChanged (:305) calls it. I checked this specifically, because adding the new generation guards to the finally blocks (:385, :407) would otherwise have leaked the actingId slot and made the reason modals un-cancellable via the if (actingId) return close guards at :594 / :627. It does not — the clears are there.
  • McpMarketListPage.handleSpaceChanged_ (:255-281) clears every modal identifier before reloading.
  • useSpaceRole's generation counter covers the space-changed payload fast-path, not just the probe.
  • All 12 new skillMarket.review.policy* / relations* keys plus plugin.publishedToast are present in both zh-CN and en-US. Deleting contacts.role.3 is safe — the old badge only rendered roles 1–2, so it was already dead.
  • mapReviewPolicy's wire shape matches request()'s .data unwrap at skillApiReal.ts:225 and the e2e handler's {data:{...}} envelope.

3. Overall verdict

REQUEST_CHANGES

Spec ❌ (one acceptance criterion unmet, several out-of-scope additions) AND Quality Changes-Requested. The core direction is right and the role-encoding fix is a genuine improvement; the blockers are the policy panel's unfetched-value state, the dark-theme token regression, and the merge/stacking state.


4. Suggested changes

  1. SpaceReviewPage.tsx — make enabled boolean | undefined; render the switch only after the current generation resolves; on error render the error in place of the switch.
  2. index.css:3336-3351var(--wk-bg-surface), var(--wk-border-subtle), var(--wk-text-secondary), var(--wk-color-accent), var(--wk-color-error), var(--wk-sp-*) / var(--wk-r-*); drop the !important.
  3. ReviewQueue.tsx:255,267 — capture spaceGenerationRef.current next to each Promise.all and check it in the .finally.
  4. Rebase to resolve chat-baseline.ts, and state the intended landing order relative to #1614.
  5. Drop isSpaceOwnerRole (and its barrel export) until something consumes it.
  6. Main/index.tsx:259 — use isSpaceAdminOrOwner.
  7. Consider splitting the frozen-relations rendering and the e2e stabilisations into their own PRs.

Add one regression for the failed-policy-load path — that the switch is not rendered with a fabricated value — since that is the acceptance criterion currently missed.


5. Coverage gaps in this review

Stated so they are not read as "checked and clean":

  • octo-marketplace was not inspected. Unverified: the /plugin_review_policies GET/PATCH contract; whether admins (not only owners) may PATCH — if the server is owner-only, admins get a 403 behind a visible switch, and policyDescription ("组织所有者和管理员均可修改") would be wrong; the documented "on when no override exists" default; and whether frozen_relations is [] or absent for relation-less plugin types. If it is [], ReviewDetailDrawer.tsx:239 renders a "无关联插件" section on every skill review.
  • Whether the server binds a review id to the request's Space header. This decides whether a cross-Space action continuation 403s or silently succeeds. Not answerable from this repo.
  • Suites not re-run here. The PR reports all package suites, pnpm build and pnpm i18n:check passing; I did not reproduce that, and CI on this head reports only label and pr-title-lint green with no test job. Worth confirming the encoding change against the dmworkbase suite in particular, since it touches shared Space components.
  • The role-encoding unit tests are self-consistent, not contract-proving. useSpaceRole.test.ts, spaceRoleBadge.test.ts and the role: 1 → 2 edits across chat-baseline.ts / sp1 / sp2 / rb3 mocks were all flipped together with the code, so they pin intent but cannot detect the encoding being wrong. The octo-server references in §2 are what actually establish it. A wire-contract test against a recorded space/my payload would close this.
  • fetchAllSpaceMembers (Contacts/index.tsx:362-373) has no abort or generation check inside its paging loop, so a discarded Space's fetch runs to completion. In practice PAGE_SIZE = 10000 with an early break means one request for any Space under 10k members, and the function is untouched by this PR — noting it as pre-existing, not a finding against this diff.

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — PR #1624 (octo-web), Round 3

Reviewed at head 67837145 against merge-base bff055a. This is a re-review of my Round-2 APPROVE (which the subsequent push dismissed); I re-verified the full delta independently at this head.

🔴 Blocking

🔴 1. Policy panel renders a policy value it never fetched — and the switch goes live on it

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx

  • enabled is initialised to true (line 38) and loadPolicy() (lines 48–66) never resets it: it only sets loading/saving/error before firing the GET.
  • On a successful GET the value is written (generation-guarded — that part is sound). On a failed GET the catch stores the error and the finally clears loading — so the checkbox becomes interactive while still showing a value that was never read for this Space.
  • Two reachable states verified byte-by-byte:
    1. Space A → B switch: A's value keeps rendering while B's GET is in flight (disabled during the window, so cosmetic only) — but if B's GET rejects, the toggle is live on A's value in B's panel.
    2. First mount with a failing GET: the panel asserts "自动审核 已开启" — the weakest posture for a control that decides whether org publications get human review — purely from the useState default, with the error line beside it.
  • This violates the PR's own acceptance criterion "Space switches cannot display stale member/role/policy state": the policy panel is the one surface in this PR that does not clear its value synchronously on space-changed (contrast AllAssetsList.tsx lines 83–90 and McpMarketListPage.tsx lines 255–281, which do).
  • I own this one: the defect was already present at the Round-2 head and my APPROVE there missed it. Credit to yujiwei's review on this head (id 5110997382) for raising it; I re-verified it independently against the source.

Fix: carry an unresolved state (boolean | undefined, or a {spaceId, value} pair), render the switch only once the current generation has resolved, and on error render the error in place of the switch. Add a regression for the failed-load path asserting the switch is not rendered with a fabricated value.

🔴 2. New policy-panel CSS resolves against undefined tokens, hard-codes light values, and uses !important — dark theme is broken for this panel

packages/dmworkskillmarket/src/index.css lines 3336–3351 (new in this PR):

  • border: 1px solid var(--wk-border-color, #e5e7eb) and background: var(--wk-bg-color, #fff): --wk-border-color and --wk-bg-color are defined nowhere in the theme files (packages/dmworkbase/src/theme/semantic.css etc. — verified by repo-wide search), so both var() calls always resolve to their literal light fallbacks.
  • The panel is therefore a hard #fff card with #6b7280 text and a #2563eb checkbox in both themes. Dark mode is a shipped, persisted feature (packages/dmworkbase/src/App.tsx toggles body[theme-mode="dark"]; semantic.css re-tints --wk-bg-surface to --wk-neutral-800 under it), and every other rule this same PR adds deliberately relies on semantic tokens for exactly that reason (e.g. the .wk-plugin-status block documents it).
  • .skill-market-review-policy__error { color: #dc2626 !important; } violates the explicit !important ban in DEVELOPMENT.md §13 (CSS 编写禁止事项); var(--wk-color-error) needs no specificity boost.
  • The adjacent .skill-market-review-policy-confirm block in the same hunk does all of this correctly with var(--wk-color-error) / var(--wk-text-secondary) / var(--wk-sp-*) — so this is an inconsistency within one commit, and the fix is mechanical: var(--wk-bg-surface), var(--wk-border-subtle), var(--wk-text-secondary), var(--wk-color-accent) (already used for this exact purpose elsewhere in this file), var(--wk-color-error), and drop the !important.

Merge state — must be resolved (not a code defect)

  • Live status: mergeable_state = dirty (CONFLICTING) at this head. The conflict is in apps/web/e2e-kit/msw-handlers/chat-baseline.ts against current main; please rebase.
  • Stacking: this branch targets main while containing #1614's commits — #1614 is still OPEN and its head 7d478ba is a direct ancestor of this head (verified). Whichever merges first empties the other; please state the landing order explicitly (or re-base onto #1614's branch) so #1614 is closed rather than left to merge into a no-op.

💬 Non-blocking

  • 🟡 Multi-step save/publish continuations are not Space-bound (raised by the second-opinion pass as blocking; I verified it independently and rate it 🟡 here). The request interceptors resolve the Space header per request (packages/dmworkskillmarket/src/api/skillApiReal.ts lines 84–89; packages/dmworkmcp/src/api/mcpService.ts lines 428–430), and the multi-step chains do not capture the originating Space: ExpertEditModal.tsx's close is unguarded while saving (onCancel={onClose}, with mask/Esc close enabled by default), and EditSkillModal.tsx / NewSkillModal.tsx's leave-confirm closes without aborting the submit chain (its abortRef only covers the upload/parse flow). A user who closes one of these modals right after clicking 发布 and switches Space leaves the continuation firing the second step under the new Space's header. McpCreateModal.tsx is already guarded (its close handler returns while submitting), which is the pattern to copy. Impact is bounded — records are Space-scoped server-side (the mock contract documents the real endpoint 404-ing on another Space's record), so the continuation fails rather than writes across — but the failure is silent (the error state renders on an already-closed modal; no toast), leaving a draft saved with the publish lost. Recommend capturing the originating Space at submit start and dropping the continuation on space-changed, consistent with the generation guards this PR already uses elsewhere. Whether the server binds the record to the request's Space header in every path is not answerable from this repo — another reason to make the client side robust.
  • 🟡 ReviewQueue.tsx lines 255–256 and ~267: the two Promise.all(...).finally(...) wrappers for the handled list clear handledLoading/handledLoadingMore without the generation check their inner fetchHandledPage calls do have — on a Space switch the stale finally can clear the flag while the replacement fetches are in flight (brief empty flash). Capture the generation beside each Promise.all.
  • 🟡 apps/web/src/Pages/Main/index.tsx line 259 still reads s.role === 1 || s.role === 2 — behaviour-neutral under both encodings, but the last un-migrated literal; route it through the shared predicate.
  • 🟡 SpaceReviewPage.tsx line 70 loads the policy unconditionally while the panel is gated on isReviewer; a deep-linked member fires a guaranteed-403 GET whose error is stored but never rendered. Gate the load on the resolved role.
  • 🟡 Frozen relations render raw identifiers (ReviewDetailDrawer.tsx lines 246–252); a reviewer cannot tell which expert is being added from a plugin id. Resolving names would make the section decision-useful.
  • 🟡 The voice e2e stabilisation (settings-center-voice-support.ts lines 64–71) wraps the whole seed-and-open sequence in a retry that discards the first failure's reason — the ordering race it documents is real, but the blanket retry can also absorb a genuine regression; narrowing it to the snapshot-ordering step would be safer.
  • 🟡 isSpaceOwnerRole (useSpaceRole.ts) still has zero production consumers (carried from Round 2); drop it or land it with its consumer.

Verified on this head (so these are not re-flagged)

  • Round-3 delta is test-only. 3f2244f5..67837145 touches exactly three files: the new guard regression suite, one MSW baseline handler addition, and the voice e2e retry wrapper. Zero production-code bytes changed (file list + diff verified).
  • The new guard regression test is real load, not an existence check. packages/dmworkmcp/src/pages/__tests__/McpMarketListPage.spaceIsolation.test.tsx drives the actual timing: start handleEditFromCard / openPublishVersion, switch Space via handleSpaceChanged_ (whose first synchronous statement bumps requestVersion), then resolve/reject the in-flight detail fetch and assert no modal opens and no toast fires — both entry points, both success and error paths. I re-read the production guards at this head (McpMarketListPage.tsx lines 255–281, 522–533, 574–585) and they match. Minor observation: there is no positive control (no-switch → modal opens) anywhere in the suite, so an inverted guard would still pass these four tests; worth adding one.
  • MineTable's nine-column rework is internally consistent: header renders from a nine-entry COLUMNS, rows render nine cells with matching modifier classes, and each responsive breakpoint hides the same classes its shrunken grid template drops. The removed --nostats rule has zero remaining consumers.
  • Local suites green at this head: packages/dmworkmcp 283/283 (baseline 279 + the 4 new isolation tests), packages/dmworkskillmarket 255/255. CI on this head runs only label/title checks (no build/test job); the code-review commit status is red pending a current approval.
  • The role-encoding fix (SpaceService.tsx lines 233–235) is corroborated by yujiwei's audit against the octo-server role model, and the rest of the migration is complete except the one neutral literal above.

Verdict

REQUEST_CHANGES — the policy panel's unfetched-value state and the dark-theme/!important token regression are both small, mechanical fixes on this PR's own flagship surface, but they block until done; the rebase and the #1614 landing order need an explicit decision. The core direction — generation guards, server-authoritative authorization, frozen relations — is sound, and the Round-3 test additions genuinely cover the Round-1 race.

lsc added 2 commits September 4, 2026 17:12
…auto-approve

# Conflicts:
#	apps/web/e2e-kit/msw-handlers/chat-baseline.ts
@l-s-c

l-s-c commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the latest requested changes in 491b7ee8 and resolved the main conflict in 92ee6992:

  • policy state is now unresolved until the current Space GET succeeds; failed loads render no fabricated/interactable toggle
  • ordinary members no longer issue the reviewer policy request
  • policy panel now uses semantic light/dark theme tokens and no !important
  • handled-list loading finalizers are Space-generation guarded
  • removed the unused owner-only helper/export and replaced the remaining role literal with isSpaceAdminOrOwner
  • narrowed the voice e2e retry to only the known conversation snapshot timeout
  • merged current upstream/main, retaining its full Summary attention-count baseline and this PR’s explicit conversation mock coverage

Stacking plan: #1614 should merge first because its head is a direct ancestor of this PR. After #1614 lands, this PR’s effective diff against main will automatically shrink to the review-policy follow-up; #1624 should merge second. No force-push or history rewrite is needed.

Verified so far: skillmarket 255 tests, MCP 283 tests, i18n check, production build, focused TES13 + S26 e2e, and diff whitespace checks. Additional base/contacts verification is running.

@l-s-c

l-s-c commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

CI update for head 92ee6992: Build ✅, Unit tests + coverage ✅, full e2e-p0 ✅ (13m31s), install-build ✅, dependency/OSV/secret/history/title checks ✅. check-sprint remains intentionally ignored. The branch is conflict-free and GitHub reports it mergeable. Automated code re-review is the only remaining pending check.

Jerry-Xin
Jerry-Xin previously approved these changes Sep 4, 2026

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review — PR #1624 (octo-web), Round 4

Reviewed at head 92ee6992 (fix commit 491b7ee8 + merge of upstream/main ca716168). Both Round-3 blockers are verified fixed at byte level; the merge is clean; CI is fully green; local suites at baseline.

🔴 1 (Round 3): policy panel CSS — FIXED

packages/dmworkskillmarket/src/index.css, the .skill-market-review-policy* blocks:

  • var(--wk-border-color, #e5e7eb) / var(--wk-bg-color, #fff)var(--wk-border-subtle) / var(--wk-bg-surface) — both defined in packages/dmworkbase/src/theme/semantic.css with dark-theme overrides.
  • #6b7280var(--wk-text-secondary); #dc2626 !importantvar(--wk-color-error) (no !important); #2563ebvar(--wk-brand-primary); gap/margin/padding/radius now --wk-sp-* / --wk-r-md.
  • I checked every var() in this file's PR diff against the theme: all resolve to tokens defined in semantic.css; no hardcoded light literals and no !important in the added lines.

🔴 2 (Round 3): enabled toggle fail-closed posture — FIXED, with tests

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:

  • enabled is now boolean | undefined, initialised undefined; loadPolicy() resets it to undefined before every fetch; space-changed bumps the generation and resets value/loading/error before reloading (reloads only for payload role 1–2; role 0 clears loading with the value left undefined); the non-reviewer path clears everything.
  • The toggle renders only when enabled !== undefined && !error. A failed GET shows the error text and renders no switch at all — there is no path left that renders an unfetched value as 已开启. Saves are not optimistic: savePolicy writes back the server-echoed value only.
  • New regression tests: "does not render an unfetched policy value when loading fails", and members no longer trigger policy reads ("avoids policy reads for ordinary members" asserts getPolicy is never called). The existing space-switch generation tests still cover the stale-response races.

Round-3 findings, current status

  • yujiwei P2 (handled-list finalizers skipping the generation check, ReviewQueue.tsx) — FIXED in 491b7ee8: both .finally sites now capture and compare spaceGenerationRef.current.
  • yujiwei P2 (un-migrated role literal Main/index.tsx:259) — FIXED: routed through isSpaceAdminOrOwner, which is exported from @octo/base via the barrel (export * from "./Service/SpaceService").
  • yujiwei P2 (policy read issued for deep-linked members) — FIXED: the load is gated on the resolved role.
  • yujiwei P2 (blanket voice-e2e retry) — ADDRESSED: the retry is narrowed to TimeoutError; other failures rethrow immediately.
  • isSpaceOwnerRole — REMOVED from the hook, the barrel export, and its test; repo-wide grep shows zero remaining references.
  • Conflict against main (chat-baseline.ts) — RESOLVED; PR is MERGEABLE again. The combined merge diff is tiny: handlers.ts keeps both main's MSW probe handler and this PR's review-badge handlers, and the chat-baseline resolution keeps main's contact-bot mocks alongside this branch's role: 2 + /api/v1 variants. Main-side changes (#1596 red-dot, #1619 communication, #1612/#1616) are all present in the merge result — nothing swallowed.

Second-opinion 🔴 claim — independently adjudicated non-blocking

The second-opinion pass flagged packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx: when reviewId changes or a retry starts, the previous review stays in state while canAct ignores loading, so Approve could act on the stale request. The mechanism is real as written (the load effect does not clear review), but it is unreachable in the current wiring:

  • The drawer is a WKModal (Semi Modal, mask on by default); the only writer that sets a non-null reviewId is a row's onOpen, which cannot be clicked through the mask while the drawer is open.
  • Every other transition passes through reviewId=null (onClose, or refreshAll clearing detailId), and the null path clears review before any next open.
  • Retry reloads the same reviewId; the footer always acts on the request shown in the header, so even on the error path the button approves exactly what is displayed.
    Clearing review when a load starts and/or adding !loading to canAct is still worth doing as defense-in-depth with a slow-B-after-A regression test — noted as P2, not blocking.

P2 notes (non-blocking, carried)

  • Frozen relations still render raw identifiers (ReviewDetailDrawer.tsx); resolving names would make the section decision-useful (yujiwei's point, unchanged).
  • .skill-market-review-notice adds one new var(--wk-color-primary) use vs main (index.css) — that token is defined nowhere in the theme, so the declaration is invalid and the accent border silently drops. Cosmetic only; var(--wk-color-accent) is the house equivalent. (The other three --wk-color-primary uses in the mine-table already exist on main via #1584.)
  • The guard regression suite still has no no-switch control case — an inverted guard would still pass the four race tests.
  • Multi-step save/publish chains (ExpertEditModal / EditSkillModal / NewSkillModal) remain unbound to the originating Space; server-side scoping means a stale continuation fails rather than cross-writes, but the failure is silent. McpCreateModal's close guard remains the pattern to copy.
  • Process: this PR still carries #1614 (open at 7d478ba, an ancestor of this branch); whichever lands first no-ops the other — please state the landing order when merging.

Verified on this head

  • Local suites: packages/dmworkmcp 283/283 and packages/dmworkskillmarket 255/255 (the latter needs Node's --localstorage-file flag in this environment because Node 22.23 does not expose the localStorage global by default — environment quirk, not a code regression; CI's Unit tests are green).
  • CI on 92ee6992: Build, Unit tests, e2e-p0, install-build, secret-scan, dependency-review — all green.

Verdict

APPROVE — both Round-3 blockers are fixed with regression coverage; the merge introduced no new blockers; the residual items are P2.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web)

Reviewed at head 92ee6992 (a merge of 491b7ee8 with main@ca716168), against merge-base ca716168. Round 4.

Scoping: main's tip is the merge-base, so ca716168..HEAD is exactly this PR's own change (90 files, +12829/−792). Round 3 reviewed 6783714; the only new work here is 491b7ee8 "fix: address review policy feedback" plus the merge. I verified each round-3 item on this head and then re-reviewed the flagship surface (SpaceReviewPage, useSpaceRole, ReviewQueue, ReviewDetailDrawer) independently, because that is where the fix landed.


1. Spec compliance

Spec: ❌

Checked against #1623's Scope and Acceptance lists.

Missing — none. The acceptance criterion that failed in round 3 is now met: "Space switches cannot display stale member/role/policy state." SpaceReviewPage.tsx:38 now holds boolean | undefined, loadPolicy() resets it to undefined at :51, and both the space-changed handler (:71-81) and the role effect (:89-101) clear it before any new read. The member/role side is unchanged and was already correct.

Out of scope (超建) — carried from round 3, unaddressed:

  • Frozen-relation rendering (api/pluginWire.ts:194, types/skill.ts, ReviewDetailDrawer.tsx:239-262, RejectReasonModal-adjacent CSS). This is a new user-visible reviewer feature. #1623's scope is "policy API mapping and review-management UI", "role behavior aligned with the authoritative role encoding", "prevent stale responses from crossing Space switches", "deterministic tests and E2E mocks". None of those covers rendering an approval-time relation graph. It may well be worth shipping — it just is not this issue.
  • Unrelated e2e flake fix (apps/web/e2e-kit/tests/settings-center/voice/settings-center-voice-support.ts:52-72). The retry is now correctly narrowed (see §2), but the voice settings flow has nothing to do with the review policy. This is the kind of change that makes an already-large PR hard to land.

The contacts role badge, the MCP all-assets isolation and the chat-baseline.ts MSW additions do fall under "role behavior aligned with the authoritative encoding" / "prevent stale responses" / "E2E mocks", so I am not counting those against scope.

Divergence (偏离) — none material.

To be clear about weight: scope creep alone would not be my blocker at round 4. The two P1s below are.


2. Code quality

Quality: Changes-Requested

First — the round-3 fixes I verified as correct, so nobody "fixes" them back

  • CSS tokens. index.css:3336-3369 now uses --wk-bg-surface, --wk-border-subtle, --wk-text-secondary, --wk-color-error, --wk-sp-{1,2,4,5,6}, --wk-r-md, --wk-brand-primary, and the !important is gone. I confirmed every one of those is actually defined in packages/dmworkbase/src/theme/semantic.css (:290/:465 for --wk-bg-surface, :323/:524 for --wk-border-subtle, :313/:516, :334/:535, :14/:393, :239-253) — including the body[theme-mode=dark] overrides, so the panel now re-tints. The previous --wk-bg-color / --wk-border-color were undefined; this is a real fix, not a rename.
  • Handled-list generation guards. ReviewQueue.tsx:255,265 capture spaceGenerationRef.current beside each Promise.all and check it in the .finally. Correct.
  • isSpaceOwnerRole removed from useSpaceRole.ts and from the barrel at index.tsx:19. Its test was dropped with it.
  • Main/index.tsx:259 now routes through isSpaceAdminOrOwner(s.role), preserving the prior role === 1 || role === 2 semantics.
  • Voice e2e retry (settings-center-voice-support.ts:60-68) now rethrows anything that is not a TimeoutError and retries only the conversation-visibility wait, not the whole seed-and-open sequence. This is the right narrowing.
  • Merge resolution. 92ee6992's conflict resolution in apps/web/e2e-kit/msw-handlers/chat-baseline.ts keeps both sides: main's new robot/my_bots, robot/space_bots, populated group/my and space/:spaceId/members fixtures, and this branch's role: 1 → 2 owner fixtures (:57, :70) and */api/v1/conversations/.../extra handlers. Nothing from main was clobbered. mergeable is now MERGEABLE, and CI Build is green on this head.
  • Member deep-link no longer fires a guaranteed-403 policy GETSpaceReviewPage.tsx:89-101 gates loadPolicy() on the resolved role, and SpaceReviewPage.test.tsx:124-130 asserts getPolicy is never called for role=0.

P1 — A failed policy save permanently removes the policy control

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:113-116 and :154

} catch (err) {
  if (requestGeneration !== requestGenerationRef.current) return false;
  setError(err instanceof Error ? err.message : t("skillMarket.review.policySaveFailed"));
  return false;
}
...
{enabled !== undefined && !error && (
  <label className="skill-market-review-policy__toggle">

The round-3 fix was "on error render the error in place of the switch" — but !error was applied to every error, not just a load error. savePolicy leaves enabled at its correctly-loaded value and sets error, so the render guard now hides the switch after a save failure. There is no retry affordance in the section — recovery requires a full remount or a Space switch.

Two reachable paths, both verified by running them against this head:

  1. Failed enable. Click the switch on → handlePolicyChange(true)savePolicy(true) rejects → enabled=false, error≠null → the checkbox is gone.
  2. Failed disable, then cancel. Click off → confirm modal → confirmDisable()savePolicy(false) rejects → error renders inside the modal (correct), user cancels the modal → enabled=true, error≠null → the checkbox is gone from the panel behind it.

I wrote two throwaway probes against the real component (mocking only skillApi and useSpaceRole, same harness as the existing suite) and both assert-not-null checks failed:

× PROBE A: a failed enable-save must not remove the toggle
    AssertionError: expected null not to be null   (screen.queryByRole("checkbox"))
× PROBE B: a failed disable-save then cancel must not remove the toggle
    AssertionError: expected null not to be null

This matters beyond a transient 500: whether admins (not only owners) may PATCH /plugin_review_policies is still unverified server-side (see §5). If the endpoint is owner-only, every admin who touches the switch gets a 403 and then watches the control vanish — the panel still says "组织所有者和管理员均可修改" while offering nothing.

Fix: track the load failure separately from the save failure (e.g. loadError vs saveError), gate the switch on enabled !== undefined && !loadError, and render saveError next to a still-interactive switch. The existing suite has no save-failure case at all — SpaceReviewPage.test.tsx:41 only ever resolves updatePolicy — so please add one in both directions.

P1 — ReviewDetailDrawer leaks its action state across reviewId, auto-opening the reject dialog on a different review

packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx:42-48, :36, :277-281

useEffect(() => {
  if (!reviewId) {
    setReview(null);
    setError(null);
    setIconError(false);
    return;
  }

rejectOpen (:36) and acting (:35) are not reset here. The drawer is permanently mounted — ReviewQueue.tsx:586 renders it with reviewId={detailId} — and ReviewQueue.refreshAll() (:299-304), which handleSpaceChanged (:309-326) calls, closes it by setDetailId(null). So the queue's own generation guards, which are otherwise thorough, stop at the child.

Concrete sequence, verified by running it:

  1. Space A, open review A's detail, click 拒绝 → setRejectOpen(true), the reason dialog is up for A.
  2. Switch Space → refreshAll()setDetailId(null)review becomes null, so {review && <RejectReasonModal …>} unmounts and the dialog visually disappears. rejectOpen stays true.
  3. In Space B, open any review's detail → review resolves to B → RejectReasonModal remounts with visible={rejectOpen} still true.
DIALOG COUNT: 2 REJECT-MODAL-VISIBLE: true
× PROBE C: rejectOpen must not survive a drawer close and re-open on another review
    AssertionError: expected true to be false   (/拒绝「Skill B」/ present in the DOM)

The dialog that pops up is titled with B's plugin name and its confirm calls rejectReview(review.id, reason) against B's id. A reviewer who was interrupted mid-reject on A can type a reason into an unsolicited dialog and reject a request they never chose. It is a destructive decision on the wrong record, in exactly the Space-switch scenario this PR exists to harden.

RejectReasonModal.tsx:28-38 shows the author already reasoned about this hazard — "this instance survives a change of review … one confirm would write the previous plugin's reason here" — and correctly clears reason/error when visible goes false. The same reasoning needed to reach the parent's rejectOpen.

acting has the same leak, with a milder symptom: an approve issued in Space A that is still in flight leaves acting=true, so the newly opened detail in Space B renders its 通过/拒绝 footer disabled (:148, :155) until A's request settles; and if A's approve then resolves, onDecided(); onClose() (:75-76) closes the drawer the user just opened in B, while a rejection writes A's message into B's error.

Fix: reset rejectOpen, acting and loading in the !reviewId branch (and on any reviewId change), and drop the approve/reject continuations when the reviewId they started under is no longer current — the same generation pattern ReviewQueue already uses. Worth a regression: close the drawer mid-reject, reopen on another id, assert no reject dialog.

P2 — c.spaceRole = m.role is a type error on Contacts

packages/dmworkcontacts/src/Contacts/index.tsx:507 and :522

const c = new Contacts()
...
c.spaceRole = m.role

c is typed Contacts (imported from @octo/base at :3, i.e. the class at packages/dmworkbase/src/Service/DataSource/DataSource.ts:69), which has no spaceRole and no index signature. spaceRole lives only on the local SpaceContact interface (:68-70). Annotating the array (items: SpaceContact[], :478; memberItems: SpaceContact[], :513) does not retype c. Reduced to a minimal case and run through this repo's own tsc:

a.ts(8,5): error TS2339: Property 'spaceRole' does not exist on type 'Contacts'.

This does not break CI — pnpm build is vite build (esbuild, transpile-only), no package here runs tsc --noEmit, and Build is green on this head — which is precisely why it is worth fixing now rather than discovering it the day someone adds a typecheck job. Note the change replaced a previously valid (c as any)._spaceRole = m.role, so the cleanup introduced the error. const c = new Contacts() as SpaceContact (or building a plain SpaceContact literal) resolves it.

I'll flag the corollary, not as a finding: nothing in CI type-checks these packages, so no reviewer and no gate can catch this class of defect. That is worth its own issue.

P2 — The role encoding is re-inlined in the very file that centralises it

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:79

if (typeof role === "number" && role >= 1 && role <= 2) loadPolicy();

isSpaceReviewerRole is exported from ../hooks/useSpaceRole (:12-18) and is already imported into this file's dependency graph; it is literally role >= SPACE_ROLE_ADMIN && role <= SPACE_ROLE_OWNER. Re-spelling it as bare integers here re-creates the drift this PR is fixing everywhere else.

Related fragility in the same handler: when the payload's role is not a number, neither branch at :79-80 runs, so loading stays true and enabled stays undefined. It recovers only because useSpaceRole.resolveFromServer() (useSpaceRole.ts:55-81) flips its own loading false→true→false, and roleLoading is in the :101 dependency array. That is a real but entirely implicit coupling between two hooks. An explicit else { loadPolicy() } — or dropping the payload fast-path here and letting the role effect own it — removes the need for the reader to discover it.

P2 — Dead disabled condition

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:159: disabled={loading || saving}. Since loadPolicy sets enabled=undefined whenever it sets loading=true, and the switch only renders when enabled !== undefined, loading can no longer be true at this point. Harmless, but it now reads as protection that isn't doing anything.

P2 — Hardcoded spacing in the new confirm block

packages/dmworkskillmarket/src/index.css:3371-3388: .skill-market-review-policy-confirm { padding: 2px 0 4px } and .skill-market-review-policy-confirm__icon { width: 40px; height: 40px; border-radius: 12px } (:3378-3388). The colour tokens in this block were always right; the spacing and radius still bypass --wk-sp-* / --wk-r-* that the sibling rule now uses.

P2 — Carried from round 3, still open

  • Multi-step publish continuations are not Space-bound. packages/dmworkmcp/src/components/ExpertEditModal.tsx:156 is still onCancel={onClose} with no in-flight guard, and EditSkillModal / NewSkillModal's leave-confirm still closes without aborting the submit chain. McpCreateModal is guarded and is the pattern to copy. Bounded impact (records are Space-scoped server-side, so the continuation should fail rather than write across) but the failure is silent.
  • Frozen relations render raw identifiers. ReviewDetailDrawer.tsx:249-252 prints targetPluginId / targetPluginType / relationType verbatim. The stated goal is "so reviewers see the graph being approved"; a reviewer cannot tell which expert is being added from an opaque id. Escaping is fine — the <pre>{JSON.stringify(...)}</pre> at :255 is React-escaped.
  • #1614 landing order. #1614 is still OPEN and its head 7d478bac is a direct ancestor of this head (verified with git merge-base --is-ancestor). Merging this empties that. The merge with main resolved the conflict, not the stacking. Please state the intended order so #1614 is closed rather than left to merge into a no-op.

Findings I checked and am NOT raising

Stated so they are not re-litigated:

  • SpaceMembers/index.tsx:156-180 — the wk-spacemembers-item-actions wrapper does not render empty for admins. The 移除 button at :174-179 is unconditional inside it; only the two role-change buttons gained the owner-only canManageRoles gate. Restricting role changes to owners matches octo-server (api_member_role_test.go denies role=1), so this is correct as written.
  • expertService.ts:632expertListErrorI18nKey is declared (err: unknown) at expertListError.ts:35. No cast needed; passing a bare catch binding is fine.
  • reviewSignal.tswithReviewInvalidation bumping in finally (including on refusal) is deliberate and cannot loop: listeners only issue reads, and reads are not wrapped. Iterating a copy of the set and swallowing listener throws are both correct.
  • useReviewRequests.ts:52-139 — aborting before the !enabled early return (:59-70), sharing the page controller with the badge probe (:113), and the controller.signal.aborted guard on every write are all correct.
  • frozenRelations is mapped from an optional frozen_relations? (pluginWire.ts:194), so an absent field yields undefined and ReviewDetailDrawer.tsx:239 renders nothing. The "empty relations section on every skill review" risk only materialises if the server sends [] — see §5.

3. Overall verdict

REQUEST_CHANGES

Spec ❌ (out-of-scope additions) AND Quality Changes-Requested. Both P1s are small and mechanical, and both are in the code this round added or touched: the render guard that fixed the unfetched-value bug over-reaches onto save errors, and the drawer is the one component the Space-switch hardening skipped. Everything round 3 asked for is otherwise done and done properly.


4. Suggested changes

  1. SpaceReviewPage.tsx — split loadError from saveError; gate the switch on enabled !== undefined && !loadError; render saveError beside a live switch. Add tests for a failed enable and a failed-then-cancelled disable.
  2. ReviewDetailDrawer.tsx:42-48 — reset rejectOpen, acting and loading when reviewId changes or clears; drop approve/reject continuations whose originating reviewId is stale. Add a regression that reopens the drawer on a different id and asserts no reject dialog.
  3. Contacts/index.tsx:507,522const c = new Contacts() as SpaceContact.
  4. SpaceReviewPage.tsx:79 — use isSpaceReviewerRole(role); make the non-numeric-role branch explicit instead of relying on useSpaceRole's loading toggle.
  5. index.css:3371-3388--wk-sp-* / --wk-r-* for the confirm block's padding and radius.
  6. State the landing order relative to #1614.
  7. Consider splitting the frozen-relation rendering and the voice e2e fix into their own PRs. Not a merge blocker; it is what would have made rounds 2–4 cheaper.

Separately, worth its own issue: no CI job type-checks these packages, so finding 3 was invisible to every gate on this PR.

5. Round count — please escalate rather than iterate

This is round 4 (8f3f4e2b666c20b66783714 ×2 → 92ee6992). Both P1s here are consequences of round-3 fixes rather than original defects, which is the signature of whack-a-mole rather than convergence. Before round 5, I'd suggest the author and whoever is shepherding this agree on scope out loud: land the policy panel and the role-encoding fix as one small PR, and move the frozen-relation feature, the MCP all-assets isolation and the e2e stabilisations to follow-ups. A 90-file, +12.8k-line diff is why a two-line render guard took three rounds to get right.

6. Coverage gaps in this review

Stated so they are not read as "checked and clean":

  • octo-marketplace was not inspected. Still unverified: the /plugin_review_policies GET/PATCH contract; whether admins may PATCH (this decides how often finding 1 fires in practice, and whether policyDescription's "组织所有者和管理员均可修改" is accurate); the documented "auto-approve on when no override exists" default; and whether frozen_relations is [] or absent for relation-less plugin types.
  • Whether the server binds a review/plugin id to the request's Space header in every mutation path. This decides whether the stale continuations above 403 or silently succeed. Not answerable from this repo.
  • Unit tests and e2e-p0 were still pending on this head when I reviewed; Build and the scanners are green. I ran only the two dmworkskillmarket suites I needed for the probes above; I did not reproduce the full matrix the PR description reports.
  • Bulk of the diff not re-read. MineTable's column rework, NewSkillModal (+903), McpCreateModal (+433), skillApiReal/skillApiMock, AllAssetsList, MarketSidebar and the expertService visibility work were verified in rounds 1–3 and are unchanged here; I re-read them only where a finding pointed. If you want a fresh pass on those, say so.
  • No positive control in the isolation suites. Noted in round 3 and still true: McpMarketListPage.spaceIsolation.test.tsx asserts only that guards suppress; an inverted guard would still pass. Same now applies to whatever regression covers finding 2.
  • Dark mode was verified by token existence, not by rendering. I confirmed each var() resolves to a token defined under both :root and body[theme-mode=dark]; I did not open the panel in a browser in either theme.

mochashanyao
mochashanyao previously approved these changes Sep 4, 2026

@mochashanyao mochashanyao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Octo-Q · automated review]

Verdict: Approve — no blocking findings; notes below (data-flow traced).


Code Review — PR #1624 (octo-web)

Reviewer: Octo-Q (automated review)
Head SHA: 92ee6992f68610e16d301ca0f88a21ad95fbe9de | Base: main (merge-base ca716168) | 90 files, +12829/−792 | 首轮审查

Summary

本 PR 把「组织发布管理(Space review)」完整落到前端:技能/连接器/专家/专家团四个市场统一走 /plugins/review_requests + 单一 /plugins/publish 发布门(后端按 visibility 决定直接上架还是开审核),新增审核队列页(SpaceReviewPage + ReviewQueue)、侧边栏待审徽标(失效信号挂在 mutation 端点上)、自动批准策略开关(/plugin_review_policies)、全部资产页与统一的 display_status 渲染契约;同时把前端 space 角色编码整体校正为 octo-server 的 0/1/2 编码。整体工程质量很高:竞态(generation/abort)、空间切换重载、fail-closed 枚举收窄都有成体系的防护和配套 e2e(RB1/RB2/RB3)。未发现 P0/P1;5 个 P2 如下。

Verification

Static analysis only at head 92ee6992; build and tests not executed in this environment.

  • Space 角色编码与后端一致 — 对照 octo-server 源码核实:modules/space/db_manager.go 转让 owner 时 role=2 → role=1(owner=2, admin=1),modules/space/db.go queryAdminsAndOwnerrole>=1。新常量(SpaceService.tsx:233-241)及全部消费点(SpaceMembers/SpaceSettings/MainPage/Contacts badge/各 MSW mock)均为 0=member/1=admin/2=owner。旧注释 1:owner,2:admin,3:member 才是错误的一方——本 PR 是修复,不是引入(旧代码下 member(role=0) 会穿过 role<=2 的 admin 判断、成员管理按钮对真实编码全错位)。
  • 审核读取的空间隔离skillApiReal.ts:78-89 getAuthHeaders() 请求时读 currentSpaceIdX-Space-Id;Main 页先同步更新 currentSpaceId 再 emit space-changed;四个市场页 + AllAssetsList + ReviewQueue + SpaceReviewPage + 侧边栏 probe 全部订阅 space-changed 并带 generation 守卫;RB3 e2e 钉住「双空间 1→3」场景。
  • 失效信号架构withReviewInvalidation 挂在 endpoint 而非调用点:deleteSkill/deleteMcp/deleteExpert/deleteSquad/publishPlugin/delistPlugin/createReviewRequest/approveReview/rejectReview/cancelReview 全覆盖;拒绝(409)也触发失效(队列已动的最强证据);dmworkmcp 通过深导入 @dmwork/skillmarket/src/api/reviewSignal 保证同一模块实例(包无 exports 字段,深导入可解析)。
  • facade mock/real 对偶skillApi.ts 引用的 19 个名字在 skillApiMockskillApiReal 均存在(逐一 grep 核实),VITE_USE_MOCK 下不会解析到 undefined
  • XSS — 冻结 readme(不可信申请人内容)经 ReactMarkdown + rehypeSanitizeReviewDetailDrawer.tsx);relation.data 经 JSON.stringify 文本渲染;其余用户字符串均为文本节点。
  • 版本规则与后端镜像utils/version.ts 格式门 + 数值化前向比较 + stored-label grandfathering,且刻意区分保存面(带 stored)与审核提交面(不带,镜像 SubmitReview 无豁免);四个模态框调用点均正确。
  • i18n — 变更文件引用的 411 个 t() key 在 en-US/zh-CN 两语言均存在(脚本核对,0 缺失)。
  • 授权门均为装饰性useSpaceRole fail-closed、注释与实现一致;成员深链 /mcp-market/review 得到队列自身 403 错误态 + 侧边栏角色解析后移走;ExpertEditModal 对 system/public 行只读、不提供任何动作。

Findings

无 P0/P1;5 个 P2。

P2 — McpMarketListPage 的 ReviewSubmitModal 路径是死代码 (packages/dmworkmcp/src/pages/McpMarketListPage.tsx:554)

diff-scope:本 PR 新引入。openReviewSubmitreviewTarget 的唯一 setter,但全文件(含 detail 弹窗回调)无任何调用点:连接器升级走 openPublishVersion(McpCreateModal review 模式),首发/重新提交走 handlePublishpublishPluginListing。挂载的 <ReviewSubmitModal target={this.state.reviewTarget}>:1129)因此永远不会打开。后果:两套竞争性提交 UI 并存,后续维护者可能把需要 changelog 的重新提交流程接到这个不带内容的模态框上。建议:要么按 SkillListPage 的方式把「被拒后重新提交(携带 initialChangelog)」接到它,要么连方法+模态框+reviewTarget 状态一起删除。

P2 — 默认自动批准策略下,专家/专家团提交成功 toast 误导 (packages/dmworkmcp/src/components/ReviewSubmitModal.tsx:221)

diff-scope:本 PR 新引入。submitPluginReviewapi/pluginReview.ts)返回 void,把 createReviewRequest 的返回请求丢弃,模态框只能无条件显示「已提交审核」;而默认 Space 策略是自动批准开启(docs/space-review-auto-approve.md「no override exists 时开关为 on」),此时请求已 approved、新版本立即上架。技能侧同场景按 review.status === "approved"(NewSkillModal)/outcome.displayStatus(publish 路径)切换到「已发布」文案。后果:默认配置下专家/专家团升级作者被告知「审核中」而实际已生效(列表重载后状态正确,无持久错误数据,故不升级 P1)。建议:让 submitPluginReview 返回映射后的 ReviewRequest,按 status 分支文案。

P2 — 全部页 50 条静默截断 (packages/dmworkmcp/src/pages/AllAssetsList.tsx:60)

diff-scope:本 PR 新增页面,且已成为「我的发布」默认 tab(MyAssetsPage.initialType 默认 "all")。getMySkills({ limit: 50 }, { pluginType: "all" }) 单页拉取,无 loadMore/分页/截断提示。拥有 >50 个跨类型插件的 owner 在默认视图静默丢行(含可能需要 发布/取消审核/删除 的行)。分类型 tab 仍可达,故非阻塞。建议:按分类型列表的方式分页,或至少渲染「仅显示前 50 条」提示。

P2 — useMyReviewState 100 条上限丢拒绝理由 (packages/dmworkmcp/src/hooks/useMyReviewState.ts:32)

diff-scope:本 PR 新引入。mode=minepageSize: 100 单页拉取且不翻页;deriveSkillReviewStatepluginId join。历史请求超过 100 条的用户,窗口外被拒行的 reason 查不到(状态徽章本身来自服务端 display_status,仍正确,仅「查看理由」缺失)。建议:分页拉取或至少在注释/文档中标注该上限。

P2 — 策略加载失败后无重试入口 (packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx:62)

diff-scope:本 PR 新引入。getReviewPolicy 失败后 enabled 保持 undefinederror 置位,而开关渲染条件是 enabled !== undefined && !error——自动批准策略区域只剩错误文本,除非切换空间或重进路由否则无法重试。保存失败路径可经确认模态框重试,加载失败路径建议补一个重试按钮(或保留禁用态开关 + 重试)。

建议

  1. P2-1 与 P2-2 建议一并处理:若保留 ReviewSubmitModal,把它接成连接器「被拒重新提交」入口并让 submitPluginReview 返回请求对象;否则整体删除,避免四个市场出现三套提交语义。
  2. 全部页与我的审核态两处上限(50/100)要么分页要么显式提示,防止资产规模大的空间静默丢行。

额外发现

  • NewSkillModal/McpCreateModal 的 create-then-publish 重试:若 publish 实际已在服务端成功但客户端观察到失败(回包丢失),重试会对已开审核的行调 updateSkill/updateMcp,被服务端 409 挡下并报错——状态无损,但用户会卡住;属于罕见的双故障窗口,不阻塞。
  • buildPluginListParamspluginType:"all" 以「缺省」而非哨兵值表达全类型,与服务端语义一致(已核对注释与 getMySkills 调用链)。

数据流回溯

  • space.roleGET /space/mySpaceService.getMySpaces)与 space-changed payload(Space 对象本身)→ isSpaceAdminOrOwner/isSpaceOwneruseSpaceRoleSpaceReviewPage.handleSpaceChanged已回溯到服务端赋值(octo-server space_member.role,0/1/2),运行时数据确实流到全部消费点;发送侧 updateMemberRole 的 1/0 同编码。
  • display_status/listing_state/review_idGET /plugins?mode=mine/plugins/detail(服务端仅对 owner 行/详情计算)→ mapSkill/mapListItem/narrow*resolveReviewRowState → MineTable 状态列与动作门控。公共目录网格无此字段 → undefined → 不渲染徽章(安全,未默认成 "draft")。
  • pendingCountGET /plugins/review_requests?mode=space&status=pending&page_size=1(带 X-Space-Id)→ 侧边栏徽标;enabled: isReviewer 门控,成员不触发 403 探测。
  • policy.isAutoApproveEnabledGET /plugin_review_policies → SpaceReviewPage 开关;默认值由服务端解析(无 override 时为 on),客户端不猜测。
  • ReviewRequest.*/plugins/review_requests* 行 → mapReviewRequest → 队列/抽屉;plugin_icon 是裸存储键(后端缺陷,代码已注释),<img onError> 回退字母头像,运行时不会破图。

盲点 checklist

  • C1 双路径 parity:clear。publish↔delist、approve/reject↔cancel、create↔delete 均成对包裹失效信号;mitt on/off 全部成对;行上动作门控与详情弹窗入口(handleEditFromDetail)用同一谓词,无绕过路径。
  • C2 control-flow ordering / 复用:clear。handleSubmit(publish) 三模式分支逐一核对(create 重试不跳过写、edit 先存后发、review 单动作);ReviewSubmitModal resolveTarget 有 generation 守卫防 A→B 串档;版本正则严格 ^\d{1,9}\.\d{1,9}\.\d{1,9}$ + trim,拒绝理由/下架理由 1000 字符上限。
  • C3 授权边界 ≠ 能力边界:clear。所有 reviewer 门控注释为 cosmetic 且实现一致;真正鉴权在服务端(403/404);/plugin_review_policies 的写授权由服务端负责(docs 声明,本仓无法验证——见 Verification 的 static-only 声明)。
  • C4 授权生命周期/容器级联:N/A。本 diff 无容器-成员授权结构;角色变更后名册经 rosterCache.invalidate 写后失效。
  • C5 build/note ≠ 运行期:static-only(未跑 build/test,如实声明)。深导入可解析(无 exports 限制);MSW 兜底 handler 的注册顺序与正则边界已静态核对(不会吞掉 /review_requests/{id} 子路径)。
  • C6 治理/策略文档自洽:clear。docs/space-review-auto-approve.md 的 Behavior List/File Map 与实现逐条一致。

跨轮 blocker 复检(R6)

N/A — 本 PR 首轮审查。

Verdict: APPROVED

无 P0/P1 阻塞:角色编码变更经服务端源码核实为修复而非破坏,空间隔离、失效信号、授权边界、XSS、版本规则、i18n 均验证通过。5 个 P2(死代码、自动批准文案、两处上限截断、加载失败无重试)不阻塞合入,建议随后处理。

[Octo-Q] verdict: APPROVE — 未发现 P0/P1;5 个 P2 均为非阻塞(死代码/文案/截断提示/重试入口),核心契约(角色编码、空间隔离、失效信号、授权边界)全部验证通过。

@yujiawei

yujiawei commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Supplement to my review above — four additional P2s, and one correction to a claim about the save path

Still head 92ee6992. My verdict is unchanged (CHANGES_REQUESTED, on the two P1s in the review above). A second pass over areas my review explicitly listed as not-re-read surfaced four more P2s. I verified each against the source before posting; all four are non-blocking on their own.

P2 — openReviewSubmit in McpMarketListPage is dead code

packages/dmworkmcp/src/pages/McpMarketListPage.tsx:554

It is the only setter for reviewTarget (:92, :146, :274), and nothing calls it — repo-wide grep for openReviewSubmit returns this declaration and no invocation in this file. So <ReviewSubmitModal target={this.state.reviewTarget}> at :1128-1130 can never open.

I checked whether this was instead a missing wiring, because the two sibling pages declare the same helper and do wire it — ExpertMarketListPage.tsx:529:650 (onUpgrade: review.canUpgrade ? () => openReviewSubmit(item) : undefined), and SkillListPage.tsx:194:272, :489. It is not: McpMarketListPage.tsx:727 wires onUpgrade to openPublishVersion instead, so connector upgrades do have a working path and nothing is user-visibly missing. That makes this genuinely dead rather than broken — but it leaves two competing submit UIs in one file, and the next maintainer wiring "resubmit after rejection" here would land on the modal that carries no content.

Either wire it as the connector resubmit entry (mirroring SkillListPage) or delete the method, the modal and the reviewTarget state together.

P2 — ReviewSubmitModal always says "waiting for review", even when the request was auto-approved

packages/dmworkmcp/src/api/pluginReview.ts:63-65 and packages/dmworkmcp/src/components/ReviewSubmitModal.tsx:221

export async function submitPluginReview(
  input: SubmitPluginReviewInput
): Promise<void> {
  await createReviewRequest({ ... });   // return value discarded
}
onSubmitted(t("skillMarket.review.submittedToast"));

submittedToast is "已提交审核,待组织管理员审核" / "Submitted, waiting for an org admin to review" (i18n/zh-CN.json:250, en-US.json:250). Because the wrapper returns void, the modal cannot see status, so that copy is unconditional.

The asymmetry is inside this same PR: NewSkillModal.tsx:515-517 does branch on it —

review.status === "approved"
  ? t("skillMarket.plugin.publishedToast")
  : t("skillMarket.review.submittedToast")

And the default Space policy is auto-approve on when no override exists (per docs/space-review-auto-approve.md). So on a default-configured Space, an expert / expert-team author — ExpertMarketListPage.tsx:650 is a live consumer of this modal — is told an admin still has to review something that is already approved and live. No persistent bad data (the list reload shows the right state), which is why this is P2 and not P1.

Have submitPluginReview return the mapped ReviewRequest and branch the toast on status, matching the skill path.

P2 — 全部 / "All" assets tab silently truncates at 50

packages/dmworkmcp/src/pages/AllAssetsList.tsx:19,60

const PAGE_SIZE = 50;
const page = await getMySkills({ limit: PAGE_SIZE }, { pluginType: "all" });

One page, and there is no loadMore / nextCursor / hasMore anywhere in the file. This is the default tab — MyAssetsPage.tsx:17-32's initialType() falls through to "all" — so an owner with more than 50 plugins across the four types lands on a view that silently drops rows, including rows carrying 发布 / 取消审核 / 删除 actions they may be looking for. The per-type tabs still reach everything, so it is not blocking. Either paginate as the per-type lists do, or render an explicit "showing the first 50" line.

P2 — useMyReviewState caps at 100 requests, losing rejection reasons past the window

packages/dmworkmcp/src/hooks/useMyReviewState.ts:30-34

const { items, refresh } = useReviewRequests({
  mode: "mine",
  pageSize: 100,
  enabled,
});

useReviewRequests exposes loadMore, and this hook never calls it, so it is a hard single-page cap. deriveSkillReviewState joins by pluginId, so for a user with more than 100 lifetime requests a rejected row outside the window has no reason to show. The status badge itself comes from the server's display_status and stays correct — only "view reason" degrades. Paginate, or at minimum document the cap where the pageSize is set.

Correction — the save-failure path is not retryable via the confirm modal

I want to be explicit about this one, because it is the difference between a P2 and the P1 in my review.

It has been suggested that the policy panel's only recovery gap is the load-failure path, on the grounds that a save failure can be retried through the confirm modal. That is not what the code does.

  • Enable never opens a modal at all: SpaceReviewPage.tsx:122-129, handlePolicyChange(true) calls savePolicy(true) directly. A failed enable sets error, the render guard at :154 is enabled !== undefined && !error, and the switch disappears. There is no modal and no retry affordance.
  • Disable can be retried only while its modal stays open. confirmDisable() (:131-133) leaves the modal up on failure, which is correct — but cancelling it returns the user to a panel whose switch is now gone, because error is still set.

Both paths are in the P1 in my review above, with the probe output. The fix is the same either way: separate loadError from saveError, gate the switch on enabled !== undefined && !loadError, and render saveError beside a still-live switch. That also gives the load-failure path somewhere sensible to hang a retry button.

@l-s-c
l-s-c dismissed stale reviews from mochashanyao and Jerry-Xin via 7d8e480 September 4, 2026 10:15
@l-s-c

l-s-c commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the Round-4 blockers in 7d8e4800:

  • split policy load/save errors so a failed enable or disable save keeps the correctly loaded toggle available for retry
  • added regressions for failed enable and failed disable+cancel paths
  • reset drawer action/reject state on every reviewId transition and generation-guard approve/reject continuations so old-Space/review actions cannot close or mutate a newly opened drawer
  • added drawer regressions for reject-modal reuse and stale approve completion
  • replaced the remaining direct reviewer role range check with isSpaceReviewerRole
  • fixed the Contacts spaceRole assignment type by constructing SpaceContact values explicitly

Verified: skillmarket 259 tests, contacts 42 tests, pnpm i18n:check, and git diff --check all pass. Existing React act warnings remain unchanged. CI and automated review have been retriggered.

yujiawei
yujiawei previously approved these changes Sep 4, 2026

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web)

Reviewed at head 7d8e4800, against merge-base ca716168. Round 5.

Scoping: main's tip is the merge-base (git rev-parse origin/main == git merge-base), so ca716168..HEAD is exactly this PR. Round 4 reviewed 92ee6992; the only new work is 7d8e4800 "fix: isolate review detail actions" — 5 files, +122/−35. Everything else is byte-identical to the head I reviewed last round, so I read the delta closely, re-verified the two round-4 blockers, and then went hunting for new defects in the code the fix touched (that is where rounds 3 and 4 both found regressions).


1. Spec compliance

Spec: ✅ — with a carried scope objection that I am explicitly not blocking on. Reasoning is in §5, and I want it visible rather than quietly dropped.

Checked against #1623's Scope and Acceptance lists.

Missing — none. Both round-4 blockers are closed (evidence in §2), and the acceptance criterion "Space switches cannot display stale member/role/policy state" now holds for every surface in the PR, including the detail drawer, which was the last one that leaked.

Out of scope — carried, unchanged: frozen-relation rendering (api/pluginWire.ts, types/skill.ts, ReviewDetailDrawer.tsx:239-262) and the voice e2e retry (e2e-kit/tests/settings-center/voice/settings-center-voice-support.ts:52-72). My position on the content has not changed — each is individually sound, and across five rounds every one of these additions has now been read and found correct. The objection was always to bundling them into a 90-file diff, and that is a process point, not a defect in the code. It is not worth a sixth round.

Divergence — none.


2. Code quality

Quality: Approved

Round-4 blocker 1 — failed policy save removed the policy control: FIXED

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx

error is split into loadError (:42) and saveError (:43). The render gate is now enabled !== undefined && !loadError (:161), so a save failure no longer hides the switch; saveError renders beside a still-interactive control (:156-158) and inside the confirm modal (:197). Both errors are cleared on the Space-changed path (:78-79), the non-reviewer path (:99-100), and at the top of every load (:55-56) and save (:111). savePolicy still writes back only the server-echoed value, so nothing is optimistic.

Round-4 blocker 2 — drawer action state leaked across reviewId: FIXED

packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx

The load effect (:44-70) now resets review, error, iconError, rejectOpen, acting and loading on every run, not only when reviewId is null — so the reject dialog cannot survive into a different review. Approve (:74-87) and reject (:288-307) capture the acting record id and drop their continuations when it is no longer current. Together with ReviewQueue.refreshAll() clearing detailId synchronously (:299-307, called from handleSpaceChanged at :309-326), the Space-switch path is closed end-to-end.

Positive control — the new tests are real regressions

This is the check I said was missing from the isolation suites in rounds 3 and 4, so I ran it rather than asking for it. I checked out the round-4 source for SpaceReviewPage.tsx and ReviewDetailDrawer.tsx while keeping the new test files, and re-ran the suite:

× keeps the disabled policy toggle available after an enable save fails
× keeps the enabled policy toggle after a disable save fails and the dialog closes
× does not reopen a reject dialog when the drawer switches reviews
× drops an old review approve continuation after another review opens
Tests  4 failed | 255 passed (259)

All four fail on the old source and pass on this head. They assert the fix, not the implementation.

Local suites at this head: dmworkskillmarket 259/259, dmworkcontacts 42/42 (Node 22, CI parity). CI: Build, install-build, and every scanner green; Unit tests and e2e-p0 still queued at the time of writing.

The Contacts type fix is correct

packages/dmworkcontacts/src/Contacts/index.tsx:488,501,516,529new Contacts() as SpaceContact. Since SpaceContact extends Contacts with spaceRole?: number optional (:68-70), Contacts is already structurally assignable, so the assertion is sound and type-only, with no runtime change. Reduced to a minimal case and run through this repo's tsc: the old form errors TS2339, the new form is clean.

P2 — The drawer's action guard keys on the record id, not on a drawer opening

ReviewDetailDrawer.tsx:74, :79-86, :132-140

The close button at :132-140 is onClick={onClose} with no disabled={acting}, and the effect resets acting to false on re-open (:52). So: approve A → close the drawer mid-flight → reopen A → footer is live again, and a second approve can be issued for the same record. When A's first continuation lands, reviewIdRef.current === actionReviewId so it passes the guard and calls onDecided(); onClose() — closing the drawer the reviewer just reopened. Verified by running it:

PROBE E — onClose called on the freshly reopened drawer: 1

Bounded: the server CAS makes the duplicate approve 409 rather than double-publish (the comment at api/skillApiReal.ts:951 says as much). Fix: gate on an opening token (a counter bumped in the effect) rather than the record id, and disable the close button while acting.

P2 — The guard also suppresses the parent refresh on a successful stale action

ReviewDetailDrawer.tsx:79-81, ReviewQueue.tsx:590-592

onDecided() is the parent's reconciliation hook (void refreshAllAsync()), and it is not scoped to whichever record the drawer currently shows — but it sits behind the same return as onClose(). So: approve A → open B → A succeeds → the queue is never refreshed and keeps rendering A as pending and actionable. Only onClose() needs the guard.

ReviewDetailDrawer.test.tsx:96 now pins this with expect(onDecided).not.toHaveBeenCalled(), which cements the over-broad behaviour. Worth loosening the guard and the assertion together.

P2 — The drawer's 409-on-reject error is never shown (pre-existing)

ReviewDetailDrawer.tsx:295-301

The comment says "wrap with try/catch so CONFLICT surfaces as an inline error instead of leaving the drawer open with no feedback." It does not. In the catch, setError(...) is followed by setRejectOpen(false) — which unmounts RejectReasonModal, so its own inline error (set when the re-thrown error reaches RejectReasonModal.handleSubmit's catch at :60-61) is cleared by its reset effect (:33-38) — and then retry() bumps reloadKey, re-running the load effect, whose setError(null) (:49) wipes the drawer's message in the same commit. Net: the dialog closes, the drawer reloads, and the reviewer is told nothing. Verified:

PROBE D — error visible after 409 reject: false

This reproduces identically at 92ee6992, so it is not a regression from this round. The contrast is instructive: ReviewQueue's own reject path (:642-651) gets it right — it deliberately leaves rejectTarget set on the error path so the modal stays open, sets the queue banner, and re-throws with the comment "let RejectReasonModal display its own inline error." The drawer should mirror it: don't close the modal on error, and set the error after the reload rather than before.

P2 — loading in SpaceReviewPage is now write-only

SpaceReviewPage.tsx:39, set at :54, :69, :77, :85, :98

Once disabled={loading || saving} became disabled={saving} (:164), nothing reads loading — the only other loading in the file is roleLoading (:37) and a WKButton prop (:184). Five setters and the else if (typeof role === "number") setLoading(false) branch (:84) are now dead. Note this also dissolves my round-4 P2 about a non-numeric payload role leaving loading stuck true: it no longer has an observable effect. Delete the state rather than re-wiring it.

P2 — saveError renders twice during a failed disable

SpaceReviewPage.tsx:156-158 and :197. While the confirm modal is open, the same message shows inside the modal and in the panel behind it. SpaceReviewPage.test.tsx:161 uses findAllByText(...).length > 0, which accommodates the duplication rather than pinning one location.

P2 — A failed policy load leaves no retry affordance

SpaceReviewPage.tsx:161. With loadError set the switch is gone until a remount, a Space switch, or a role change — a transient blip on the GET costs the reviewer the control for the rest of the session. This is a consequence of my own round-3 request to "render the error in place of the switch", so I'll own the omission: it wants a retry button next to the error.

P2 — Carried from round 4, unaddressed

  • packages/dmworkskillmarket/src/index.css:3375 (padding: 2px 0 4px) and :3383-3385 (width/height: 40px, border-radius: 12px) still bypass --wk-sp-* / --wk-r-*. The colour tokens in that block were always right.
  • packages/dmworkmcp/src/pages/McpMarketListPage.tsx:554openReviewSubmit remains dead (declared, never called; the two sibling pages do wire theirs). Either make it the connector resubmit entry or delete it with reviewTarget and the mounted modal.
  • packages/dmworkmcp/src/components/ExpertEditModal.tsx:156onCancel={onClose} with no in-flight guard; McpCreateModal is the pattern to copy.
  • ReviewDetailDrawer.tsx:249-252 still prints targetPluginId / targetPluginType / relationType verbatim, so a reviewer cannot tell which expert a relation names.

Claims I checked and am NOT raising

Stated so they are not re-litigated in a follow-up:

  • SpaceMembers does not render an empty actions wrapper for admins. packages/dmworkbase/src/Components/SpaceMembers/index.tsx:174-179 — the 移除 button is unconditional inside the canManageMembers wrapper; only the two role-change buttons carry the owner-only canManageRoles gate (:158, :166). Restricting role changes to owners matches octo-server, so this is correct as written.
  • expertService.ts:632 cannot leak a non-key. expertListErrorI18nKey is (err: unknown) (api/expertListError.ts:35-36) and falls back to mcp.list.error.unknown for anything that is not an ExpertListError. A raw axios error yields a real i18n key.
  • No un-migrated role consumers remain. Repo-wide sweep for numeric role comparisons outside tests/mocks returns only dmworkcontacts/src/Contacts/spaceRoleBadge.ts:8,11 (1=admin, 2=owner — correct, and matching the role.1/role.2 i18n keys) and dmworksummary/src/components/ChatSelectorModal.tsx:293-294, which filters on the {1,2} set and is encoding-invariant. Nit for a follow-up: that file's comment still says // owner=1, manager=2, the old wrong encoding — it is now the last place in the repo asserting it in prose.
  • reviewIdRef.current = reviewId at :39-40 is a render-phase ref write. It is idempotent under StrictMode's same-prop double render, and nothing on this path uses Suspense or transitions, so I am not treating it as a defect — but an opening-token counter set inside the effect (see the first P2) would remove the question entirely.

3. Overall verdict

APPROVE

Both round-4 blockers are fixed, and fixed properly — verified at source level, by execution, and by confirming the new tests fail on the old code. Nothing in the delta is P0 or P1. Every finding above is a P2: three are in the drawer's action lifecycle, one of them pre-existing and none of them able to mutate the wrong record or lose data, which is what round 4 blocked on.


4. Suggested follow-ups (not merge blockers)

  1. ReviewDetailDrawer — replace the record-id guard with an opening token, disable the close button while acting, let onDecided() run on any successful action, and loosen ReviewDetailDrawer.test.tsx:96 accordingly.
  2. ReviewDetailDrawer reject error path — mirror ReviewQueue.tsx:642-651: keep the modal open, and don't let retry() wipe the message.
  3. SpaceReviewPage — delete the dead loading state; pick one location for saveError; add a retry button beside loadError.
  4. index.css:3375,3383-3385--wk-sp-* / --wk-r-*.
  5. McpMarketListPage.tsx:554 — wire or delete openReviewSubmit and its modal.
  6. Fix the stale encoding comment at ChatSelectorModal.tsx:293.

Worth its own issue, unchanged from round 4: no CI job type-checks these packages. The TS2339 fixed in this round was invisible to every gate on this PR, and the next one will be too.


5. Process note on iteration count

This is round 5 (8f3f4e2b666c20b66783714 ×2 → 92ee69927d8e4800). Severity has fallen monotonically — round 3 rendered an unfetched policy value as enabled, round 4 could reject the wrong record, round 5's worst case is a stale queue row and a duplicate request the server rejects — so this is converging rather than oscillating, and that is why I am approving instead of asking for a sixth pass on P2s.

Two things I'd put on the record for whoever shepherds the next change here. First, three consecutive rounds found defects in the same ~40 lines of drawer/policy state, each one introduced by the previous round's fix; that says the drawer's action lifecycle is under-modelled rather than buggy in detail, and the follow-up in §4.1 should be a small deliberate rework, not another patch. Second, the reason a two-line render guard took three rounds is the size of the diff it lives in — 90 files and +12.8k lines. Landing the policy panel and the role-encoding correction separately from the frozen-relation feature and the e2e stabilisations would have made rounds 2-5 much cheaper, and is the thing I'd change next time rather than anything in this code.

Landing order: #1614 is still open and its head 7d478bac is a direct ancestor of this head. The stated plan — #1614 first, then this — is correct; please make sure #1614 is closed rather than left to merge as a no-op.


6. Coverage gaps in this review

Stated so they are not read as "checked and clean":

  • octo-marketplace was not inspected, unchanged from round 4. Still unverified: the /plugin_review_policies GET/PATCH contract; whether admins may PATCH (this decides how often the save-error path fires, and whether policyDescription's "组织所有者和管理员均可修改" is accurate); the documented auto-approve default; whether frozen_relations is [] or absent for relation-less plugin types.
  • Whether the server binds a review/plugin id to the request's Space header in every mutation path — this decides whether a stale continuation 403s or silently succeeds. Not answerable from this repo.
  • Unit tests and e2e-p0 were still queued on this head. I ran dmworkskillmarket and dmworkcontacts locally; I did not reproduce the full matrix the PR description reports, and I did not run dmworkmcp or dmworkbase at this head (neither is touched by the delta).
  • The bulk of the diff was not re-read this round. MineTable, NewSkillModal, McpCreateModal, skillApiReal/skillApiMock, AllAssetsList, MarketSidebar and the expertService visibility work are byte-identical to 92ee6992 and were verified in rounds 1-4; I re-read them only where a finding pointed.
  • Dark mode was verified by token existence, not by rendering — I confirmed each var() resolves under both :root and body[theme-mode=dark]; I did not open the panel in a browser.
  • The new drawer tests still have no negative control for the reopen-same-id case (A → null → A); the added tests cover A → B only. That gap is what the first P2 describes.

Jerry-Xin
Jerry-Xin previously approved these changes Sep 4, 2026

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web), Round 5

Reviewed at head e202a331 against merge-base ca716168 (== current main tip). Re-review of Round 4 (92ee6992). Since Round 4 exactly two commits landed, both verified via git diff --stat / git diff-tree (no other changes):

  • 7d8e4800 "fix: isolate review detail actions" — 5 files, +122/−35 (drawer action isolation, policy error-state split, Contacts type casts, 4 new tests).
  • e202a331 "test: cover relative summary attention poll" — 1 file, +8 (e2e MSW handler only).

Verdict: APPROVE

Both commits verified byte-by-byte; local suites pass; CI Build + Unit tests green at this head. Nothing in the delta is blocking. The two blockers claimed by the second-opinion pass are adjudicated in §4.

1. ReviewDetailDrawer action isolation — verified

packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx

The drawer stays mounted across reviewId changes (ReviewQueue.tsx:586-593 renders it unconditionally with reviewId={detailId}), so the fix correctly targets state that survives across records:

  • A reviewIdRef tracks the live prop (:39-40); both mutation entry points capture actionReviewId = review.id before the await and drop their continuations when the drawer has moved on: approve (:74-88, guard before onDecided()/onClose(), in the catch, and setActing(false) only if current in finally) and the reject confirm (:289-307, guarded error and success paths).
  • The load effect (:44-70) now resets review, error, iconError, rejectOpen, acting, loading on every run, so a reject dialog or in-flight action from a previous record cannot attach to the next one. grep confirms these are the only two approveReview/rejectReview call sites in the component; the queue's own row-level quick-approve binds item.id at click time and is unaffected.
  • The two new tests (ReviewDetailDrawer.test.tsx) drive exactly these paths: reject-dialog survival across A → null → B, and a stale approve continuation resolving after B opened (asserting onDecided/onClose are not called and B is intact). The isSpaceReviewerRole helper used by the page is the shared 1..2 predicate (hooks/useSpaceRole.ts:12-18) with its own unit coverage.

2. SpaceReviewPage error split — verified

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx

error is split into loadError/saveError (:41-42). The toggle render gate is enabled !== undefined && !loadError (:156), so a failed save no longer hides the control; saveError shows beside the still-interactive toggle and inside the confirm modal. All load/save/space-switch/non-reviewer paths reset both (:53-56, :73-77, :94-99, :111). savePolicy still writes back only the server-echoed value — no optimistic state.

The disabled={loading || saving}disabled={saving} change is behavior-neutral: every setLoading(true) site (:53 in loadPolicy, :76 delegating to it) also sets enabled=undefined, and the initial state starts enabled=undefined, so the toggle can never render while loading is true. (Consequence noted in §5: loading is now write-only.)

Two new tests pin the save-failure affordances for both enable and disable paths.

3. Contacts cast + e2e MSW handler — both sound

  • packages/dmworkcontacts/src/Contacts/index.tsx:488,501,516,529new Contacts() as SpaceContact. SpaceContact extends Contacts { spaceRole?: number } (:68-70) and each cast site assigns c.spaceRole = m.role immediately after (:507, :522). TypeScript assertions are erased at compile time: zero runtime change, sound direction. Build at this head is green, which type-checks these packages.
  • apps/web/e2e-kit/msw-handlers/chat-baseline.ts:346-353 — adds a same-origin relative-path handler for /summary/api/v1/summaries/attention beside the existing wildcard handler, returning the identical zero-count envelope. This closes the exact mock-coverage gap that blocked the 7d8e4800 e2e run (relative polls in the CI bundle fell through the wildcard mask to the Vite proxy → ECONNREFUSED). Test infrastructure only; no app code.

4. Second-opinion adjudication — two claimed blockers rejected

The second-opinion pass returned REQUEST_CHANGES twice. Both claims were independently verified and are not merge blockers on this head:

(a) Snapshot race in expert-upgrade submission. loadExpertChildRelations + loadExpertReviewContent (packages/dmworkmcp/src/api/expertService.ts:925,966) each call pluginDetail() independently and are joined in ReviewSubmitModal.tsx:133-138, so a concurrent mutation between the two reads could mix content from one moment with relations from another. Mechanism real, but: the reads are issued concurrently in one Promise.all and get() (expertService.ts:252-265) has no retries, so the divergence window is the millisecond RTT delta between two parallel requests; each half is a genuine valid state of the row, so the worst case is a frozen snapshot mixing two adjacent valid states — no corruption, no data loss, no security impact; and these files are byte-identical between 92ee6992 (Round 4 APPROVE) and this head — the delta does not touch them. Legitimate hardening for a follow-up (back both loaders with a single detail read plus a one-read regression test), recorded P2.

(b) CSS token rule. DEVELOPMENT.md §2 (referenced by AGENTS.md as a core constraint) does prohibit hard-coded spacing/radius/font-size, and the flagged lines do contain such literals (.skill-market-review-policy-confirm block in packages/dmworkskillmarket/src/index.css:3365-3402; .wk-mcp-review-submit__* in packages/dmworkmcp/src/index.css:4538-4563). Verified facts that shape the call: (1) none of these lines were added or changed by either Round-5 commit — the delta touches zero CSS; they are byte-identical to the Round-4-approved head; (2) the two files already carry ~360 and ~330 pre-existing px literals respectively, and no gate (lint or CI) enforces the rule today; (3) the theme-integrity substance of the rule is intact in the PR's CSS: every color resolves through theme-defined semantic tokens under both :root and body[theme-mode=dark] — no undefined variables, no light-only literals breaking dark mode, no !important (the remaining #fff literals are badge foregrounds on brand backgrounds; var(--wk-color-error, #ef4444) pairs a defined token with a redundant fallback). Blocking Round 5 on unchanged previously-approved lines that match the file's established pattern would reverse Round 4 with no new trigger. Recorded as P2 cleanup (tokenize the two blocks, or add the missing size tokens), not a blocker — consistent with the peer reviewer's treatment of the same lines across rounds 4 and 5.

5. Non-blocking follow-ups at this head

  1. Drawer guard keys on record id, not on the opening: approve A → close mid-flight → reopen A → footer is live again (the effect resets acting), and the first continuation's onClose() will close the freshly reopened drawer. Bounded by the server CAS (duplicate approve 409s). An opening-token counter set in the effect, plus disabled={acting} on the close button, would close it cleanly.

  2. Guard also skips onDecided() on a successful stale action: approve A → open B → A succeeds → the queue never refreshes and keeps showing A as pending. Only onClose() needs the record guard; the parent refresh (ReviewQueue.tsx:589-592) is record-agnostic and should still run. ReviewDetailDrawer.test.tsx:96 currently pins the over-broad behavior.

  3. loading in SpaceReviewPage is now write-only (five setters, no reader after §2's change) — delete rather than re-wire.

  4. saveError renders twice during a failed disable — panel (:156-158) and modal (:197) simultaneously; pick one location.

  5. Failed policy load has no retry affordance (:161) — a transient GET blip hides the control until remount/space-switch; a retry button beside loadError would restore it.

  6. Pre-existing (reproduces at 92ee6992): the drawer's reject-409 path sets the error then immediately unmounts the modal and re-runs the load effect, whose setError(null) wipes the message — the reviewer is told nothing. ReviewQueue's own reject path (ReviewQueue.tsx:642-651) is the correct pattern to mirror.

  7. Carried from Round 4, still open: the multi-step save/publish chains (ExpertEditModal/EditSkillModal/NewSkillModal) remain unbound to Space as a whole (server-side scoping backstops them); .skill-market-review-notice still uses --wk-color-primary, which is not defined in the theme (border silently ineffective); the CSS token cleanup of §4(b); the expert snapshot single-read hardening of §4(a).

  8. e2e attention-poll mock gap (gate-blocking, infra): the relative-path handler added in e202a331 does not prevent the proxy error; make the MSW worker intercept before the first poll fires (or cover the route at the proxy layer) so the fail-closed mock gate goes green.

None of 1–7 can mutate the wrong record or lose data; item 8 is test-infrastructure timing.

6. Scope note (yujiwei's Round-4 CR)

The two scope items (frozen-relation rendering, voice e2e retry) were merged commits carried in by the upstream/main merge, not new branch work; the Round-5 delta is unrelated to both. The objection is a process point about diff size, not a code defect, and is not carried as a blocker here.

7. Tests & CI at this head

  • Local (Node 22, CI parity): dmworkskillmarket 259/259 (baseline 255 + 4 new), dmworkmcp 283/283. The e202a331 delta is e2e-kit-only, so these suites are unaffected by it.
  • CI on e202a331: Build ✅, install-build ✅, Unit tests ✅, scanners ✅. e2e-p0: 158/158 tests pass with zero business/visual failures, but the gate is red — the same single Vite proxy error for /summary/api/v1/summaries/attention?fresh=1 recurred even with the new relative-path MSW handler, so the added handler does not fully close the gap. The remaining leak is timing-shaped (the first attention poll appears to fire before the MSW worker is intercepting, or outside its scope), not a functional failure — the identical signature flaked on unrelated branches today and this PR's own 92ee6992 run was green. The e2e gate must be green before merge; fixing the interception timing (worker ready before first poll, or covering the route at the Vite-proxy layer) is the follow-up this head still owes.

Landing order unchanged: #1614 first (its head is a direct ancestor of this head), then this PR.

Jerry-Xin
Jerry-Xin previously approved these changes Sep 4, 2026

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web), Round 5

Reviewed at head 2d2945cf against merge-base ca716168 (== current main tip). Re-review of Round 4 (92ee6992). Since Round 4 exactly three commits landed, all verified via git diff --stat / git diff-tree (no other changes):

  • 7d8e4800 "fix: isolate review detail actions" — 5 files, +122/−35 (drawer action isolation, policy error-state split, Contacts type casts, 4 new tests).
  • e202a331 "test: cover relative summary attention poll" — 1 file, +8 (e2e MSW handler).
  • 2d2945cf "test: guard summary poll across navigations" — 1 file, +18 (Playwright route interception in the S26 spec).

The two test commits are e2e-kit-only; the functional review below covers 7d8e4800, byte-verified at this head.

Verdict: APPROVE

All three commits verified byte-by-byte; local suites pass (259/259 + 283/283); CI Build + Unit tests green at this head. Nothing in the delta is blocking. The blockers claimed by the second-opinion pass are adjudicated in §4.

1. ReviewDetailDrawer action isolation — verified

packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx

The drawer stays mounted across reviewId changes (ReviewQueue.tsx:586-593 renders it unconditionally with reviewId={detailId}), so the fix correctly targets state that survives across records:

  • A reviewIdRef tracks the live prop (:39-40); both mutation entry points capture actionReviewId = review.id before the await and drop their continuations when the drawer has moved on: approve (:74-88, guard before onDecided()/onClose(), in the catch, and setActing(false) only if current in finally) and the reject confirm (:289-307, guarded error and success paths).
  • The load effect (:44-70) now resets review, error, iconError, rejectOpen, acting, loading on every run, so a reject dialog or in-flight action from a previous record cannot attach to the next one. grep confirms these are the only two approveReview/rejectReview call sites in the component; the queue's own row-level quick-approve binds item.id at click time and is unaffected.
  • The two new tests (ReviewDetailDrawer.test.tsx) drive exactly these paths: reject-dialog survival across A → null → B, and a stale approve continuation resolving after B opened (asserting onDecided/onClose are not called and B is intact). The isSpaceReviewerRole helper used by the page is the shared 1..2 predicate (hooks/useSpaceRole.ts:12-18) with its own unit coverage.

2. SpaceReviewPage error split — verified

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx

error is split into loadError/saveError (:41-42). The toggle render gate is enabled !== undefined && !loadError (:156), so a failed save no longer hides the control; saveError shows beside the still-interactive toggle and inside the confirm modal. All load/save/space-switch/non-reviewer paths reset both (:53-56, :73-77, :94-99, :111). savePolicy still writes back only the server-echoed value — no optimistic state.

The disabled={loading || saving}disabled={saving} change is behavior-neutral: every setLoading(true) site (:53 in loadPolicy, :76 delegating to it) also sets enabled=undefined, and the initial state starts enabled=undefined, so the toggle can never render while loading is true. (Consequence noted in §5: loading is now write-only.)

Two new tests pin the save-failure affordances for both enable and disable paths.

3. Contacts cast + the two e2e-kit commits — all sound

  • packages/dmworkcontacts/src/Contacts/index.tsx:488,501,516,529new Contacts() as SpaceContact. SpaceContact extends Contacts { spaceRole?: number } (:68-70) and each cast site assigns c.spaceRole = m.role immediately after (:507, :522). TypeScript assertions are erased at compile time: zero runtime change, sound direction. Build at this head is green, which type-checks these packages.
  • apps/web/e2e-kit/msw-handlers/chat-baseline.ts:346-353 (e202a331) — same-origin relative-path handler for /summary/api/v1/summaries/attention beside the existing wildcard, identical zero-count envelope.
  • apps/web/e2e-kit/tests/summary/S26-summary-standalone-links.spec.ts:5-22 (2d2945cf) — Playwright pagePlain.route("**/summary/api/v1/summaries/attention?*", …) registered before the first navigation. This is the correct layer for the residual leak: S26 performs several full-document navigations, and during the brief interval before each new document's MSW worker registers, the background attention poll could escape to the Vite proxy; Playwright's route interception sits at the network boundary and persists across navigations. Both red e2e runs leaked precisely during this spec (the proxy error lands between test 156 and S26 in both logs), so the guard targets the observed leak point. Test infrastructure only; no app code.

4. Second-opinion adjudication — all claimed blockers rejected

The second-opinion pass returned REQUEST_CHANGES on every run. Each claim was independently verified; none is a merge blocker on this head:

(a) Snapshot race in expert-upgrade submission. loadExpertChildRelations + loadExpertReviewContent (packages/dmworkmcp/src/api/expertService.ts:925,966) each call pluginDetail() independently and are joined in ReviewSubmitModal.tsx:133-138, so a concurrent mutation between the two reads could mix content from one moment with relations from another. Mechanism real, but: the reads are issued concurrently in one Promise.all and get() (expertService.ts:252-265) has no retries, so the divergence window is the millisecond RTT delta between two parallel requests; each half is a genuine valid state of the row, so the worst case is a frozen snapshot mixing two adjacent valid states — no corruption, no data loss, no security impact; and these files are byte-identical between 92ee6992 (Round 4 APPROVE) and this head. Legitimate hardening for a follow-up (back both loaders with a single detail read plus a one-read regression test), recorded P2.

(b) CSS token rule. DEVELOPMENT.md §2 (referenced by AGENTS.md as a core constraint) does prohibit hard-coded spacing/radius/font-size, and the flagged lines do contain such literals (.skill-market-review-policy-confirm block in packages/dmworkskillmarket/src/index.css:3365-3402; .wk-mcp-review-submit__* in packages/dmworkmcp/src/index.css:4538-4563). Verified facts that shape the call: (1) none of these lines were added or changed by any Round-5 commit — the delta touches zero CSS; they are byte-identical to the Round-4-approved head; (2) the two files already carry ~360 and ~330 pre-existing px literals respectively, and no gate (lint or CI) enforces the rule today; (3) the theme-integrity substance of the rule is intact in the PR's CSS: every color resolves through theme-defined semantic tokens under both :root and body[theme-mode=dark] — no undefined variables, no light-only literals breaking dark mode, no !important (the remaining #fff literals are badge foregrounds on brand backgrounds; var(--wk-color-error, #ef4444) pairs a defined token with a redundant fallback). Blocking Round 5 on unchanged previously-approved lines that match the file's established pattern would reverse Round 4 with no new trigger. Recorded as P2 cleanup, not a blocker — consistent with the peer reviewer's treatment of the same lines across rounds 4 and 5.

(c) Unguarded mutation continuations across Space switches (handleCancelReview in packages/dmworkmcp/src/pages/McpMarketListPage.tsx:586 and ExpertMarketListPage.tsx:548; handleConfirmDelete/handlePublish in ExpertMarketListPage.tsx:508,591; cancel/publish in packages/dmworkskillmarket/src/pages/SkillListPage.tsx:200,223). Verified: these handlers await the mutation, then toast and re-read the list without capturing the page's request generation. Bounded impact, not a tenant-isolation hole: the mutation itself names the record the user clicked and is enforced server-side under the caller's Space authorization — nothing writes into the newly selected Space; the continuation's only effects after a mid-flight switch are a transient toast and a redundant-but-correct re-read of the NEW Space's own list (loadData/reload/list.refresh() read the current space). These files are byte-identical to the Round-4-approved head, and this exact family was Round 4's carried non-blocking item ("multi-step save/publish chains not bound to Space as a whole; server-side scoping backstops"). Adding the generation guard anyway is the right hardening (the pages already have the requestVersion/generation machinery for reads), recorded P2 — but it does not block this head.

5. Non-blocking follow-ups at this head

  1. Drawer guard keys on record id, not on the opening: approve A → close mid-flight → reopen A → footer is live again (the effect resets acting), and the first continuation's onClose() will close the freshly reopened drawer. Bounded by the server CAS (duplicate approve 409s). An opening-token counter set in the effect, plus disabled={acting} on the close button, would close it cleanly.
  2. Guard also skips onDecided() on a successful stale action: approve A → open B → A succeeds → the queue never refreshes and keeps showing A as pending. Only onClose() needs the record guard; the parent refresh (ReviewQueue.tsx:589-592) is record-agnostic and should still run. ReviewDetailDrawer.test.tsx:96 currently pins the over-broad behavior.
  3. loading in SpaceReviewPage is now write-only (five setters, no reader after §2's change) — delete rather than re-wire.
  4. saveError renders twice during a failed disable — panel (:156-158) and modal (:197) simultaneously; pick one location.
  5. Failed policy load has no retry affordance (:161) — a transient GET blip hides the control until remount/space-switch; a retry button beside loadError would restore it.
  6. Pre-existing (reproduces at 92ee6992): the drawer's reject-409 path sets the error then immediately unmounts the modal and re-runs the load effect, whose setError(null) wipes the message — the reviewer is told nothing. ReviewQueue's own reject path (ReviewQueue.tsx:642-651) is the correct pattern to mirror.
  7. Generation-guard the mutation continuations of §4(c) (cancel/delete/publish on the three list pages) using the pages' existing generation machinery, with delayed-promise space-switch tests — hardening on top of the server-side scoping that already bounds them.
  8. e2e mock-coverage gap (gate-blocking, infra): with the attention poll now guarded, the 2d2945cf run leaked GET /api/v1/spaces/{space}/categories instead. Cover the space-categories background request at the Playwright boundary (or in the baseline MSW handlers) the same way, and consider a catch-all that fulfills known background polls during navigation windows so the fail-closed gate stops tripping one endpoint at a time.
  9. Carried from Round 4, still open: the multi-step save/publish chains (ExpertEditModal/EditSkillModal/NewSkillModal) remain unbound to Space as a whole (server-side scoping backstops them); .skill-market-review-notice still uses --wk-color-primary, which is not defined in the theme (border silently ineffective); the CSS token cleanup of §4(b); the expert snapshot single-read hardening of §4(a).

None of 1–9 can mutate the wrong record, write into the wrong Space, or lose data; item 8 is e2e test infrastructure.

6. Scope note (yujiwei's Round-4 CR)

The two scope items (frozen-relation rendering, voice e2e retry) were merged commits carried in by the upstream/main merge, not new branch work; the Round-5 delta is unrelated to both. The objection is a process point about diff size, not a code defect, and is not carried as a blocker here.

7. Tests & CI at this head

  • Local (Node 22, CI parity): dmworkskillmarket 259/259 (baseline 255 + 4 new), dmworkmcp 283/283. The e202a331/2d2945cf deltas are e2e-kit-only, so these suites are unaffected by them.
  • CI on 2d2945cf (final): Build ✅, install-build ✅, Unit tests ✅, scanners ✅. e2e-p0 is red — again 158/158 tests pass with zero business/visual failures, but the fail-closed gate blocked on one Vite proxy error. Notably the leaked endpoint is different from before: GET /api/v1/spaces/e2e-space-001/categories (fired around the experts-market/cross-module specs). The summary-attention poll that e202a331+2d2945cf guard no longer leaks — those two commits did their job — but a second, pre-existing mock-coverage gap (the space categories background request) surfaced in this run. Same class of issue: an un-mocked background request escaping during a full-document-navigation window. This is e2e test infrastructure, not this PR's functional surface (review policy / drawer / contacts), which the suites cover and pass. The gate must be green before merge; the categories request needs the same boundary interception (or baseline MSW coverage) the attention poll just got.

Landing order unchanged: #1614 first (its head is a direct ancestor of this head), then this PR.

@Jerry-Xin Jerry-Xin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web), Round 5

Reviewed at head 9271d7ac against merge-base ca716168 (== current main tip). Re-review of Round 4 (92ee6992). Since Round 4 exactly four commits landed, all verified via git diff --stat / git diff-tree (no other changes):

  • 7d8e4800 "fix: isolate review detail actions" — 5 files, +122/−35 (drawer action isolation, policy error-state split, Contacts type casts, 4 new tests).
  • e202a331 "test: cover relative summary attention poll" — 1 file, +8 (e2e MSW handler).
  • 2d2945cf "test: guard summary poll across navigations" — 1 file, +18 (Playwright route interception in the S26 spec).
  • 9271d7ac "test: guard expert search bootstrap" — 1 file, +6 (Playwright route interception in the EX2 spec).

The three test commits are e2e-kit-only; the functional review below covers 7d8e4800, byte-verified at this head.

Verdict: APPROVE

All three commits verified byte-by-byte; local suites pass (259/259 + 283/283); CI Build + Unit tests green at this head. Nothing in the delta is blocking. The blockers claimed by the second-opinion pass are adjudicated in §4.

1. ReviewDetailDrawer action isolation — verified

packages/dmworkskillmarket/src/components/ReviewDetailDrawer.tsx

The drawer stays mounted across reviewId changes (ReviewQueue.tsx:586-593 renders it unconditionally with reviewId={detailId}), so the fix correctly targets state that survives across records:

  • A reviewIdRef tracks the live prop (:39-40); both mutation entry points capture actionReviewId = review.id before the await and drop their continuations when the drawer has moved on: approve (:74-88, guard before onDecided()/onClose(), in the catch, and setActing(false) only if current in finally) and the reject confirm (:289-307, guarded error and success paths).
  • The load effect (:44-70) now resets review, error, iconError, rejectOpen, acting, loading on every run, so a reject dialog or in-flight action from a previous record cannot attach to the next one. grep confirms these are the only two approveReview/rejectReview call sites in the component; the queue's own row-level quick-approve binds item.id at click time and is unaffected.
  • The two new tests (ReviewDetailDrawer.test.tsx) drive exactly these paths: reject-dialog survival across A → null → B, and a stale approve continuation resolving after B opened (asserting onDecided/onClose are not called and B is intact). The isSpaceReviewerRole helper used by the page is the shared 1..2 predicate (hooks/useSpaceRole.ts:12-18) with its own unit coverage.

2. SpaceReviewPage error split — verified

packages/dmworkskillmarket/src/pages/SpaceReviewPage.tsx

error is split into loadError/saveError (:41-42). The toggle render gate is enabled !== undefined && !loadError (:156), so a failed save no longer hides the control; saveError shows beside the still-interactive toggle and inside the confirm modal. All load/save/space-switch/non-reviewer paths reset both (:53-56, :73-77, :94-99, :111). savePolicy still writes back only the server-echoed value — no optimistic state.

The disabled={loading || saving}disabled={saving} change is behavior-neutral: every setLoading(true) site (:53 in loadPolicy, :76 delegating to it) also sets enabled=undefined, and the initial state starts enabled=undefined, so the toggle can never render while loading is true. (Consequence noted in §5: loading is now write-only.)

Two new tests pin the save-failure affordances for both enable and disable paths.

3. Contacts cast + the two e2e-kit commits — all sound

  • packages/dmworkcontacts/src/Contacts/index.tsx:488,501,516,529new Contacts() as SpaceContact. SpaceContact extends Contacts { spaceRole?: number } (:68-70) and each cast site assigns c.spaceRole = m.role immediately after (:507, :522). TypeScript assertions are erased at compile time: zero runtime change, sound direction. Build at this head is green, which type-checks these packages.
  • apps/web/e2e-kit/msw-handlers/chat-baseline.ts:346-353 (e202a331) — same-origin relative-path handler for /summary/api/v1/summaries/attention beside the existing wildcard, identical zero-count envelope.
  • apps/web/e2e-kit/tests/summary/S26-summary-standalone-links.spec.ts:5-22 (2d2945cf) — Playwright pagePlain.route("**/summary/api/v1/summaries/attention?*", …) registered before the first navigation. This is the correct layer for the residual leak: S26 performs several full-document navigations, and during the brief interval before each new document's MSW worker registers, the background attention poll could escape to the Vite proxy; Playwright's route interception sits at the network boundary and persists across navigations. Both red e2e runs leaked precisely during this spec (the proxy error lands between test 156 and S26 in both logs), so the guard targets the observed leak point. Test infrastructure only; no app code.
  • apps/web/e2e-kit/tests/experts/EX2-experts-market-search.spec.ts:6-11 (9271d7ac) — Playwright authedPage.route("**/api/v1/spaces/*/categories", …) fulfilling [], registered before the second full-document navigation. This guards the space-categories bootstrap request that leaked in the 2d2945cf run (the proxy error landed in EX2's window, after EX1 completed). The [] body matches CategoryService's get<CategoryItem[]> contract (the client unwraps the array directly). Test infrastructure only; no app code.

4. Second-opinion adjudication — all claimed blockers rejected

The second-opinion pass returned REQUEST_CHANGES on every run. Each claim was independently verified; none is a merge blocker on this head:

(a) Snapshot race in expert-upgrade submission. loadExpertChildRelations + loadExpertReviewContent (packages/dmworkmcp/src/api/expertService.ts:925,966) each call pluginDetail() independently and are joined in ReviewSubmitModal.tsx:133-138, so a concurrent mutation between the two reads could mix content from one moment with relations from another. Mechanism real, but: the reads are issued concurrently in one Promise.all and get() (expertService.ts:252-265) has no retries, so the divergence window is the millisecond RTT delta between two parallel requests; each half is a genuine valid state of the row, so the worst case is a frozen snapshot mixing two adjacent valid states — no corruption, no data loss, no security impact; and these files are byte-identical between 92ee6992 (Round 4 APPROVE) and this head. Legitimate hardening for a follow-up (back both loaders with a single detail read plus a one-read regression test), recorded P2.

(b) CSS token rule. DEVELOPMENT.md §2 (referenced by AGENTS.md as a core constraint) does prohibit hard-coded spacing/radius/font-size, and the flagged lines do contain such literals (.skill-market-review-policy-confirm block in packages/dmworkskillmarket/src/index.css:3365-3402; .wk-mcp-review-submit__* in packages/dmworkmcp/src/index.css:4538-4563). Verified facts that shape the call: (1) none of these lines were added or changed by any Round-5 commit — the delta touches zero CSS; they are byte-identical to the Round-4-approved head; (2) the two files already carry ~360 and ~330 pre-existing px literals respectively, and no gate (lint or CI) enforces the rule today; (3) the theme-integrity substance of the rule is intact in the PR's CSS: every color resolves through theme-defined semantic tokens under both :root and body[theme-mode=dark] — no undefined variables, no light-only literals breaking dark mode, no !important (the remaining #fff literals are badge foregrounds on brand backgrounds; var(--wk-color-error, #ef4444) pairs a defined token with a redundant fallback). Blocking Round 5 on unchanged previously-approved lines that match the file's established pattern would reverse Round 4 with no new trigger. Recorded as P2 cleanup, not a blocker — consistent with the peer reviewer's treatment of the same lines across rounds 4 and 5.

(c) Unguarded mutation continuations across Space switches (handleCancelReview in packages/dmworkmcp/src/pages/McpMarketListPage.tsx:586 and ExpertMarketListPage.tsx:548; handleConfirmDelete/handlePublish in ExpertMarketListPage.tsx:508,591; cancel/publish in packages/dmworkskillmarket/src/pages/SkillListPage.tsx:200,223). Verified: these handlers await the mutation, then toast and re-read the list without capturing the page's request generation. Bounded impact, not a tenant-isolation hole: the mutation itself names the record the user clicked and is enforced server-side under the caller's Space authorization — nothing writes into the newly selected Space; the continuation's only effects after a mid-flight switch are a transient toast and a redundant-but-correct re-read of the NEW Space's own list (loadData/reload/list.refresh() read the current space). These files are byte-identical to the Round-4-approved head, and this exact family was Round 4's carried non-blocking item ("multi-step save/publish chains not bound to Space as a whole; server-side scoping backstops"). Adding the generation guard anyway is the right hardening (the pages already have the requestVersion/generation machinery for reads), recorded P2 — but it does not block this head.

5. Non-blocking follow-ups at this head

  1. Drawer guard keys on record id, not on the opening: approve A → close mid-flight → reopen A → footer is live again (the effect resets acting), and the first continuation's onClose() will close the freshly reopened drawer. Bounded by the server CAS (duplicate approve 409s). An opening-token counter set in the effect, plus disabled={acting} on the close button, would close it cleanly.
  2. Guard also skips onDecided() on a successful stale action: approve A → open B → A succeeds → the queue never refreshes and keeps showing A as pending. Only onClose() needs the record guard; the parent refresh (ReviewQueue.tsx:589-592) is record-agnostic and should still run. ReviewDetailDrawer.test.tsx:96 currently pins the over-broad behavior.
  3. loading in SpaceReviewPage is now write-only (five setters, no reader after §2's change) — delete rather than re-wire.
  4. saveError renders twice during a failed disable — panel (:156-158) and modal (:197) simultaneously; pick one location.
  5. Failed policy load has no retry affordance (:161) — a transient GET blip hides the control until remount/space-switch; a retry button beside loadError would restore it.
  6. Pre-existing (reproduces at 92ee6992): the drawer's reject-409 path sets the error then immediately unmounts the modal and re-runs the load effect, whose setError(null) wipes the message — the reviewer is told nothing. ReviewQueue's own reject path (ReviewQueue.tsx:642-651) is the correct pattern to mirror.
  7. Generation-guard the mutation continuations of §4(c) (cancel/delete/publish on the three list pages) using the pages' existing generation machinery, with delayed-promise space-switch tests — hardening on top of the server-side scoping that already bounds them.
  8. e2e mock-coverage gap (gate-blocking, infra): the fail-closed gate has tripped on one un-mocked background request per run (attention poll → space categories), each patched by a per-spec Playwright route guard. Consider a catch-all that fulfills known background polls during navigation windows so the gate stops tripping one endpoint at a time, rather than continuing the per-endpoint whack-a-mole.
  9. Carried from Round 4, still open: the multi-step save/publish chains (ExpertEditModal/EditSkillModal/NewSkillModal) remain unbound to Space as a whole (server-side scoping backstops them); .skill-market-review-notice still uses --wk-color-primary, which is not defined in the theme (border silently ineffective); the CSS token cleanup of §4(b); the expert snapshot single-read hardening of §4(a).

None of 1–9 can mutate the wrong record, write into the wrong Space, or lose data; item 8 is e2e test infrastructure.

6. Scope note (yujiwei's Round-4 CR)

The two scope items (frozen-relation rendering, voice e2e retry) were merged commits carried in by the upstream/main merge, not new branch work; the Round-5 delta is unrelated to both. The objection is a process point about diff size, not a code defect, and is not carried as a blocker here.

7. Tests & CI at this head

  • Local (Node 22, CI parity): dmworkskillmarket 259/259 (baseline 255 + 4 new), dmworkmcp 283/283. The e202a331/2d2945cf deltas are e2e-kit-only, so these suites are unaffected by them.
  • CI on 9271d7ac: Build ✅, install-build ✅, scanners ✅; Unit tests and e2e-p0 still running at the time of writing. History at the prior heads: 2d2945cf Build/Unit/scanners all green, and its e2e run passed 158/158 tests but was blocked by a single Vite proxy error on GET /api/v1/spaces/e2e-space-001/categories (the summary-attention poll that e202a331+2d2945cf guard no longer leaked — those commits did their job; the categories request is the next pre-existing mock-coverage gap, which 9271d7ac now guards). Same class of issue throughout: an un-mocked background request escaping during a full-document-navigation window — e2e test infrastructure, not this PR's functional surface (review policy / drawer / contacts), which the suites cover and pass.

Landing order unchanged: #1614 first (its head is a direct ancestor of this head), then this PR.

@yujiawei yujiawei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1624 (octo-web)

Reviewed at head 9271d7ac, against merge-base ca716168 (origin/main's tip is the merge-base, so ca716168..HEAD is exactly this PR: 92 files, +12,952/−796). Round 6.

Delta since the head I last reviewed (7d8e4800): three commits, +32/−0, all e2e/test-infra — e202a331 (relative attention handler in chat-baseline.ts), 2d2945cf (S26 attention route), 9271d7ac (EX2 categories route). No production source changed. I read that delta line by line, then went back over the two areas where I had the least evidence last round: the Space-role authorization gates (this time checked against octo-server source rather than reasoned from the client) and the completeness of the cross-Space hardening in dmworkmcp's modals.

That second pass found two blockers I missed in rounds 1–5. Neither is a regression from the recent fixes — both have been in the diff since the role-encoding and connector-flow commits — and both fixes are small and already patterned elsewhere in this same PR.


1. Spec compliance

Spec: ❌ — one Scope item of issue #1623 is not met, and one explicit PR-body claim is falsified.

Divergence (1) — SpaceSettings is stricter than the server and removes an admin capability

#1623 Scope: "Keep owner/admin role behavior aligned with the authoritative role encoding."

packages/dmworkbase/src/Components/SpaceSettings/index.tsx:154-156

isOwner() { return isSpaceOwner(this.props.space.role); }   // role === 2
isAdmin() { return isSpaceAdminOrOwner(this.props.space.role); }

isOwner() gates the Space name input (:182), the description textarea and its voice button (:195, :197), the save button (:209) — and disband (:265). One predicate, two different server policies:

  • handleSaveSpaceService.updateSpacePUT space/:id (Service/SpaceService.tsx:416-418). Server: octo-server/modules/space/api.go:74 routes it to updateSpace, whose gate is member.Role < 1 → reject (:372) and whose own doc comment reads "用户侧修改空间基础信息(owner / admin 自服务)" (:353). Admin is explicitly permitted.
  • disbandSpace's gate is member.Role != 2 → reject. Owner-only. isOwner() is right here.

Before this PR isOwner() was role === 1, which under the corrected encoding is admin — owners were locked out, admins could edit. After it, owners can edit and admins cannot. The disband half is genuinely fixed; the rename half is now wrong in the opposite direction, and the net effect for a Space admin is the silent loss of a working, server-sanctioned action. Neither the PR body nor #1623 mentions narrowing it, and SpaceSettings has no tests (no __tests__ anywhere under it), so nothing else catches this.

Fix: gate name / description / save on this.isAdmin(), keep isOwner() for disband. If owner-only rename is the intended product policy, say so in the PR body and add the test — because the server disagrees.

Divergence (2) — the "stale mutations cannot touch a new Space" claim does not hold for two modals

PR body, COMPREHENSION §2: "Late old-Space requests or mutations could … send old IDs with a new Space header … Generation guards, synchronous clearing, action-continuation checks, and relation-detail tests cover those failures."

They do not cover ExpertEditModal or McpCreateModal. Both contain multi-await submit chains with zero space guard — grep -n 'generation|spaceId|currentSpaceId|abort|useRef' returns nothing in ExpertEditModal.tsx and no guard identifiers in McpCreateModal.tsx. Detail in §2. The sibling ReviewSubmitModal.tsx:116-167 — same PR, same author — carries exactly the guard these two need, commented "Mirrors useSpaceRole's generationRef", which is what makes this an oversight rather than a design choice.

Missing — none.

Both round-4 blockers stay fixed at this head (re-verified at source level, not carried over on trust).

Out of scope — carried, unchanged, still not blocking

Frozen-relation rendering, the voice e2e retry, and this round's three e2e-infra commits are none of them in #1623's scope. Same position as round 5: each addition is individually sound and has now been read across six rounds; the objection was always to bundling them into a 92-file diff, which is a process point, not a defect.

Acceptance

Criterion Verdict
Owners and admins can view and update the policy SpaceReviewPage.test.tsx:52-65 (owner), :117-123 (admin, role 1)
Members cannot access review management ⚠️ True of the sidebar (MarketSidebar.test.tsx:139,148,202-231 — real, well covered). Not true of the page: SpaceReviewPage.tsx:173 renders <ReviewQueue mode="space" /> outside the isReviewer block, by documented design (:23-26), relying on a server 403 that nothing in this repo or any fixture ever exercises. The source is honest about it; the acceptance wording overstates it.
Space switches cannot display stale member/role/policy state ✅ for every display surface in the PR. See Divergence (2) for mutations.
Affected package tests, production build, i18n pass ✅ ran locally at this head

Local suites at 9271d7ac, Node 22: dmworkskillmarket 259/259, dmworkmcp 283/283, dmworkcontacts 42/42, dmworkbase 4327/4327, dmworksummary 941/941, apps/web 1426/1426 — all green. CI: Build, install-build and every scanner green; Unit tests and e2e-p0 still queued at the time of writing. PR body leaves Manually verified unchecked.


2. Code quality

Quality: Changes-Requested — two P1. Everything else is P2.

P1-1 — Space admins lose name/description editing

See §1, Divergence (1). Not repeated.

P1-2 — ExpertEditModal.submit and McpCreateModal.submit can issue the second mutation with the new Space's header and the old Space's plugin id

packages/dmworkmcp/src/components/ExpertEditModal.tsx:112-148

if (dirty) {
  await updateExpertVisibility(item.id, visibility);        // :120
}
if (publish) {
  const outcome = await publishPluginListing(item.id, {     // :123 — issued after the switch

X-Space-Id is attached per request, at issue time, from WKApp.shared.currentSpaceId (api/expertService.ts:180-182, api/mcpService.ts:428-430), and space-changed fires after currentSpaceId flips (Chat/vm.ts:129-130). So a Space switch landing between :120 and :123 sends the new Space's header with the old Space's item.id. The parent does setEditItem(null) on switch (ExpertMarketListPage.tsx:274), which closes the modal — but closing does not cancel the running closure, which captured item at click time. Its continuation then calls onSaved(...) + onClose() (:128-140) → handleListingChanged (ExpertMarketListPage.tsx:576-580) → toast + reload() + myReviews.refresh(): an old-Space outcome announced in the new Space.

McpCreateModal is the same shape with a wider window, because the first stage is not local work:

const content = await buildConnectorReviewContent(editing.id, payload);  // :1122 — 2-3 RTTs
await submitPluginReview({ pluginId: editing.id,});                   // :1124

(buildConnectorReviewContent fans out to GET /plugins/detail plus category resolution, api/mcpService.ts:984-993:897-916.) Three more straddles in the same function: :1065 uploadMcpIcon:1159 createMcp / :1133 updateMcp; :1133 updateMcp:1139 publishSaved; :1161 updateMcp:1166 publishSaved.

Nothing in the suite drives a two-stage mutation across a switch, so this is both the widest hole in the hardening and an untested one.

On severity, plainly: the demonstrated harm is P2 — a misattributed success toast, an error setError'd into a modal the parent already unmounted, and a reload in the wrong Space. What I cannot rule out from this repo is the P1 case: whether these endpoints scope plugin_id against X-Space-Id, i.e. whether an old-Space record actually gets published. This repo's own note for a sibling endpoint says "A cross-Space target is still 404" (skillApiReal.ts, delistPlugin doc), which is encouraging but is about a different endpoint. I am calling it P1 because the PR body asserts this class is covered, the fix is the four-line generationRef pattern already sitting in ReviewSubmitModal.tsx:116-167, and "the server probably rejects it" is not something a review should wave through. Point me at the server-side scope check and I will downgrade it.

P2 — The new S26 attention-poll guard cannot match the request it was written for

apps/web/e2e-kit/tests/summary/S26-summary-standalone-links.spec.ts:8

await pagePlain.route("**/summary/api/v1/summaries/attention?*", async (route) => {

The comment says "Intercept the background attention poll at the Playwright boundary." The background poll sends no query string, and this glob requires one.

Playwright puts ? in its escapedChars set, so it is matched literally. Resolved against this repo's own playwright-core@1.61.1:

glob → ^(.*/)summary/api/v1/summaries/attention\?([^/]*)$
  .../summaries/attention          → false
  .../summaries/attention?fresh=1  → true

And the background poll is explicitly the non-fresh caller:

  • packages/dmworksummary/src/module.tsx:299-302"后台轮询【不】传 fresh", readSummaryAttentionCount() with no options
  • packages/dmworksummary/src/api/summaryApi.ts:650-652params: options?.fresh ? { fresh: 1 } : undefined

So the interception covers only refreshSummaryAttentionBadge's fresh=1 reads (summaryAttentionBadge.ts:348) and silently misses the poll. Since the e2e gate is fail-closed on proxy errors (.github/workflows/e2e.yml:128,164-166), if e2e-p0 comes back red on summaries/attention, this is why. One-character fix: drop the ?**/summary/api/v1/summaries/attention* matches both forms (verified).

I have this as P2 rather than P1 on the reasoning that it cannot produce a false green — a guard that does not fire leaves the existing gate to do its job. A second reviewer rated it P1 on the grounds that the commit does not accomplish what it claims; recording the disagreement rather than splitting it.

P2 — The third commit's relative MSW handler may add no coverage at all

apps/web/e2e-kit/msw-handlers/chat-baseline.ts:346-352 adds http.get("/summary/api/v1/summaries/attention", …) beside the existing "*/summary/…" mask, justified as "a leading-wildcard mask does not consistently match in the browser worker." Under the installed MSW 2.15 matcher the existing mask already matches same-origin absolute request URLs and already ignores query parameters, so the twin is a no-op; and if the client has not registered with MSW yet, neither handler can intercept anything. The comment attributes the failure to the wrong matching behaviour. Harmless as code, but it means the actual cause of that leak is still unidentified. (I could not reproduce or refute the wildcard claim empirically — see §6.)

P2 — Both new page.route stubs contradict a documented in-repo decision, and never unroute

apps/web/src/mocks/swControl.ts:29-32 (untouched by this PR) argues against exactly this pattern: "之所以在这里等、而不是在各 spec 里逐个补 page.route 兜底 … 逐个补是打地鼠,而且补的是症状。" This PR then adds two: S26:8-22 and EX2:10-12. Neither is unrouted, so for the rest of each test those endpoints are hard-stubbed above MSW and a real contract drift on spaces/{id}/categories becomes undetectable in EX2. The [] body does match today's baseline fixture exactly (chat-baseline.ts:177-182 returns a bare array, no envelope), so it is behaviour-equivalent right now — it just stops tracking. Relatedly, the same leak class was fixed globally for two endpoints this round but locally, in one spec for spaces/{id}/categories; chat-baseline.ts:177 still has only the bare-wildcard form, so any other spec booting a market page still leaks it.

P2 — ReviewGateProbe fires the 403-ing mode=space read on an owner→member Space switch

packages/dmworkmcp/src/components/MarketSidebar.tsx:186-191, contradicting its own comment at :156-157 ("held back entirely until the role resolves so a plain member never fires the 403-ing mode=space read").

const handleSpaceChanged = () => refreshRef.current();

refresh closes over fetchPage, whose enabled is isReviewer from the last render. Both handlers run inside one synchronous mittBus.emit, and React batches setRole, so when the probe refetches, enabled is still the previous Space's role. An owner switching into a Space where they are a plain member issues one mode=space read with the new X-Space-Id. Harmless (the catch sets pendingCount 0 and the sidebar ignores error) but it is one uncovered request per such switch, and the comment claims it cannot happen. Read isReviewer through a ref, or key the refetch off the resolved role.

P2 — Success toasts are unguarded where failure toasts are guarded

packages/dmworkmcp/src/pages/AllAssetsList.tsx guards the error path (:107 if (version !== requestRef.current) return;) but the success toasts sit inside the action() closure, upstream of any check: :206-210 after publishPlugin, :223 after cancelReview, :263 after deleteSkill. An old-Space publish/cancel/delete that succeeds mid-switch still toasts success in the new Space; the new test covers only the reject path (AllAssetsList.test.tsx:133-134). Same class, with no version snapshot at all: McpMarketListPage.tsx:586-604, :660-676; ExpertMarketListPage.tsx:508-521, :548-566, :591-605. Cosmetic plus one wasted refetch — those mutations are single-await, so no header/id mismatch.

P2 — Only one of the three list surfaces clears rows synchronously

AllAssetsList.tsx:86-91 bumps the generation then clears items/error/busyId/deleting before reloading. McpMarketListPage.handleSpaceChanged_ (:254-280) clears the whole modal stack but not items; ExpertMarketListPage.handleSpaceChanged (:269-287) clears every modal target but not myAgentsData / mySquadsData / agentsData / squadsData. In both, the stale rows are hidden by the loading gate (:1053, :870), and I could not construct a user-reachable click on an old-Space row — latent and inconsistent with the sibling, not live. One real asymmetry worth noting: ExpertMarketListPage sets loading:true in the same batch as the handler, while McpMarketListPage sets it in the setState callback (:276), leaving one committed render with old items and loading:false. Intra-task, no paint.

Related: useMyReviewState is refreshed but never cleared (McpMarketListPage.tsx:277-279, ExpertMarketListPage.tsx:286), so stateByPlugin (hooks/useMyReviewState.ts:40) keeps mapping old-Space plugin ids → rejection reasons until the new fetch settles. Space-scoped ids make a collision impossible, so latent only.

P2 — useSpaceRole's re-probe fallback leaves the previous Space's role in state

packages/dmworkskillmarket/src/hooks/useSpaceRole.ts:55-81resolveFromServer sets loading true (:64) without clearing role, so isReviewer keeps the old Space's answer for the duration of the probe and MarketSidebar.tsx:118 visible: (gate) => gate.isReviewer keeps the 组织发布管理 row on screen. To be fair to the author: all three production emit sites pass a full Space (Chat/vm.ts:130, Main/index.tsx:186,196), so the numeric-role fast path (:87-93) always wins and this fallback is currently reached only by useSpaceRole.test.ts:130. Worth a setRole(undefined) anyway, since the fallback exists precisely for the payload shape that has no role.

P2 — The policy mapper trusts the wire with no coercion

packages/dmworkskillmarket/src/api/skillApiReal.ts:1332-1336

function mapReviewPolicy(raw: PluginReviewPolicyWire): PluginReviewPolicy {
  return { isAutoApproveEnabled: raw.is_auto_approve_enabled,};
}

request<T> is an unchecked cast (:219-224); the only runtime check is "data" in body. Two consequences, traced to the rendered output:

  • is_auto_approve_enabled: null passes enabled !== undefined (SpaceReviewPage.tsx:160) and renders policyDisabled = 「已关闭」 — which i18n/zh-CN.json's policyDescription defines as "由组织管理员人工审核". An admin would be told submissions are human-gated while the server may be auto-approving. No error, no warning.
  • A 204 on the PATCH returns { data: undefined } by design (:203-206), so mapReviewPolicy(undefined) throws and SpaceReviewPage.tsx:119 renders err.message — i.e. the literal string Cannot read properties of undefined (reading 'is_auto_approve_enabled').

Both contingent on server behaviour I cannot verify (§6). raw.is_auto_approve_enabled === true plus a genuine error otherwise closes it. Related: review.policyLoadFailed / review.policySaveFailed are unreachable — SkillMarketApiError and TypeError are both Error, so the t() branch at :64/:119 is dead and raw server text shows instead.

P2 — Mutation-proven test holes

Found by flipping the source and re-running the suite, not by reading:

  • SpaceReviewPage.tsx:151 — replacing isReviewer && with true && leaves all 10 SpaceReviewPage tests green. The member test (SpaceReviewPage.test.tsx:125-131) only asserts the checkbox is absent, and the checkbox is separately gated by enabled !== undefined, trivially true for a member because no read fires. A regression leaking the policy heading, description and error text to plain members would ship. (The read-suppression half does have teeth — mutating :94 correctly fails.)
  • Three of the five stale-continuation guards the PR body claims are untested duplicated logic: cancel (ReviewQueue.tsx:398-414), reject (:632-660), delist (:601-625). Approve (:280-302) and the drawer path (ReviewDetailDrawer.test.tsx:75-99) are covered.
  • McpMarketListPage.spaceIsolation.test.tsx does bite on the counter (removing this.requestVersion += 1 at :259 fails it), but it does new McpMarketListPage() (:80) and stubs setState as a plain object merge that drops the callback (:81-84), so React never renders and loadData() never runs. It tests the counter, not the page — which is why the batching asymmetry above is invisible to it.
  • MarketSidebar.test.tsx:250-256 asserts only expect(h.reviewRefresh).toHaveBeenCalledTimes(1). The badge value comes from a pendingCount the test fully controls, so "the old count left the DOM" was cheap and available.
  • SpaceReviewPage.test.tsx:168-175 asserts container.querySelectorAll(".skill-market-state") has length 0 — satisfied by a blank render, a renamed class, or a thrown child, and ReviewQueue is mocked out entirely (:10-14).
  • plugin_listing_state → pluginListingState has zero coverage (only pluginWire.ts:182 and skillApiReal.ts:967 mention it), yet ReviewQueue.tsx:516 gates the 下架 button on it. It is the newest and most confusable field in the mapper.

P2 — The new /plugins/review_requests catch-all is broader than its stated purpose

apps/web/src/mocks/handlers.ts:38-56, registered last (:79). The stated reason (a scenario rendering the market shell but not modelling review leaks the badge probe to the proxy) is legitimate, and I checked the obvious risk: RB1/RB2 both assert toHaveText("1") before acting, which proves the scenario handler is live, so their closing toHaveCount(0) is not made vacuous by it. What does follow: the regex accepts both /api/v1/… and /market/api/v1/…, so a base-path regression is unobservable, and no e2e can ever see a 403 or a wrong mode on the endpoint whose 403 is this feature's only real authorization boundary.

P2 — Role encoding is declared in three places with two different predicates

  • SpaceService.tsx:233-242 — exact equality, role === 1 || role === 2
  • useSpaceRole.ts:9-18 — local re-declaration plus a range check, role >= 1 && role <= 2
  • spaceRoleBadge.ts:8,11 — bare literals 1 / 2, with the encoding baked into the i18n key names themselves (contacts.role.1 / .2)

All three agree for 0/1/2/−1/undefined today, and @octo/base already re-exports the constants (dmworkbase/src/index.tsx:148) — which is how Main/index.tsx:259 consumes them. They diverge the moment the server adds a role ≥ 3.

P2 — Two SpaceMembers gaps this PR passed next to

  • SpaceMembers/index.tsx:128 — the 邀请 button has no canManageMembers guard, while createInvite requires Role >= 1 (api.go:898), so a plain member gets an error toast. Pre-existing, but this PR introduced canManageMembers 25 lines away, and SpaceSettings:221 does gate its invite section.
  • SpaceMembers/index.tsx:156,174 — 移除 is offered for admin rows to an admin. Server skips member.Role <= target.Role and still ResponseOK()s (api.go:759-772); handleRemove (:70-78) then drops the row locally and toasts success, and the row returns on the next fetch. A shifted pre-existing bug (before the fix the same false success applied to owner rows), but now in the set an admin will actually click.

P2 — Smaller items

  • utils/review.ts:25-28reviewKindLabel has no default, so any unknown kind renders 首次发布. reviewStatusLabel (:20-21) and labels.ts:16-17 both fall back to the raw wire value, by the convention labels.ts states explicitly.
  • utils/version.tsversionErrorKey("1.2.3", "01.02.03") returns null (accepted); the string differs from the published label, so the server 409s and the pre-flight validator whose stated purpose is "saves the user a 400" waved it through. nextPatch("1.2.999999999") emits "1.2.1000000000", which its own isValidVersion rejects — and nextPatch seeds the version field in both modals (NewSkillModal.tsx:122, EditSkillModal.tsx:39) with zero tests of its own.
  • McpCreateModal.tsx:677-694editing is read inside a [visible]-keyed effect. The alive flag is correct and the effect is space-safe; only the editing read is stale-prone, and I found no live path that changes it while visible stays true. Hygiene.
  • No async continuation in the dmworkmcp pages checks mounted-ness (AllAssetsList.tsx:62,65,67,104,116; ExpertMarketListPage.tsx:215-244; McpMarketListPage.tsx:386-400,445-455). react@^17 still warns on setState-after-unmount. Pre-existing pattern, extended here.
  • Carried and unaddressed from rounds 4–5: index.css:3375,3383-3385 bypass --wk-sp-*/--wk-r-*; McpMarketListPage.tsx:554 openReviewSubmit still dead; ExpertEditModal.tsx:156 onCancel={onClose} with no in-flight guard (now compounded by P1-2); ReviewDetailDrawer.tsx:249-252 still prints raw ids; plus the four round-5 drawer P2s (opening-token guard, onDecided suppressed on a successful stale action, the 409-reject error never rendered, and the now-dead loading state in SpaceReviewPage).

Claims I checked and am NOT raising

Stated so they are not re-litigated:

  • t is imported in expertService.ts:2, import { WKApp, buildAcceptLanguage, t, DEFAULT_REQUEST_TIMEOUT_MS } from "@octo/base". A claim that :631 throws ReferenceError is wrong.
  • The voice e2e retry has no stale-locator problem. conversation is page.getByText(...), a lazy Locator re-resolved on every wait, so it survives the reload. The retry does convert an acknowledged intermittent race into a green test and can hide ~30 s, but the re-throw narrowing at :70 is right and no assertion was removed.
  • No pre-existing e2e assertion was deleted or weakened to reach green. C40's "删除 X""删除「X」" tracks a real aria-label change and stays exact: true and name-scoped; the role: 1 → 2 fixture updates track the encoding remap and flip no gate (both 1 and 2 are reviewer roles).
  • The encoding itself is right, and the bug it replaced was severe. octo-server/modules/space/model.go:38: Role int // 成员角色 0.普通成员 1.管理员 2.拥有者. SpaceMembers' old isAdmin() { role <= 2 } was always true (−1 included), so every plain member saw the remove and role-change buttons. That fix is the most valuable thing in this part of the diff. canManageRoles = isSpaceOwner also correctly matches updateMemberRole's member.Role != 2 (api.go:822), and RoleColors' keys now line up.
  • No caller was left reading the old shape. Repo-wide sweep of Space.role / SpaceMember.role consumers, the contacts.role.* keys (role.3 deleted and unreferenced), _spaceRole (0 hits), and loadAllData's new signature — all updated. ChatSelectorModal.tsx:293 and everything under features/channelSetting/ use GroupRole, a different enum, correctly untouched.
  • Both space-changed emit sites set currentSpaceId before emitting (Chat/vm.ts:129-130, Main/index.tsx:182-186), so every synchronous refetch inside a handler already sees the new Space. No ordering bug there.
  • The sidebar does not double-mount the right pane on a Space switch. replaceToRoot renders into queues:[view] with key={i} (WKViewQueue/index.tsx:109-121,203-210), so React reconciles rather than remounts — which is what makes the pages' own space-changed handlers load-bearing, and means there is no double-fetch.
  • MyReviewStateProbe's onChange effect does not loop (useMyReviewState.ts:119-121): result is double-memoized down to [items], and handleReviewStateChange is a stable class field.
  • McpCreateModal's remembered createdPluginId (an old-Space id kept for publish retry, :1156-1163) is reset by the prefill effect on reopen (:733, deps [visible, editing]), which commits before any click can land. Not exploitable — this is the one thing that would have made P1-2 a P0.

3. Overall verdict

CHANGES_REQUESTED — on two focused items.

  1. SpaceSettings.isOwner() gating the Space name/description/save controls contradicts the server (updateSpace admits Role >= 1, doc-commented owner / admin 自服务) and #1623's own "keep owner/admin role behavior aligned" scope item, and it removes an action admins could perform before this PR.
  2. ExpertEditModal.submit and McpCreateModal.submit carry multi-await mutation chains with no space guard, which falsifies the PR body's explicit claim that this class is covered — while the sibling ReviewSubmitModal in the same PR shows the pattern to copy.

Nothing else here is P0/P1.

I want to be straight about provenance: I approved this same production code at 7d8e4800 last round, and both findings predate the recent fixes. I only caught the first by reading octo-server/modules/space/api.go directly instead of reasoning from the client, and the second by grepping the modals for guard identifiers rather than trusting the PR body's coverage claim.


4. Suggested follow-ups (not merge blockers)

  1. S26-summary-standalone-links.spec.ts:8 — drop the ? from the glob (or delete the route and fix chat-baseline.ts instead, per swControl.ts:29-32).
  2. MarketSidebar.tsx:186-191 — read isReviewer through a ref so the space-switch refetch respects the resolved role.
  3. skillApiReal.ts:1332-1336 — coerce is_auto_approve_enabled with === true, raise a real error otherwise, handle the 204 PATCH.
  4. AllAssetsList.tsx:206,223,263 — move the success toasts behind the same requestRef check the error path already has; add a version snapshot to the five sibling handlers in the two list pages.
  5. SpaceReviewPage.test.tsx — assert the policy section is absent for a member, not just the checkbox (the mutation above is the regression test). Tests for the cancel / reject / delist guards, and one for plugin_listing_state.
  6. SpaceMembers.tsx:128 — gate 邀请 on canManageMembers; :174 — gate 移除 on canManageRoles for admin rows, or stop treating 200 as proof.
  7. Import SPACE_ROLE_* from @octo/base in useSpaceRole.ts and spaceRoleBadge.ts.
  8. reviewKindLabel default; nextPatch overflow; leading-zero version labels.

Worth its own issue, unchanged from rounds 4–5: no CI job type-checks these packages. I tried to close this myself with a direct tsc -p at this head and the run was environmentally broken (react type resolution, lib target), so I can report it neither as confirmed nor as clean — which is itself the point.


5. Process note — round 6

Round history: 8f3f4e2b666c20b66783714 ×2 → 92ee69927d8e48009271d7ac. Severity has fallen monotonically and is still falling: round 3 rendered an unfetched policy value as enabled, round 4 could reject the wrong record, round 5's worst case was a stale queue row, and this round's blockers are a disabled input and a guard-shaped gap in two modals. The delta itself is 32 lines of e2e infra.

Given that, I do not think a seventh full pass is the right shape:

  • Land the isOwner()isAdmin() decision and the two generationRef copies (plus a test each) and nothing else on this branch. Round 7 should be a ~20-line diff, not another sweep.
  • Move every P2 in §2 and §4 into a single follow-up issue against the market/review area. There are roughly fifteen, they have been accumulating for three rounds, and none is worth another round-trip on a 92-file branch.
  • The deeper cause is unchanged and worth saying once more: a two-line render guard took three rounds because of the size of the diff it lives in. Both of this round's blockers are also size artifacts — one sits in a shared component nobody was looking at, the other in two modals that the hardening pass simply did not reach. Landing the policy panel and the role-encoding correction separately from the connector flow, the frozen-relation feature and the e2e stabilisations would have made rounds 2–6 much cheaper, and is the thing I would change next time rather than anything in this code.

Landing order: #1614 is still open and its head 7d478bac is a direct ancestor of this head. The stated plan — #1614 first, then this — remains correct; please make sure #1614 is closed rather than left to merge as a no-op.


6. Coverage gaps in this review

Stated so they are not read as "checked and clean":

  • octo-marketplace was not inspected, unchanged from rounds 4–5. Still unverified: the /plugin_review_policies GET/PATCH contract; whether admins may PATCH; whether is_auto_approve_enabled can be absent or null, and whether the PATCH can answer 204 (both gate a P2 above); the documented auto-approve default; whether /plugins/upsert, /plugins/publish and /plugins/reviews scope plugin_id against X-Space-Id (this is what decides whether P1-2 is a cross-Space write or a 404); whether the status enum is canceled (as sent verbatim at skillApiReal.ts:1108) or cancelled — if the latter, the 已取消 tab silently returns zero rows forever, and only fixtures written by this same PR say otherwise; whether frozen_relations is [] or absent for relation-less types.
  • SpaceMembers / SpaceSettings have no in-repo mount site and no tests (repo-wide grep finds only the barrel re-exports at dmworkbase/src/index.tsx:146-147), so P1-1 and the two SpaceMembers P2s are verified against client source plus octo-server source, not against a running app.
  • Out-of-repo consumers of the flipped Space.role were not swept — octo-admin / octo-ios / octo-android are separate repos. If any still encodes 1 = owner, this desynchronizes web from them. Worth one grep before merge.
  • Unit tests and e2e-p0 were still queued on this head, and I ran no Playwright locally. The six vitest suites above are the executed evidence, plus direct input probes of version.ts and of Playwright's glob matcher. I could not empirically reproduce or refute the MSW leading-wildcard claim behind the chat-baseline.ts twin.
  • The bulk of the diff was not re-read this round. MineTable, NewSkillModal, AllAssetsList's rendering, and the expertService visibility work are byte-identical to 7d8e4800 and were verified in rounds 1–5; I re-read them only where a finding pointed.
  • Dark mode verified by token existence, not by rendering.

@mochashanyao mochashanyao left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Octo-Q · automated review]

Verdict: Approve — no blocking findings; notes below (data-flow traced).


Code Review — PR #1624 (octo-web)

Reviewer: Octo-Q (automated review)

Summary

本 PR 把 Space 维度的插件发布审核完整搬上前端:技能 / 连接器 / 专家 / 专家团四个市场统一走 /plugins/review_requests 生命周期(提交、撤回、通过、拒绝)、单一 发布 门(后端按存储的 visibility 决定直接上架还是开审核)、/plugins/publish|delist、以及空间级的自动通过策略开关(本 PR 标题所示)。同时把空间角色编码统一为服务端真实语义(0=member / 1=admin / 2=owner),修复了通讯录角色徽标与成员管理中角色显示/写值的既有错位;新增 全部 聚合 tab、组织发布管理 侧边栏入口(含待审计数徽标)与配套 e2e/MSW 回归守护。整体设计克制:客户端只做展示门(cosmetic gate),授权与状态推导全部留在服务端,display_status 由后端计算、客户端不重推导。审查结论:未发现 P0/P1 阻塞;两条 P2 建议如下。

Verification

  • 静态审查 — 对 pr-base...HEAD(merge-base ca716168,head 9271d7ac)全部 92 个变更文件做了逐批审查;本环境未执行构建与测试(Static analysis only at head 9271d7ac; build and tests not executed in this environment)。
  • 角色编码上游核实 — 针对本 PR 最关键的语义变更(空间角色 0/1/2),只读核对 octo-server:modules/space/api_member_search.go:13spaceRoleAdmin = 1)、modules/space/api_manager.go:742role=2 触发 owner 转让)、updateMemberRole 接受 0..2。与本 PR 采用的编码完全一致;旧代码注释(1:owner/2:admin/3:member)为陈旧错误语义。
  • i18n 键一致性 — 脚本化核对三个改动包 en↔zh 键集合完全对齐,且变更文件中引用的 447 个 i18n 键全部可解析(含跨包使用 skillMarket.review.*)。
  • 数据流回溯 — 对每个被消费数据回溯到上游来源,详见下方「Things I checked」。

Findings

无 P0/P1;两条 P2 如下。

P2 — 全部 tab 单页 50 条静默截断,无分页也无截断提示 (packages/dmworkmcp/src/pages/AllAssetsList.tsx:60)

load() 只调用一次 getMySkills({ limit: PAGE_SIZE }, { pluginType: "all" })PAGE_SIZE=50:19),随后丢弃了 PagedResulttotal / nextCursor。拥有 50+ 条跨类型资产的用户会静默看不到后续行——而 全部 正是本次新增、且已设为「我的发布」默认视图、用来回答「还有什么在待审」的入口,待审行完全可能落在第 50 条之后。各类型子 tab 仍有自己的分页,故不构成既有路径回归;建议为 全部 tab 增加 load-more/分页,或至少按 total 渲染一条截断提示。

P2 — 行级 发布 未做版本格式预校验,legacy 标签会撞 400 (packages/dmworkskillmarket/src/pages/SkillListPage.tsx:225)

handlePublish 把行上存储的 skill.version 原样传给 publishPlugin;对 visibility=space 的草稿,该调用经后端 SubmitReview,按 packages/dmworkskillmarket/src/utils/version.ts 自身注释,其以 validVersion 门禁且无 grandfathering。携带格式收紧前标签(1.0 / v1.2.3 / 2.0.0-beta.1——version.ts 明确这类标签在生产存在)的 space 草稿点击行级 发布 必然得到原始服务端 400 toast;packages/dmworkmcp/src/pages/AllAssetsList.tsx:202 的同一动作同病。存在绕行(编辑弹窗内可改出合法版本再发布),不构成死路,但其余提交面均用 versionErrorKey(不带 stored 参数)预先拦截,唯此两处行级入口漏掉。建议行级 发布 前用同一规则预校验,并在校验失败时引导到编辑/升级弹窗。

Things I checked that are fine

  • 角色编码联动(本 PR 最高风险点)SpaceSettings.isOwner/isAdminSpaceMembers 的提权/降权写值(旧代码「设为管理员」实际写 2=owner、「取消管理员」写 3 会被服务端 400 拒绝)、Contacts 角色徽标(旧代码把 admin 显示为「创建者」、owner 显示为「管理员」)全部按服务端真实编码修正;apps/web/src/Pages/Main/index.tsx:259 同步迁移到 isSpaceAdminOrOwnerdmworksummary/ChatSelectorModal 的既有写法(1/2=管理、0=普通)恰与新编码自洽,无需改动。
  • 失效信号对称性(C1):两个包的全部变更型端点(deleteSkill/deleteMcp/deleteExpert/deleteSquadcreateReviewRequest/approve/reject/cancelpublishPlugin/delistPlugin)均经同一 withReviewInvalidation 模块实例包裹(含 409 拒绝场景的故意失效);唯一未包裹的 updateReviewPolicy 不改变待审计数,正确。
  • 空间切换竞态useSpaceRole 用代际计数而非仅卸载标记;ReviewQueue/SpaceReviewPage/Contacts/AllAssetsList/McpMarketListPage 均在 space-changed 时先作废旧请求与旧 Space 标识再重取;CommunicationShell 的无 role payload 路径经 useSpaceRole 重查 → roleLoading 翻转 → SpaceReviewPage effect 重新 loadPolicy(),策略区不会卡在 loading(逐 emit 点核实过两处发射器的 payload 形状)。
  • 授权边界:侧边栏/页面门均为 cosmetic,mode=space 读与决策端点服务端 403/404 强制;深链成员会看到队列自身 403 错误态并被侧边栏迁走;ExpertEditModal 对平台发布(system/public)记录拒发可见范围控件,防止租户把官方记录降级进自己空间。
  • 冻结快照完整性:容器类型(专家/专家团)提交审核显式携带当前子关系图(含 relation_iddata,防止软删重插丢失 member_key/is_leader 接线);升级提交携带新内容(连接器经 buildConnectorReviewContent 保留未建模附件与服务端键,专家经 loadExpertReviewContent 逐字回显);首发提交不带内容、由服务端冻结草稿行——与 pluginReview.ts 的三值 relations 语义一致。
  • 图标 404 回退plugin_icon 为原始存储键(非预签名 URL),ReviewQueue/ReviewDetailDrawer 均保留 onError → 字母头像回退。
  • 申请人内容安全ReviewDetailDrawer 渲染冻结 SKILL.md 用 ReactMarkdown + rehypeSanitize
  • 版本规则镜像:保存面(/plugins/upsert)带 grandfathering、审核提交面(SubmitReview)不带,EditSkillModal/NewSkillModal/ReviewSubmitModal/McpCreateModal 各自按面正确选用 versionErrorKeystored 参数;isVersionForward 数值比较(1.10.0 > 1.9.0)正确。
  • 测试与 e2e:新增 14 个测试文件覆盖 hook/API/页面契约(含 spaceIsolationpluginDelete.reviewInvalidation、角色编码单测),RB1/RB2/RB3 三个 MSW 场景为有状态实现(能区分「真的重读」与「恰好数字对」)。

Verdict: APPROVED

无 P0/P1:审核生命周期的客户端契约与服务端语义对齐、授权全部留在服务端、空间切换与并发决策的竞态均有代际/中止防护,两条 P2(全部 tab 截断、行级发布的 legacy 版本 400)均为非阻塞的正确性/体验缺口,可在后续跟进。

@yujiawei

yujiawei commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Corrections and two additions to my review above

A parallel review pass raised points that made me re-check two things in my own review. One of my statements was wrong and one carried stale citations. Both corrections are below, followed by two findings I had missed. The verdict is unchanged — see the end for why.

Correction 1 — I was wrong that the useSpaceRole re-probe fallback is test-only

In §2 I wrote that "all three production emit sites pass a full Space (Chat/vm.ts:130, Main/index.tsx:186,196), so the numeric-role fast path always wins and this fallback is currently reached only by useSpaceRole.test.ts:130."

That is incorrect. There is a fourth production emit site:

apps/web/src/client-communication/CommunicationShell.tsx:167-174

if (command.type === "spaceChanged") {
  WKApp.shared.currentSpaceId = command.space.id;
  WKApp.mittBus.emit("space-changed", {
    space_id: command.space.id,
    name: command.space.name,      // no `role` field
  });

I missed it because I truncated a repo-wide grep at 40 lines and the apps/web hits fell off the end. My mistake, not the author's.

What follows from it:

  • The space-changed payload contract is genuinely heterogeneous — three sites pass a full Space, this one passes {space_id, name}. Both new consumers narrowing on typeof role === "number" (useSpaceRole.ts:87, SpaceReviewPage.tsx:82) is therefore correct and necessary, not defensive over-engineering. The author was right to write the fallback.
  • But my P2 about resolveFromServer not clearing role (useSpaceRole.ts:55-81) is live on the embedded/host-driven space-switch path, not latent as I claimed. On that path isReviewer keeps the previous Space's answer for the duration of the re-probe, so MarketSidebar.tsx:118 keeps the 组织发布管理 row on screen after the user has already moved Space. Still P2 (the read behind it is server-enforced), but it should be fixed rather than filed as theoretical — setRole(undefined) at the top of resolveFromServer.
  • CommunicationShell.tsx:169 does set currentSpaceId before emitting, so my "no ordering bug at any emit site" conclusion holds for this one too.
  • I re-traced the recovery path on this payload shape: SpaceReviewPage.handleSpaceChanged takes neither branch at :82-83, and recovery depends entirely on roleLoading toggling inside useSpaceRole. It does toggle, so there is no stuck-loading state — but the policy panel's refresh on this path is indirect and unpinned by any test.

Correction 2 — P1-1's octo-server citations were read from a stale clone

My line numbers for updateSpace came from a local octo-server checkout that turned out to be 310 commits / three months behind origin. That is the stale-context failure I am supposed to catch in others, so: re-verified against current origin/HEAD. The substance is unchanged and the finding stands; only the line numbers move.

Claim Current upstream
Route modules/space/api.go:87auth.PUT("/:space_id", s.updateSpace)
Doc comment modules/space/api.go:409"updateSpace 用户侧修改空间基础信息(owner / admin 自服务)。"
Gate modules/space/api.go:428if member == nil || member.Role < 1 → reject. Admin permitted.
Disband gate modules/space/api.go:594member.Role != 2 → owner only
Encoding modules/space/model.go:38Role int // 成员角色 0.普通成员 1.管理员 2.拥有者

Also confirmed at current upstream, corroborating the encoding from a second angle: modules/space/api_member_search.go:13const spaceRoleAdmin = 1, commented "the minimum role allowed to manage space-scoped resources." (That file does not exist in my stale clone, which is why I could not see it earlier.)

So P1-1 is not a disagreement about the encoding — the encoding in this PR is right, and I said so. It is that isOwner() carries two different server policies at once: owner-only for disband (correct) and owner-only for rename (the server says owner or admin). A check that verifies "the encoding was fixed" passes cleanly and still misses this.

Addition 1 — P2: the 全部 tab silently truncates at 50 rows

packages/dmworkmcp/src/pages/AllAssetsList.tsx:19,60

const PAGE_SIZE = 50;

const page = await getMySkills({ limit: PAGE_SIZE }, { pluginType: "all" });
if (version !== requestRef.current) return;
setItems(page.items);        // `page.total` and `page.nextCursor` discarded

Confirmed: grep for nextCursor|loadMore|hasMore|IntersectionObserver|total in that file returns nothing, so there is no load-more and no truncation notice. 全部 is new in this PR and is the default view of 我的发布, and it is the surface a user goes to in order to answer "what else is waiting for review" — a pending row past #50 is invisible. The per-type sub-tabs keep their own pagination, so this is not a regression on an existing path. Add load-more, or at minimum render a truncation hint from total.

Addition 2 — P2: row-level 发布 skips the version pre-check that every other submit surface performs

packages/dmworkskillmarket/src/pages/SkillListPage.tsx:225 and packages/dmworkmcp/src/pages/AllAssetsList.tsx:202-205

const outcome = await publishPlugin({ pluginId: skill.id, version: skill.version });

The stored label is passed straight through with no versionErrorKey call. For a visibility=space draft this routes through the review-submit path, and utils/version.ts:76-78 states the rule itself: "OMIT it on the review-submit surfaces. SubmitReview … gates on validVersion with no grandfathering at all." version.ts also documents that pre-tightening labels (1.0, v1.2.3, 2.0.0-beta.1) exist in production. So a space draft carrying a legacy label gets a raw server 400 toast from the row button, while EditSkillModal, NewSkillModal, ReviewSubmitModal and McpCreateModal all pre-validate. There is a workaround (edit the version in the modal first), so it is not a dead end — but these two row entry points are the only submit surfaces that skip the check. Pre-validate and route the user to the edit/upgrade modal on failure.

Note this compounds with Addition 1 in one specific way: on the 全部 tab, both the truncation and the unvalidated 发布 sit on the same rows.

Verdict: unchanged — still CHANGES_REQUESTED

The parallel pass suggested approval, and I have weighed it rather than set it aside. It is right about a great deal (the encoding correction, invalidation symmetry, the frozen-snapshot semantics, the cosmetic-gate design, the plugin_icon fallback, sanitized applicant markdown, and the CommunicationShell payload path where it was right and I was wrong). Neither of my two P1s falls inside what it examined:

  • P1-1 — it verified that the role encoding now matches the server, which is true. It did not compare the policy on isOwner() against updateSpace's gate, which is where the admin capability is lost.
  • P1-2 — its space-switch race review enumerates useSpaceRole, ReviewQueue, SpaceReviewPage, Contacts, AllAssetsList and McpMarketListPage. It does not reach ExpertEditModal.submit or McpCreateModal.submit, which are the two multi-await mutation chains with no guard at all. (It does mention ExpertEditModal, but about visibility-control gating for platform-published records — a different concern, and correct.)

Both P1s remain small, and the ask is unchanged: the isOwner()isAdmin() decision for name/description/save, and the generationRef pattern from ReviewSubmitModal.tsx:116-167 copied into the two modals. The two P2s above belong in the same follow-up issue as the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL PR size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants