Skip to content

feat: A1 3D 檢視改由 Review Room 手動接管 - #286

Merged
monkey1sai merged 4 commits into
mainfrom
feat/a1-3d-review-decouple
Jul 3, 2026
Merged

monkey1sai merged 4 commits into
mainfrom
feat/a1-3d-review-decouple

Conversation

@monkey1sai

@monkey1sai monkey1sai commented Jul 3, 2026 •

Copy link
Copy Markdown
Owner

摘要

  • A1 Governance Workbench 不再自動建立 viewer lease、嵌入 WebRTC viewer 或直接送 3D highlight。
  • 新增 Review Room 的手動接管畫面,從 A1 handoff URL 帶入 session / rule_run / mapping context,按下啟動後才 claim primary lease、mount viewer、等待 first frame / DataChannel / stage match。
  • 調整 hash routing 支援 #review?... handoff,並讓 frontend / coordinator 的 stage URL 比對規則一致,避免 custom stage URL 被寬鬆誤判為相同。
  • 新增 docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md 作為本次 A1 3D decouple 規格與驗收契約。

Frontend Verification

Label Evidence
Frontend route http://127.0.0.1:5174/#a1 and http://127.0.0.1:5174/#review?source=a1&session=review_session_x&rule_run_id=rule_run_x&ifc_guid=GUID-1&usd_prim_path=/World/Wall_1&rule_code=A1.MINIMAL&expected_stage_url=stage://expected.usdc
Main button(s) tested 開啟 3D Review Room handoff button on A1; 啟動 Kit Viewer manual attach button visible/gated in Review Room; old inline a1-highlight-3d button absent
Fixture used Local mocked A1 handoff URL with review_session_x, rule_run_x, GUID-1, /World/Wall_1, stage://expected.usdc; unit fixtures in ReviewSessionViewerPane.test.tsx and A1ViewerEmbed.test.tsx
Visible success state A1 shows no embedded viewer and no direct highlight controls; Review Room shows handoff context, manual start surface, first frame / DataChannel / stage truth / highlight ack fields, and no viewer host before manual start
E2E command Local Playwright browser pass against Vite dev server http://127.0.0.1:5174; DOM observations recorded in artifacts/e2e/a1-3d-review-decouple/playwright-observations.json
Screenshot / trace artifacts/e2e/a1-3d-review-decouple/a1-decouple-desktop-full.png; artifacts/e2e/a1-3d-review-decouple/a1-decouple-mobile-full.png; artifacts/e2e/a1-3d-review-decouple/review-room-handoff-desktop-full.png; artifacts/e2e/a1-3d-review-decouple/review-room-handoff-mobile-full.png; artifacts/e2e/a1-3d-review-decouple/trace-desktop.zip; artifacts/e2e/a1-3d-review-decouple/trace-mobile.zip
Known gaps 真實 Kit WebRTC first-frame / highlight ack 因本機 coordinator helper CORS 限制未完成 live observation;本 PR 已驗證 UI decouple / handoff / manual attach gating

驗證

  • git diff --check HEAD~3..HEAD
  • web-viewer-sample: npm test,33 files / 429 tests passed
  • web-viewer-sample: npm run build
  • bim-review-coordinator: npm test,50 files / 534 tests passed
  • bim-review-coordinator: npm run build
  • GitNexus detect_changes(scope=compare, base_ref=main): risk medium,12 changed files,4 affected processes,無 HIGH / CRITICAL

Browser evidence

本機 Playwright evidence 已產生於:

  • artifacts/e2e/a1-3d-review-decouple/a1-decouple-desktop-full.png
  • artifacts/e2e/a1-3d-review-decouple/a1-decouple-mobile-full.png
  • artifacts/e2e/a1-3d-review-decouple/review-room-handoff-desktop-full.png
  • artifacts/e2e/a1-3d-review-decouple/review-room-handoff-mobile-full.png
  • artifacts/e2e/a1-3d-review-decouple/trace-desktop.zip
  • artifacts/e2e/a1-3d-review-decouple/trace-mobile.zip

已知限制

  • 本輪已驗證 UI decouple / handoff / manual attach gating;真實 Kit WebRTC first-frame / highlight ack 因本機 coordinator helper CORS 限制未完成 live observation。
  • Build 仍有既有 Vite chunk-size warning;不影響本次功能驗證。

Move A1 3D viewer ownership into a manual Review Room attach flow.

Add handoff routing/tests and align stage URL matching between frontend and coordinator lease validation.
Copilot AI review requested due to automatic review settings July 3, 2026 02:03
@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR decouples the A1 governance workbench from the embedded 3D viewer, introducing a standalone ReviewSessionViewerPane for manual Kit session attach and highlight actions via a URL-hash handoff. It also fixes viewer-lease stage URL protocol equivalence, adjusts hash routing to strip query strings, and adds a design spec document.

Changes

A1/Review Room decoupling

Layer / File(s) Summary
Viewer lease stage-URL equivalence fix
bim-review-coordinator/src/services/viewerLeaseStore.ts, bim-review-coordinator/tests/viewer-leases.test.ts
stageUrlsEquivalent now rejects non-http(s) protocols before comparing; a new heartbeat test verifies stage_match becomes false for a mismatched stage URL.
Hash routing query-string handling
web-viewer-sample/src/console/EdgeConsole.tsx, OperatorConsole.tsx, routing.ts, routing.test.ts
usePageHash, readPage(), and SHORT_CONSOLE_HASH now strip or tolerate query strings in the hash when deriving the routed page/path.
ReviewSessionViewerPane component
web-viewer-sample/src/console/ReviewSessionViewerPane.tsx, ReviewSessionViewerPane.test.tsx
New component parses ReviewRoomHandoff from the hash, manages manual primary lease claim, heartbeat, viewer readiness/highlight gating, and highlight sending via EmbeddedViewer; extensively tested.
A1 workbench decoupling and handoff wiring
web-viewer-sample/src/console/pages.tsx, A1ViewerEmbed.test.tsx, console.test.tsx
A1 no longer mounts an embedded viewer or auto-claims/auto-selects sessions; adds explicit session selection, handoff hash construction, "Open Review Room" button, and rewrites ReviewRoomPage to render ReviewSessionViewerPane.
Design specification document
docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md
Adds a spec defining non-negotiables, handoff contract, routing rules, and verification checklist for the decoupling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant A1GovernanceWorkbenchPage
  participant Hash as window.location.hash
  participant ReviewRoomPage
  participant ReviewSessionViewerPane
  participant coordinatorClient
  participant EmbeddedViewer

  User->>A1GovernanceWorkbenchPage: select session and run governance rule
  A1GovernanceWorkbenchPage->>Hash: set `#/review`?ifc_guid&usd_prim_path&rule_code
  User->>ReviewRoomPage: navigate to hash
  ReviewRoomPage->>ReviewSessionViewerPane: render with parsed handoff
  User->>ReviewSessionViewerPane: click manual start
  ReviewSessionViewerPane->>coordinatorClient: claimViewerLease(primary)
  coordinatorClient-->>ReviewSessionViewerPane: lease granted
  ReviewSessionViewerPane->>EmbeddedViewer: mount viewer
  EmbeddedViewer-->>ReviewSessionViewerPane: onFirstFrame/onStageLoaded
  ReviewSessionViewerPane->>coordinatorClient: viewerLeaseHeartbeat(stage, datachannel_ready)
  User->>ReviewSessionViewerPane: click highlight
  ReviewSessionViewerPane->>EmbeddedViewer: sendHighlight(HighlightItem)
  EmbeddedViewer-->>ReviewSessionViewerPane: onHighlightResult
Loading

Possibly related PRs

  • monkey1sai/AI-BIM-governance#182: Both PRs modify isOperatorConsolePath/short-hash routing detection and query-string/hash parsing in web-viewer-sample/src/console/routing.ts.
  • monkey1sai/AI-BIM-governance#194: Both PRs change web-viewer-sample/src/console/routing.ts to adjust how hash-based console routes are recognized when query strings are present.
  • monkey1sai/AI-BIM-governance#273: Both PRs touch ViewerLeaseStore heartbeat stage-matching behavior via stageUrlsEquivalent.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: A1 3D viewing is decoupled and handed off to Review Room for manual control.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/a1-3d-review-decouple

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI 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.

Pull request overview

This PR decouples A1 governance validation from 3D/WebRTC viewer control by moving viewer-lease claiming, session attach, first-frame/stage evidence, and highlight dispatch into a dedicated Review Room manual takeover pane, with A1 providing a non-secret handoff URL.

Changes:

  • Update console hash routing to treat #review?... / #gpu?... as operator/console routes (query-friendly short-hash support).
  • Add ReviewSessionViewerPane for manual primary-lease attach, evidence display (first frame/DataChannel/stage match), and highlight command trace/ack in Review Room.
  • Align stage URL equivalence rules (frontend + coordinator) to avoid falsely treating different custom stage URLs as equivalent; add targeted tests/spec.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
web-viewer-sample/src/console/routing.ts Allow short-hash console routes to include query strings for handoff.
web-viewer-sample/src/console/routing.test.ts Add regression coverage for query-bearing short hashes (Review Room handoff).
web-viewer-sample/src/console/ReviewSessionViewerPane.tsx New manual attach + lease + evidence + highlight surface for Review Room.
web-viewer-sample/src/console/ReviewSessionViewerPane.test.tsx Component tests for manual attach gating, evidence states, and highlight trace/ack behavior.
web-viewer-sample/src/console/pages.tsx Remove A1 embedded viewer/auto-lease behavior; add Review Room handoff flow; embed new pane in Review Room page.
web-viewer-sample/src/console/OperatorConsole.tsx Normalize operator page parsing to ignore hash query strings.
web-viewer-sample/src/console/EdgeConsole.tsx Normalize EdgeConsole page parsing to ignore hash query strings.
web-viewer-sample/src/console/console.test.tsx Update honesty smoke assertions for the new Review Room manual attach messaging.
web-viewer-sample/src/console/A1ViewerEmbed.test.tsx Replace embedded-viewer assertions with “decoupling lock” tests (no auto-select/auto-lease).
docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md Add implementation spec + acceptance criteria for A1 3D decoupling.
bim-review-coordinator/tests/viewer-leases.test.ts Add regression ensuring different custom stage URLs do not match.
bim-review-coordinator/src/services/viewerLeaseStore.ts Tighten stage URL equivalence logic to avoid false matches on custom schemes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +87 to +89
export function ReviewSessionViewerPane({ handoff = parseReviewRoomHandoff() }: { handoff?: ReviewRoomHandoff }) {
const [sessionId, setSessionId] = useState(handoff.sessionId);
const [runtimeSessions, setRuntimeSessions] = useState<RuntimeSessionSummary[]>([]);
Comment on lines +132 to +142
useEffect(() => {
setSessionId(handoff.sessionId);
setLease(null);
setLeaseErr(null);
setFirstFrame(false);
setDataChannelReady(false);
setLoadedStageUrl(null);
setHighlightResult(null);
setCommandTrace(null);
}, [handoff.sessionId]);

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe7e8e6f2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const activePrimaryLease = lease && lease.session_id === sid && lease.role === "primary" && lease.status === "active" ? lease : null;
const runtimeSession = runtimeSessions.find((s) => s.session_id === sid) ?? null;
const sessionObserved = Boolean(runtimeSession);
const expectedStageUrl = handoff.expectedStageUrl ?? runtimeSession?.expected_stage_url ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use selected session stage for handoff checks

When the operator uses the manual session input to recover from a stale A1 handoff or attach a different active session, this still prefers handoff.expectedStageUrl from the old URL over the selected session's runtimeSession.expected_stage_url. The next first-frame/stage comparison can therefore validate the newly selected session against stale handoff data, either keeping highlight incorrectly blocked or enabling it against the wrong expected stage; prefer the runtime session value, or only use the handoff value when it belongs to the current sid.

Useful? React with 👍 / 👎.

Comment on lines +340 to +342
if (u) setLoadedStageUrl(u);
void coordinatorClient.viewerLeaseHeartbeat(sid, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(u ? { loaded_stage_url: u } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale stage evidence on null stage reports

When the viewer reports stage_loaded/first_frame with stageUrl: null after a previously matched stage, this branch leaves the old loadedStageUrl in local state and omits the null from the heartbeat, so Review Room can continue showing matched and keep highlight enabled even though the current stage is unproven. The viewer has an explicit null-stage path for visible stream without Kit-proven URL, so clear the cached stage and report null to the coordinator in that case.

Useful? React with 👍 / 👎.

requested_role: "primary",
client_nonce: `${identity.viewer_id}:${sid}:primary`,
});
setLease(claimed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Release stale lease claims after session edits

If the operator clicks manual start and then changes the session ID before claimViewerLease resolves, this stores the old session's primary lease while activePrimaryLease is false for the current sid, so the release effect never runs for that claimed lease. In that race the old session can keep its primary lease until TTL expiry and block another reviewer from attaching; guard the response against the current session or explicitly release stale claims.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

♻️ Duplicate comments (1)
web-viewer-sample/src/console/ReviewSessionViewerPane.tsx (1)

52-63: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Same host-comparison gap as the coordinator's stageUrlsEquivalent.

This is a verbatim duplicate of bim-review-coordinator/src/services/viewerLeaseStore.ts's stageUrlsEquivalent. For http:/https: it only compares pathname/search, never host — see the corresponding comment on the coordinator file for the fix and duplication concern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx` around lines 52 -
63, The stage URL comparison in stageUrlsEquivalent is missing host validation
for http: and https: URLs, so update the logic to compare the host as well as
protocol, pathname, and search. Keep the behavior aligned with the duplicate
stageUrlsEquivalent in the coordinator code by applying the same host-check fix
there too, and use the existing function name to locate the shared comparison
logic.
🧹 Nitpick comments (2)
web-viewer-sample/src/console/ReviewSessionViewerPane.tsx (1)

201-225: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deeply nested ternary for highlightDisabledReason.

The 8-level nested ternary is correct but hard to scan/modify safely as more gating conditions get added. Consider an ordered array of [condition, reason] pairs and picking the first true one.

♻️ Suggested refactor
-  const highlightDisabledReason = !handoff.ifcGuid
-    ? t(...)
-    : !handoff.usdPrimPath
-      ? t(...)
-      ...
-                : "";
+  const highlightDisabledReasons: Array<[boolean, string]> = [
+    [!handoff.ifcGuid, t("handoff 缺 ifc_guid,無法高亮", "handoff is missing ifc_guid")],
+    [!handoff.usdPrimPath, t("缺 usd_prim_path / mapping,需先補 mapping artifact", "missing usd_prim_path / mapping")],
+    [!validSession, t("尚未輸入有效 review session", "enter a valid review session first")],
+    [!sessionObserved, t("runtime/status 未列出此 session(可能 stale / 已關閉)", "runtime/status does not list this session (possibly stale / closed)")],
+    [!activePrimaryLease, t("需先手動啟動 / attach Kit session", "manually start / attach the Kit session first")],
+    [!firstFrame, t("等待 3D 第一幀", "waiting for first frame")],
+    [!dataChannelReady, t("等待 viewer DataChannel", "waiting for viewer DataChannel")],
+    [!stageMatched, t("stage 未對齊,禁止誤標", "stage mismatch; highlight is blocked")],
+  ];
+  const highlightDisabledReason = highlightDisabledReasons.find(([cond]) => cond)?.[1] ?? "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx` around lines 201 -
225, The highlightDisabledReason logic in ReviewSessionViewerPane is correct but
too deeply nested to maintain safely as more checks are added. Refactor the
chained ternary into an ordered sequence of condition/reason pairs evaluated in
priority order, returning the first matching reason, and keep the existing
translation keys/messages intact for each gating condition.
bim-review-coordinator/src/services/viewerLeaseStore.ts (1)

305-316: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Logic duplicated verbatim in the frontend.

The identical stageUrlsEquivalent implementation is duplicated in web-viewer-sample/src/console/ReviewSessionViewerPane.tsx (lines 52-63). Any future fix (including the host-comparison gap above) has to be applied in two places, risking drift between coordinator-side validation and client-side gating.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bim-review-coordinator/src/services/viewerLeaseStore.ts` around lines 305 -
316, The stage URL comparison logic in stageUrlsEquivalent is duplicated in both
viewerLeaseStore and ReviewSessionViewerPane, so update the implementation to
use a single shared helper instead of maintaining two copies. Extract the URL
equivalence logic into a common utility/module and have both the
coordinator-side validation and the frontend gating call that shared function,
so any future fix only needs to be made once.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md`:
- Around line 1-7: Add an explicit document-nature label to this spec so it
follows the docs classification guidelines. Update the front matter in the A1 3D
Review Decoupling Design document to include a clear nature value such as spec
design, contract, or wiki, instead of only the project-specific type string.
Keep the existing spec title and scope, but make the document’s role obvious for
readers and tooling.
- Around line 103-120: Update the handoff contract in the A1ReviewHandoff
section to match the actual parser and highlight requirements: include
usd_prim_path as a first-class optional field, and make the session identifier
canonical by choosing one key name instead of mixing review_session_id with
session. Use the A1ReviewHandoff interface and the Transport notes to align the
spec so implementers know exactly which payload keys are supported.

In `@web-viewer-sample/src/console/pages.tsx`:
- Around line 311-321: The mount-time session fetch in pages.tsx hides
coordinator failures by treating a rejected runtimeStatus() like an empty
session list. Update the effect around coordinatorClient.runtimeStatus() to
store a dedicated error state (similar to ReviewSessionViewerPane’s runtimeErr
and the existing minioErr pattern) when the call fails, and use that state in
the UI branch that currently renders “無 active session” so
unreachable/coordinator-down is shown as an error instead of a normal empty
state.

In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx`:
- Around line 96-97: The dataChannelReady state in ReviewSessionViewerPane is
currently a proxy for viewer lifecycle events rather than a real DataChannel
signal. Update EmbeddedViewer/ReviewSessionViewerPane wiring to use an actual
DataChannel-ready callback if one exists, or otherwise rename the state and the
review-room-runtime-evidence “DataChannel ready” field to reflect the real
condition and keep it not_observed until a true signal is available. Use the
existing firstFrame, onViewerReady, onStageLoaded, and dataChannelReady
references to locate the affected logic and replace the misleading readiness
gate.

---

Duplicate comments:
In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx`:
- Around line 52-63: The stage URL comparison in stageUrlsEquivalent is missing
host validation for http: and https: URLs, so update the logic to compare the
host as well as protocol, pathname, and search. Keep the behavior aligned with
the duplicate stageUrlsEquivalent in the coordinator code by applying the same
host-check fix there too, and use the existing function name to locate the
shared comparison logic.

---

Nitpick comments:
In `@bim-review-coordinator/src/services/viewerLeaseStore.ts`:
- Around line 305-316: The stage URL comparison logic in stageUrlsEquivalent is
duplicated in both viewerLeaseStore and ReviewSessionViewerPane, so update the
implementation to use a single shared helper instead of maintaining two copies.
Extract the URL equivalence logic into a common utility/module and have both the
coordinator-side validation and the frontend gating call that shared function,
so any future fix only needs to be made once.

In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx`:
- Around line 201-225: The highlightDisabledReason logic in
ReviewSessionViewerPane is correct but too deeply nested to maintain safely as
more checks are added. Refactor the chained ternary into an ordered sequence of
condition/reason pairs evaluated in priority order, returning the first matching
reason, and keep the existing translation keys/messages intact for each gating
condition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb4bb7e3-f314-44bb-8add-80e6f399c321

📥 Commits

Reviewing files that changed from the base of the PR and between 5bfa783 and fe7e8e6.

📒 Files selected for processing (12)
  • bim-review-coordinator/src/services/viewerLeaseStore.ts
  • bim-review-coordinator/tests/viewer-leases.test.ts
  • docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md
  • web-viewer-sample/src/console/A1ViewerEmbed.test.tsx
  • web-viewer-sample/src/console/EdgeConsole.tsx
  • web-viewer-sample/src/console/OperatorConsole.tsx
  • web-viewer-sample/src/console/ReviewSessionViewerPane.test.tsx
  • web-viewer-sample/src/console/ReviewSessionViewerPane.tsx
  • web-viewer-sample/src/console/console.test.tsx
  • web-viewer-sample/src/console/pages.tsx
  • web-viewer-sample/src/console/routing.test.ts
  • web-viewer-sample/src/console/routing.ts

Comment on lines +1 to +7
# A1 3D Review Decoupling Design

> 日期:2026-07-02
> 類型:A1 3D highlight architecture bug fix spec
> Scope:`web-viewer-sample` A1 console / dedicated 3D review screen / coordinator handoff
> 狀態:approved implementation spec for `spec-to-done`

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an explicit document-nature label.

As per coding guidelines, docs/**/*.md must mark the document nature (spec design, contract, wiki, etc.). The current front matter only uses a project-specific type string, which makes this spec harder to classify and easier to mistake for source-of-truth implementation docs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md` around
lines 1 - 7, Add an explicit document-nature label to this spec so it follows
the docs classification guidelines. Update the front matter in the A1 3D Review
Decoupling Design document to include a clear nature value such as spec design,
contract, or wiki, instead of only the project-specific type string. Keep the
existing spec title and scope, but make the document’s role obvious for readers
and tooling.

Source: Coding guidelines

Comment on lines +103 to +120
Recommended handoff payload:

```ts
interface A1ReviewHandoff {
source: "a1";
rule_run_id: string;
review_session_id?: string;
ifc_guid?: string;
usd_prim_path?: string;
rule_code?: string;
}
```

Transport:

- URL query/hash may carry non-secret IDs: `source=a1`, `rule_run_id`, `session`, `ifc_guid`, `rule_code`.
- Do not put viewer lease token in URL.
- The dedicated screen claims/refreshes lease through coordinator after user intent is explicit.

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Close the handoff contract gap.

Section 7 omits usd_prim_path, even though highlight is blocked without mapping evidence, and it mixes review_session_id with session while the current parser only reads session. That will drift the spec away from the actual handoff contract and leave implementers guessing which key is canonical.

♻️ Suggested contract shape
 interface A1ReviewHandoff {
   source: "a1";
   rule_run_id: string;
-  review_session_id?: string;
+  session?: string;
   ifc_guid?: string;
+  usd_prim_path?: string;
   rule_code?: string;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Recommended handoff payload:
```ts
interface A1ReviewHandoff {
source: "a1";
rule_run_id: string;
review_session_id?: string;
ifc_guid?: string;
usd_prim_path?: string;
rule_code?: string;
}
```
Transport:
- URL query/hash may carry non-secret IDs: `source=a1`, `rule_run_id`, `session`, `ifc_guid`, `rule_code`.
- Do not put viewer lease token in URL.
- The dedicated screen claims/refreshes lease through coordinator after user intent is explicit.
Recommended handoff payload:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-02-a1-3d-review-decouple-design.md` around
lines 103 - 120, Update the handoff contract in the A1ReviewHandoff section to
match the actual parser and highlight requirements: include usd_prim_path as a
first-class optional field, and make the session identifier canonical by
choosing one key name instead of mixing review_session_id with session. Use the
A1ReviewHandoff interface and the Transport notes to align the spec so
implementers know exactly which payload keys are supported.

Comment on lines +311 to +321
// Mount 時只列出可手動選取的 active/created session。不得自動選 act[0];
// 3D attach/lease 由 Review Room 明確按鈕啟動。
useEffect(() => {
let alive = true;
coordinatorClient.runtimeStatus()
.then((rt) => {
if (!alive) return;
const act = rt.sessions.items.filter((s) => s.status === "active" || s.status === "created");
setSessions(act);
if (act[0]) setSelectedSession(act[0].session_id);
setViewerOrigin(rt.configured_endpoints.viewer.browser_url_base || null); // 真 viewer 入口(:5173 baked),非 :8004
setCoordinatorBase(rt.configured_endpoints.coordinator.public_base_url || null); // handoff base(對齊 /ui/open)
})
.catch(() => { if (alive) { setSessions([]); setViewerOrigin(null); setCoordinatorBase(null); } }); // 連不上就空,不假資料
.catch(() => { if (alive) setSessions([]); }); // 連不上就空,不假資料

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Mount session loader hides connection failures behind the "no active session" state.

On runtimeStatus() rejection the effect only clears sessions to [], so the panel renders the honest-looking "無 active session" empty branch (Line 594-596) even when the coordinator is unreachable. That nudges the operator toward triggering a conversion instead of surfacing the real fetch error. ReviewSessionViewerPane surfaces runtimeErr for the same call; A1 should do likewise (a small sessionsErr note, mirroring minioErr).

As per coding guidelines: "UI must honestly label sections as DEMO DATA / NOT BUILT / not observed, not fake readiness (honesty protocol)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/console/pages.tsx` around lines 311 - 321, The
mount-time session fetch in pages.tsx hides coordinator failures by treating a
rejected runtimeStatus() like an empty session list. Update the effect around
coordinatorClient.runtimeStatus() to store a dedicated error state (similar to
ReviewSessionViewerPane’s runtimeErr and the existing minioErr pattern) when the
call fails, and use that state in the UI branch that currently renders “無 active
session” so unreachable/coordinator-down is shown as an error instead of a
normal empty state.

Source: Coding guidelines

Comment on lines +96 to +97
const [firstFrame, setFirstFrame] = useState(false);
const [dataChannelReady, setDataChannelReady] = useState(false);

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect EmbeddedViewer's prop/callback surface for a genuine DataChannel-ready signal
fd -e tsx -e ts 'EmbeddedViewer' web-viewer-sample/src/console
ast-grep run --pattern 'onDataChannelReady' --lang tsx web-viewer-sample/src/console/EmbeddedViewer.tsx
rg -n -C3 'DataChannel|datachannel|onOpen|onstatechange' web-viewer-sample/src/console/EmbeddedViewer.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 1360


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== EmbeddedViewer prop surface =='
ast-grep outline web-viewer-sample/src/console/EmbeddedViewer.tsx --view expanded || true

echo
echo '== Relevant message/handler wiring in EmbeddedViewer =='
sed -n '1,260p' web-viewer-sample/src/console/EmbeddedViewer.tsx | cat -n

echo
echo '== ReviewSessionViewerPane readiness wiring =='
sed -n '1,430p' web-viewer-sample/src/console/ReviewSessionViewerPane.tsx | cat -n

echo
echo '== Tests referencing dataChannelReady or highlight_result =='
rg -n -C3 'dataChannelReady|datachannel_not_ready|highlight_result|firstFrame|stageLoaded|onFirstFrame|onStageLoaded' web-viewer-sample/src/console

Repository: monkey1sai/AI-BIM-governance

Length of output: 50384


Wire a real DataChannel-ready signal
ReviewSessionViewerPane.tsx:96-97, 327-345

EmbeddedViewer only exposes onViewerReady, onFirstFrame, onStageLoaded, onHighlightResult, and onSelectedGuid — there’s no distinct DataChannel-ready callback. dataChannelReady is therefore just mirroring first-frame/stage-loaded events, so the !dataChannelReady gate and the review-room-runtime-evidence “DataChannel ready” field are reporting a proxy state, not actual DataChannel readiness.

If the viewer can emit a real DataChannel signal, wire that here; otherwise rename this state/label to match the actual condition and keep it not_observed until a real signal exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/console/ReviewSessionViewerPane.tsx` around lines 96 -
97, The dataChannelReady state in ReviewSessionViewerPane is currently a proxy
for viewer lifecycle events rather than a real DataChannel signal. Update
EmbeddedViewer/ReviewSessionViewerPane wiring to use an actual DataChannel-ready
callback if one exists, or otherwise rename the state and the
review-room-runtime-evidence “DataChannel ready” field to reflect the real
condition and keep it not_observed until a true signal is available. Use the
existing firstFrame, onViewerReady, onStageLoaded, and dataChannelReady
references to locate the affected logic and replace the misleading readiness
gate.

Source: Coding guidelines

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status passed
Risk low
PR 286
Head feat/a1-3d-review-decouple / 04e71a37057ef0ab74b8baa766e3ca0d25403841
Base main / 5bfa783a7cb3403271a38dba387d38b34032fb86

Blockers

  • None

Warnings

  • None

Validation Commands

  • npm run verify
  • npm run verify

Checks

  • passed bim-review-coordinator verify (bim-review-coordinator)
  • passed web-viewer-sample verify (web-viewer-sample)

Human Review Notes

  • OpenSpec archive or formal spec evidence detected; active change-id validation was skipped.
  • Optional AI adapter is not required by policy and was skipped.

@monkey1sai
monkey1sai merged commit a334e49 into main Jul 3, 2026
12 checks passed
@monkey1sai
monkey1sai deleted the feat/a1-3d-review-decouple branch July 3, 2026 02:25
monkey1sai added a commit that referenced this pull request Jul 4, 2026
* docs(spec): 七軸(A1/CV/SS/KG/M/IN/RT)跨頁和諧整合 spec

新增 spec-to-done 用整合設計:跨頁 handoff 契約、共享狀態/證據列、
A1↔Review Room 既有交握延伸;不合併路由、不新增後端、不重裁 A1 3D 架構。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyhkWS4Y4SZr1PGakMEKyF

* plan: 七軸跨頁和諧整合實作計畫(14 tasks,frontend-only 加法)

依 superpowers writing-plans 規格,由 spec 2026-07-03-seven-axis-cross-page-harmony-design.md
產出逐 task TDD 實作計畫:共用 handoff.ts(CrossAxisHandoff build/parse)+ SharedStatusProvider
單一輪詢 + SharedStatusRail + 七軸 cross-link chip + CV 轉檔歷史 panel(GET /api/dev/conversions)
+ KG 真 session 聚合 + Review Room 候選 seed + browser E2E。零新後端、零新路由、零新 production
dependency,diff 全落 web-viewer-sample/src/console/ 與 e2e/。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* plan: fix 修進 reviewer 五項 blocker/major(Task6 regex、Task14 空洞斷言、接收端重驗、a1-conv-link、Task7 覆蓋)

依 reviewer 發現逐項修入 plan(只改 plan 文件,不動其他檔;不刪需求):

- [buildability/completeness] Task 6 Step 1 測試改用 DOMParser 逐鈕查 `disabled`,
  取代 `/data-testid="a1-link-minio"[^>]*disabled/` 正則。Btn(components.tsx:113)
  屬性序 disabled 先於 data-testid 且 data-testid 為最後一個屬性,原正則永不 match
  (即使鈕真的 disabled);Step 5「Expected: PASS」矛盾一併消解。

- [spec-alignment] §4.3 A1 → CV 列標「既有連結,補帶 source/id」但 plan 零覆蓋:
  Task 6 新增 Step 4b 把 pages.tsx:604 的 a1-conv-link(`#/conv`)改成
  `buildHandoff("conv",{source:"a1",job_id:convJobId??undefined})`,並補測 href 斷言。

- [spec-alignment] §4.2(硬性)/§12/§13 要求接收端一律以 ID 重驗、查無誠實 not-found、
  禁靜默 fallback,但 plan 只做發送端。新增 Task 14「Receiver-side handoff
  re-verification」:共用 useIncomingHandoff+IncomingHandoffBanner,對 M/A1/CV/SS/KG
  五接收頁以已抓的權威資料重驗(零新後端 N2/N4);原 E2E 順延為 Task 15。
  另於 Task 13 補測 parseReviewRoomHandoff 接受 conv/sessions/intake/runtime 非 a1 source。

- [completeness] Task 15(原 14)E2E 的 `a1-embedded-viewer` testid 全庫不存在,
  toHaveCount(0) 真空通過、對 N3 零防護。改以真實存在的 review-room-viewer-host
  (ReviewSessionViewerPane.tsx:313,僅 Review Room 渲染)做差異式閘,並註明 exhaustive
  防線仍是 A1ViewerEmbed.test.tsx;另補 M→CV 落地後 conv-incoming-handoff 接收端斷言。

- [task-decomposition] Task 7 三加法只測 history panel,ledger/job 三個 chip testid
  無斷言即進 commit。補一輪 chip 斷言(conv-ledger-minio/conv-job-session/conv-job-review
  存在+導頁),Step 7 預期改 3 tests;並註明可等價拆兩 task。

同步更新 File Structure(新增 incomingHandoff.tsx)、Self-Review Checklist 與 OQ4 的
Task 14/15 交叉引用。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* plan: fix Task14 五接收頁補齊具體 code+測試、Task15 補 §8 stitched E2E 與誠實 checklist

修 reviewer 三項 blocker/major:

Task 14(接收端重驗,completeness + task-decomposition major):
- Step 4 把 A1/CV/SS/KG 四頁的散文指引改為可直接貼上的 code block,
  每頁綁真實 in-memory state var(A1=minioObjects、CV=jobs/records、
  SS=rt?.sessions.items、KG=shared.sessionsById)並附精確 render 行號錨點,
  M 也補上 <h1>(1608) 錨點;連 §4.3 硬性重驗鐵律於五頁一致。
- Step 1 receiving-pages 測試新增 A1(verified)/CV(verified)/KG(not_found)
  三個 per-page 斷線斷言,補齊 imports;五頁均可獨立驗證,接錯 state var
  或漏接即 npm test -- incomingHandoff.test.tsx 失敗(原本只斷言 M/SS)。

Task 15(Browser E2E,spec-alignment major):
- 新增一支 §8 stitched walk-through 測試(M→IN→CV→A1→Review Room→回 A1 issue),
  各 infra-heavy leg 用誠實 test.skip(同既有 chip 測試 pattern),不偽造。
- 修正 Self-Review Checklist 過度宣稱;補「§8 coverage & honesty」註記。
- 誠實查證:reviewer 建議引用的 VG-01(viewer-embed-a1-highlight.spec.ts)
  與 a1-minio-governance-3d.spec.ts 於 a334e49 解耦後已 stale(斷言已移除的
  a1-first-frame-evidence/a1-stage-matched/a1-highlight-3d),不得當覆蓋依據;
  四格深度證據由 Review Room 產出、需 live Kit session、CI 誠實 skip(skip != pass)。

只動 plan 文件;testids 全經 pages.tsx / ReviewSessionViewerPane.tsx 查證為真。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#0: feat(console): 新增 CrossAxisHandoff build/parse util(七軸通用 handoff)

- 新增 handoff.ts:AxisKey/CrossAxisHandoff 型別、isAxisKey/buildHandoff/parseHandoff
- buildHandoff target 收斂為 string(而非 AxisKey):既有 A1→Review Room pattern
  用 "review" 當 target,屬 alias route 非七軸之一(spec N1),AxisKey 過窄會與
  自身測試矛盾,故放寬(僅型別註記,行為不變)
- PAYLOAD_KEYS 型別排除 "source" 避免 parseHandoff 寫入時的 TS2322
- 4 個 vitest 全綠;tsc --noEmit 維持 baseline(僅 1 個既有無關檔案錯誤,無新增)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#1: feat(console): 新增 coordinatorClient.getConversionsHistory() wrapper(GET /api/dev/conversions)

薄封裝既有 GET /api/dev/conversions(conversion service 側 job 歷史,與 coordinator ledger
getConversionRecords 不同源);後端不改動(N2/N4)。新增 DevConversionRecord pass-through 型別
供 Task 7 消費。TDD:先寫失敗測試(getConversionsHistory is not a function)→ 補最小實作 → 通過;
順手修正測試檔內 afterEach(() => vi.restoreAllMocks()) 的隱式回傳型別(VitestUtils 非 void)觸發
的 tsc 錯誤,改用與既有 coordinatorClient.test.ts 一致的花括號寫法(行為不變)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#2: feat(console): 新增 SharedStatusProvider 單一輪詢 + useSharedStatus hook(§5)

新增七軸跨頁狀態共享層:useSharedStatus.ts 定義 SharedSessionEntry / SharedStatusSnapshot 型
別、EMPTY_SHARED_STATUS 預設值、SharedStatusContext 與 useSharedStatus() hook;
SharedStatusProvider.tsx 是全 console 唯一對 GET /api/runtime/status 做 5000ms 定時輪詢的
地方(既有各頁自己的 mount-once fetch 不受影響),並用 getConversionRecords(100) 補轉檔佇列
深度(status ∈ detected/queued/converting 才計入)。GPU 節點欄位依 OQ3 恆為 null(未取得,非
偽造 0/0);stage_matched 依 §5.2 設計恆為 null。輪詢失敗時標記 stale=true、health="unknown",
不假裝資料仍新鮮。支援 value prop 供測試注入快照(跳過輪詢,測試 seam)。

TDD:先寫 SharedStatusProvider.test.tsx(3 案例:輪詢映射/失敗降級/注入不輪詢)→ 確認因缺模組
失敗 → 補最小實作 → 3 測試轉綠。驗證:npm run verify(build + 437 vitest + 10 struct-log 全過);
另用暫移新檔驗證 windowParentMessage.dom.test.tsx 既有 TS6133 warning 為 baseline 既存、非本次
引入。GitNexus detect_changes(staged) 確認 changed_files=3、risk_level=low,範圍與預期一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#2: fix 補齊 SharedStatusProvider stale 兩觸發條件 + 覆蓋 records 降級分支

gap1(漏做,spec §5.2/§5.4):stale 原本只在 poll reject 時翻真,缺「超過 2×
間隔」的時間 watchdog。請求掛起不 reject 時(背景分頁節流/連線卡住,jsonGet
無 AbortController/timeout)poll 停在 pending 不進 catch,stale 會永遠停在上次
成功值 false,把過期資料當新鮮,違反 §5.4。新增以 updatedAt/Date.now() 為基礎、
獨立於 await 是否 settle 的 setInterval watchdog:last-known-good 超過 2×pollMs
即翻 stale=true(已 stale 或尚無成功 poll 則回傳 prev 不 churn),cleanup 一併
clearInterval。

gap2(測試未覆蓋既有分支):新增測試涵蓋 runtimeStatus 成功但
getConversionRecords 失敗的 inner catch,證實 conversionQueue 誠實降為 null、
外層 poll 不崩、runtimeStatus 半邊仍正確映射。

驗證:SharedStatusProvider.test.tsx 5/5 綠(watchdog 先紅後綠、以預期原因失敗);
全套 vitest 36 檔 439 測全綠;tsc 我方檔案 0 error(僅既有 windowParentMessage
無關 baseline TS6133)。

未動 coordinatorClient.jsonGet 的 fetch timeout:跨所有端點、屬前端凍結契約、
blast radius 大,且 watchdog 已足以滿足 spec stale 語意,故列為 advisory 不在本
task 動它。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#3: feat(console): 新增 SharedStatusRail 元件與誠實渲染規則(§5.3/§5.4)

新增跨頁共享狀態列元件,讀 useSharedStatus() 單一真相來源,顯示 Active
sessions/GPU/Health/轉檔佇列/資料時間五項指標。GPU 與轉檔佇列在值為
null 時一律渲染「未取得」(非假綠燈);health="unknown" 顯灰、不與 ok/fail
混淆;stale=true 時整列變暗且顯示「資料過期」,不把舊值當即時呈現。點擊
GPU/Health/Active sessions 指標經 buildHandoff() 導向對應權威頁
(#instances/#runtime/#sessions),沿用既有七軸 handoff 慣例。

CSS 僅視覺(.ec-statusrail 區塊,附加於 edge-console.css 檔尾),測試不依賴
樣式,一律鎖 data-testid。

驗證:SharedStatusRail.test.tsx 5/5 綠(先紅:找不到模組 ./SharedStatusRail
→後綠);全套 vitest 37 檔 444 測全綠;tsc 僅既有 windowParentMessage.dom
.test.tsx pre-existing 錯誤(未觸碰檔案,非本次引入)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#2: fix SharedStatus 佇列低報與孤兒輪詢兩個 quality 發現

Important #1:conversionQueue 在 ledger 超過回傳窗上限(後端 parseListLimit 100)
時,原本直接對被截斷的 recs.items 算佇列狀態筆數,會靜默低報真實佇列深度,操作員
會把低估值當真。比照 pages.tsx ledgerChipStatus 的 recordsIncomplete 模式:
recs.count > recs.items.length(截斷)時退 conversionQueue=null(未取得),不臆測
(誠實鐵律 / §5.4)。

Important #2:aliveRef 原為跨 effect 世代共用的 useRef。effect 因 pollMs/value 變動
重跑時,新 effect 會把同一個 ref 撥回 true,使舊 effect 卡在 await 的 poll resume 後
誤判自己仍存活,重新 setTimeout 排下一輪——這條孤兒輪詢鏈只存在舊 closure、新
cleanup 清不到,與正確迴圈並存重複打 API(違反 spec §12「只建立一條輪詢」)。改成
effect-local 的 let cancelled=false 閉包旗標,每個 effect 世代各自一份;一併移除不再
使用的 useRef import。

新增兩個回歸測試(截斷窗退 null、effect 重跑不產生孤兒 timer),先紅後綠;既有 5 測
試維持綠,tsc 僅既有 baseline TS6133(windowParentMessage.dom.test.tsx,未觸及)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#2: fix SharedStatusProvider.test 補 root.unmount 清理殘留輪詢 timer

原 afterEach 只做 removeChild(container) + restoreAllMocks + useRealTimers,
從未 unmount;且各 it() 用 const root 宣告在區塊內,afterEach 根本拿不到。
React 18 createRoot 的 effect cleanup 只在 unmount / deps 變動時觸發,單純把
容器移出 DOM 不會清,SharedStatusProvider 的 setInterval watchdog + setTimeout
下一輪 poll(spec §5.1 的 5000ms 自動輪詢)因此殘留在 worker event loop;
watch 模式重跑會累加,且 restoreAllMocks 後殘留 timer 的下一輪會打到真的
coordinatorClient.runtimeStatus()。

比照同目錄 EmbeddedViewer.test.tsx 慣例修正:root 提升到 describe 作用域、
beforeEach 重置為 null、afterEach 改 async 先 await act(() => root.unmount())
再 removeChild,在還原 mock / real timer 之前清掉 effect 註冊的 timer;
render 呼叫比照慣例用 root! 非空斷言。

驗證:npx vitest run SharedStatusProvider.test.tsx 7/7 綠;npx tsc --noEmit 僅
剩既有 baseline windowParentMessage.dom.test.tsx:292 TS6133(不在本 diff);
eslint 該檔 0 warning/error。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#3: fix watchdog 觸發 stale 時一併把 health 降為 unknown(§5.4 誠實渲染)

SharedStatusProvider 的 watchdog(輪詢掛起、從未 resolve/reject 的路徑)原本只把
snapshot.stale 撥 true,卻沿用上一輪成功的 health(例如 "ok")。SharedStatusRail 的
health 徽章文字與 health-* CSS class 都純由 s.health 推導,於是在資料已過期時仍渲染
綠字級別的 "ok",違反 spec §5.4「stale=true 不得呈現 last-known-good 為 fresh;
來源沉默 = unknown」。

修法:watchdog flip stale 時於同一次 setState 一併把 health 降為 "unknown",與 catch
分支及 EMPTY_SHARED_STATUS(兩者皆 stale=true 配 health="unknown")對齊;文字與色階
一次修正。

先補回歸測試(既有 watchdog 測試只斷言 stale,未斷言 health):斷言掛起超過 2× 間隔
(3100ms)後 health === "unknown",紅→綠。

驗證:SharedStatusProvider + Rail 12/12;full vitest 37 檔 446 綠;tsc 僅既有 baseline
TS6133(windowParentMessage.dom.test.tsx,與本 diff 無關);eslint 3 檔 0;build 綠。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#3: fix SharedStatus 輪詢掛起自癒 + 補 degraded 健康態回歸測試

Important #1(SharedStatusProvider):jsonGet 無 AbortController/timeout,單次請求永久
掛起會讓 poll() 卡在 await、finally(唯一排下一輪的地方)永不執行,整條輪詢迴圈死亡
直到手動重整。watchdog 增設 liveness 復活:pollGen 標記每輪、inFlightSince 記錄當前
請求起始時間,in-flight 超過 2× 間隔即判定 wedged,disown 舊 poll(gen 不符→丟棄,不
重複排程 §12)並重啟新 poll,後端恢復後迴圈自癒。既有 watchdog honesty 與 orphan-loop
測試不動即綠。

Important #2(SharedStatusRail):健康三態(ok/degraded/unknown)原本缺 degraded 的回歸
測試。補 health="degraded" 斷言 health-degraded class 與字面值,防日後誤接 t() 翻譯或誤
併入 unknown 分支。

驗證:vitest 全 37 檔 448 test 綠(+2 新測);tsc 僅剩既有 baseline TS6133;eslint 改動檔 0 error。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 補強 SharedStatusProvider wedge 復活測試驗 snapshot 回到健康

原「revives the poll loop after a request wedges」測試只驗 watchdog 持續重試
(statusSpy call count 增加),沒驗復活後 snapshot 真的恢復健康;原 mock 鏈
mockResolvedValueOnce(rt(1)).mockReturnValue(hang) 之後永遠回傳不 settle 的 hang。
改為 .mockReturnValueOnce(hang).mockResolvedValue(rt(2)),讓某次 watchdog 重啟的
poll 真正成功,前進數個間隔後新增斷言 stale===false / health==="ok" /
activeSessions===2,把「復活後端已收到 fresh 資料」這半也鎖進回歸測試。
純測試改動,不動 production code(§5.1 Important #1)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#4: feat(console): 在 EdgeConsole 頂層掛載 SharedStatusProvider + SharedStatusRail

七軸每頁共用同一份 runtime 真相(spec §5):EdgeConsole 頂層包一層
SharedStatusProvider(全 console 唯一輪詢),並在 ec-mainhead 的 FlowBar
之後插入 SharedStatusRail,依目前 hash 頁面算出 railAxis(七軸直接對映;
#gpu/#review 併入 runtime;其餘非七軸頁預設 a1)供狀態列高亮脈絡。
現有 header/nav/main/aside/footer JSX 內容不變,僅做外層包裹與單行插入。

新增 EdgeConsole.sharedstatus.test.tsx:驗證 shared-status-rail 出現在
DOM 中,且整個 console 只有一條 runtime/status 輪詢(不因多頁掛載而
重複打 API)。

驗證:npx vitest run(EdgeConsole.sharedstatus 1/1;console 全套 33
檔 407/407 綠,含既有 EdgeConsole SSR smoke 無回歸)、npm run build
成功、npx tsc --noEmit 僅餘與本次改動無關的既有 windowParentMessage.dom
.test.tsx TS6133(已用 git stash 比對 baseline 確認為既有問題)、npm run
test:struct-log 10/10 綠。GitNexus impact(EdgeConsole,upstream)=LOW
risk、0 upstream caller;detect_changes(staged) 僅回報 EdgeConsole.tsx
三個符號,經 diff 核對 usePageHash/read 為新增 import 造成的行號位移
雜訊、非真實行為變更。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#5: feat(a1): 新增 #minio / #sessions 證據型 cross-link chips

- A1 交付面板補兩顆 chip:回看 MinIO 來源物件(帶 minio_key)、跳
  Session 管理檢視此 session(帶 session id);目標 id 不存在時誠實
  disabled,不製造無效跳轉。
- 既有 a1-conv-link 錨點升級為 buildHandoff("conv", {source:"a1",
  job_id}) 產生的 #conv?source=a1[&job_id=...],取代舊的裸 #/conv
  (spec §4.3 A1→CV 列:既有連結,補帶 source/id)。
- 新增 A1CrossLinks.test.tsx:SSR smoke test(renderToString +
  DOMParser,無 mock),驗證兩顆 chip 存在且未選取時 disabled、
  a1-conv-link href 帶 source=a1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#5: fix 補 A1 cross-link chip 導覽行為回歸測試

review 發現:A1CrossLinks.test.tsx 只驗證未選取時 disabled 與 caption
文案,從未驗證「已選取 → 點擊 → window.location.hash 內容正確」的核心
行為,無法擋 buildHandoff 目標軸字串打錯(如 "sessions" 誤打成單數
"session",EdgeConsole 無此 case 只會靜默 fallback 到 HomePage)或
minio_key / session 兩參數寫反。

在 A1ViewerEmbed.test.tsx 復用既有 renderA1 / selectSession / act /
flush harness,新增一個 client render 情境:選 MinIO 物件 + 選 review
session 後分別點擊 a1-link-minio / a1-link-sessions,解析 hash 斷言
且未交叉洩漏。已以暫時注入單數 "session" typo 驗證此測試會 fail
(line 210 startsWith("#sessions?"))確認有回歸擋防力後還原。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#6: feat(conv): 新增轉檔歷史 panel + #minio / #sessions|#review cross-link chips

- 新增 conv-history-panel:讀既有 GET /api/dev/conversions
  (coordinatorClient.getConversionsHistory,Task 2 已建),呈現
  conversion service 側 job 歷史 pass-through;prov="artifact"(形狀非
  本專案定義);載入失敗誠實顯示「未取得」而非假空表;空陣列另顯「非
  錯誤」提示。history/historyErr 與既有 records/recErr 各自獨立
  useEffect,不污染既有 ledger/ifc-ready 載入時序。
- Ledger 列 Control cell 補 conv-ledger-minio-<idem> chip:object_key
  存在才掛(evidence-typed),導到
  buildHandoff("minio",{source:"conv",minio_key,conversion_id});與既
  有「觸發轉檔」鈕互不排斥、可同列並存。
- Ifc-ready job 列 session cell 補 conv-job-session-<jobid> /
  conv-job-review-<jobid> 兩顆 chip:review_session_id 存在才掛,分別
  導到 #sessions?source=conv&session= 與
  #review?source=conv&session=;接收端依 spec §4.2 重驗 id,不靜默
  fallback。
- 新增 ConversionHistory.test.tsx(3 tests):history panel 正常/失敗
  兩態、ledger+job 列三顆 chip 存在性與點擊導覽(hash 含
  source=conv&session=)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* plan: fix Task7 轉檔歷史第三欄改用 source_ifc_filename

created_at 經 Task7 實作查證,後端 GET /api/dev/conversions 三條回應組裝
分支結構性從不回傳(僅存在內部 job dict 供排序),對真實資料恆為 undefined。
使用者裁決:換成 source_ifc_filename(setdefault 保證存在,顯示轉檔的是哪個
IFC 檔,較 created_at 更有用)。同步修正 Task2 段落的 interface 說明,
避免文件與程式碼不一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#6: fix 轉檔歷史第三欄改用 source_ifc_filename(呼應 770a10a plan 修正)

轉檔歷史 panel(GET /api/dev/conversions pass-through)先前實作沿用
DevConversionRecord.created_at 顯示第三欄,但後端 _conversion_result_list_item
(conversion_authority.py:661-679)結構性從不對此欄位賦值——只存在內部 job
dict 供排序;序列化進 API 回應的只有 setdefault 保證存在的
source_ifc_filename。故 created_at 對任何真實資料恆為 undefined,畫面永遠
顯示「—」。plan 檔已在 770a10a 訂正描述,本 commit 補上對應的程式碼修正。

- coordinatorClient.ts:DevConversionRecord.created_at → source_ifc_filename
- pages.tsx:轉檔歷史表頭與儲存格改讀 source_ifc_filename
- ConversionHistory.test.tsx:mock 加入 source_ifc_filename 值並斷言渲染
  (先跑此測試確認因讀 created_at 而失敗,套用修正後再轉綠)

驗證:ConversionHistory.test.tsx(3)+ ConversionSchedulingPage.test.tsx
(39)+ coordinatorClient.conversions-history.test.ts(1)全綠;全套
vitest 40 檔 455 測試全綠;tsc --noEmit 僅餘既有無關檔案(不在本次改動
範圍)1 個 pre-existing 錯誤;vite build 成功。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#6: fix 補齊 CV cross-link chip 證據型雙向回歸測試

quality 發現:ConversionHistory.test.tsx 只驗 conv-job-review 一顆 chip 的
click→hash,另兩顆(conv-ledger-minio / conv-job-session)只驗 DOM 存在;且
spec §12「目標 ID 缺 → chip 消失」方向零覆蓋,日後若把 truthy gate 寫反無測試可抓。

- 正向:三顆 chip 各驗一次 click→hash(minio_key 經 parseHandoff round-trip
  斷言 CJK/斜線精確還原;session 帶 source=conv)。
- 反向(新測):object_key / review_session_id 皆 null 時三顆 chip 皆不 render,
  且列本身仍 render(證明是 chip 條件隱藏、非整列消失),與正向配對夾住條件。

純測試改動不動 production;vitest 40 檔 456 綠、tsc 僅餘既有無關錯誤、
eslint 改動檔 0 error。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#7: feat(sessions): 新增 per-row #instances / #review / #a1 cross-link chips

SessionManagementPage 的 Active sessions 動作欄在既有「結束 session」鈕旁
補三顆證據型 chip(session-link-instances-<id> / session-link-review-<id> /
session-link-a1-<id>),各自用 buildHandoff 帶 source=sessions + session id
導向 #instances / #review / #a1。SS 頁維持自己 mount-once runtimeStatus
抓取不變(N6),不耦合 useSharedStatus;接收端依既有 §4.2 規則自行重驗
session id。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#8: feat(instances): 新增即時 session 聚合列 + demo 列 #sessions cross-link chip

KG(#instances)原本 100% 靜態、無任何 fetch;現在讀 useSharedStatus() 呈現真 session
聚合(asbuilt,data-testid=kg-live-aggregate,含每個真 session 的 #sessions 導覽鈕),
並在 demo Node snapshot 表首列補一顆導向 #sessions?source=instances 的 chip
(data-testid=kg-demo-link-sessions)。demo 表本身維持 prov="demo" 不動、不假裝接真
(N5);GPU per-node 遙測仍未取得(OQ3)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#8: fix KG 即時聚合只列 active session,closed 不假裝成即時可操作

sessionsById(spec §5.2)是全量 session 表(不分狀態,供跨頁 ID 查找重用),
且 coordinator 從不刪除 session(只 active→closing→closed,永遠保留)。原
liveIds = Object.keys(sessionsById) 未過濾狀態,把 closed/closing 的過期
session 在標題「即時 session 聚合(真實)」、prov="asbuilt" 的區塊渲染成可點的
kg-session-link 導覽鈕,且緊鄰只算 active 的「使用中 session 數」,並蓋掉誠實
空狀態文案——把過期 session 假裝成真實可操作(違反 N5 誠實鐵律)。

改為只取 status==='active' 的 session 當即時連結,與相鄰 activeSessions 聚合
一致;closed 過期 session 回到誠實空狀態。新增兩個回歸測試涵蓋 activeSessions=0
卻殘留 closed session、以及 active/closed 混雜兩情境。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#9: feat(minio): 新增 .ifc 物件 #conv / #a1 cross-link chips(中文 minio_key 往返)

M 頁(MinioDataPage)obj.role === "source_ifc" 列在既有觸發轉檔鈕後補兩顆
evidence-typed chip:minio-link-conv-<idk> 導向 #conv?source=minio&minio_key=...、
minio-link-a1-<idk> 導向 #a1?source=minio&minio_key=...,皆帶編碼後的 obj.key
(可能含中文,如 270專案/建築/v07/模型.ifc)。接收端(CV/A1)依 §4.2 重驗 minio_key。

新測試 MinioCrossLinks.test.tsx 驗證中文 key 經 buildHandoff → URL hash →
parseHandoff 完整往返不失真(OQ4 決定性 spike)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#9: fix 補 #conv cross-link 目的地路由前綴斷言(與 #a1 測試對稱)

MinioCrossLinks.test.tsx 第一個測試(navigates to #conv)原本只斷言 parseHandoff
解出的 source / minio_key,未驗目的地路由前綴。因 parseHandoff 只解析 query、不看
target 字串,若 onClick 誤寫成 buildHandoff("a1", ...) 此測試仍會通過,測試名稱與
實際斷言不對稱。補一行 window.location.hash.startsWith("#conv?") 斷言,與同檔 #a1
測試(line 58)及 Session/KitGpuFleet 姊妹測試家族的目的地前綴檢查對齊。

生產碼本身正確(pages.tsx:1844 buildHandoff("conv", ...)),純測試嚴謹度補強,
非修 live bug。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#10: feat(intake): 新增 job 列 #conv / #review cross-link chips

IntakePage 的 intake job 表新增「跨頁」欄:intake-link-conv-<jobid> 永遠顯示,
導向 #conv?source=intake&job_id=...;intake-link-review-<jobid> 僅在
j.review_session_id 存在時顯示,導向 #review?source=intake&session=...
(無 session 時不畫假 nav,符合 §4.2 誠實鐵律)。皆用既有 buildHandoff 產生
hash,接收端(CV / Review Room)依既有規則自行重驗 ID。

新測試 IntakeCrossLinks.test.tsx:驗證兩顆 chip 皆出現且點擊後 hash 正確;
以及無 review_session_id 時 review chip 不渲染(no fake nav)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#10: fix 補 IN #review cross-link chip 的 click→hash 斷言

IntakeCrossLinks.test.tsx 原本只斷言 intake-link-review-job_1 存在於 DOM
(not.toBeNull),從未點擊、從未驗證產生的 hash;#review chip 的 onClick
(session=…)因此無任何行為防護,若複製貼上失手把 session 換成 job_id,CI 會
靜默通過。改為比照同檔 #conv chip 與 sibling ConversionHistory.test.tsx 的
既有 pattern,逐一 click→斷言 hash 含 #review?source=intake 與
session=review_session_a。

已 red-verify:暫時把 pages.tsx 的 session 改成 job_id,新斷言如期以
「expected '#review?source=intake&job_id=job_1' to contain
'session=review_session_a'」失敗,還原後 2/2 綠。

Verify:npx vitest run src/console(39 files / 428 tests 綠)、tsc --noEmit
本 diff 0 新增錯誤、eslint 該檔 exit 0、vite build 成功、git diff --check 乾淨。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#11: feat(runtime): CoordinatorPage 新增跨頁 session 連結 Panel

RT 值班視圖(#runtime)新增「跨頁 session 連結」Panel,列出
rt.sessions.items 每個 session 並提供三顆證據型 chip:
- rt-link-sessions-<id> → #sessions?source=runtime&session=<id>
- rt-link-review-<id>   → #review?source=runtime&session=<id>
- rt-link-instances-<id> → #instances?source=runtime&session=<id>

複用既有 buildHandoff(Task 1)與 CoordinatorPage 既有 rt state,
不改 CoordinatorGovernanceTabs(endpoint/role-keyed rows 加 chip 不安全),
不動四分頁路由(D2-A′維持)。RT 的共享狀態消費由全域 rail(Task 5)
已滿足,本 Panel 只補 session 層級的跨頁導航。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#11: fix RT 跨頁 session 連結對 closed session 停用即時操作鈕(N5 誠實鐵律)

CoordinatorPage 的「跨頁 session 連結」Panel(prov="asbuilt")原對 GET /api/runtime/status
回傳的全量 rt.sessions.items 直接 .map(),不分 active/closing/closed 都渲染三顆滿血可點鈕。
coordinator 從不刪除 session(只 active→closing→closed,永遠保留),此表隨時間無界成長,
把已結束 session 假裝成即時可操作——與同分支前一 commit 0860a54(task#8)剛修的 KG 即時
聚合同型 N5 違規。

比照 0860a54:以 live = s.status === "active" 判定,對「在 Review Room 開此 session」
「Kit / GPU 機隊」兩顆即時操作型鈕在非 active 時 disabled + 誠實 caption/title(session 已結束);
「Session 管理」是 lifecycle 全量治理視圖,對已結束 session 給連結語意合理,保留 enabled。

新增兩個回歸測試:closed-only(Review/KG disabled、SS enabled)與 active+closed 混雜
(只 active 列可操作);既有 active 導航測試不變。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#11: fix RT 跨頁 session Panel false-empty 分流 + 補 SS/Review chip handoff 斷言

Important #1(N5 誠實鐵律):CoordinatorPage 跨頁 session Panel 原本 rt===null(初載/
Refresh 失敗)與 items 為空共用同一「無 session」文案,把「連不上 coordinator」講成「確實
無 session」。改為比照同頁 IntakePage:err 先以 ec-warn-note 浮出,rt===null 且無 err 顯示
「讀取中」,只有 rt 已回且 items 為空才顯示 confirmed-empty(並註明 coordinator 已連線、
非錯誤)。err 一起浮出也讓 Refresh 失敗、rt 停舊值時有錯誤線索。

Important #2:CoordinatorCrossLinks.test.tsx 原本只驗 SS/Review 鈕 disabled 布林,未驗其
onClick handoff 內容。比照 IntakeCrossLinks / SessionCrossLinks,為 rt-link-sessions-* 與
rt-link-review-* 各補 click→hash 斷言(#sessions / #review?source=runtime&session=…),並新增
fetch 失敗時不得 false-empty 的紅→綠回歸測試。

驗證:vitest CoordinatorCrossLinks 4/4、全庫 45 檔 474 tests 綠;tsc 僅 1 個 pre-existing
錯誤(windowParentMessage.dom.test.tsx,commit 1f97127,早於本範圍);eslint 僅 2 個
pre-existing _reason unused(pages.tsx:999/1712,commit d5e9286);vite build 綠。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: CoordinatorPage confirmed-empty 分支加 err 守門,避免 Refresh 失敗與「無 session」並存

初載成功且 0 session 後按 Refresh,若第二次 fetch 失敗,load() 只 setErr、不重置 rt,
rt 停在 0-session 舊真相(非 null),confirmed-empty 分支照舊渲染,導致紅字錯誤與
「coordinator 已連線,非錯誤」文案自相矛盾並存。比照同檔 IntakePage 的 {err ? "" : t(...)}
守門,有 err 時 confirmed-empty 文案讓位給上方錯誤訊息。新增回歸測試覆蓋此 Refresh 失敗情境。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#12: feat(review): 從 shared status 種入 Review Room session input 候選 datalist

- ReviewSessionViewerPane 新增 useSharedStatus() 讀 sessionsById,餵入新增的
  <datalist id="review-room-session-candidates">;既有 session input 加 list 屬性,
  input 仍是自由輸入欄位、不強制選單(additive,N3 安全)。
- 不動 claimPrimary、lease/heartbeat effects、sendHighlight、EmbeddedViewer wiring。
- 新增 ReviewSessionViewerPane.crosslinks.test.tsx:驗證 datalist 種子 + no auto-claim,
  並佐證既有 parseReviewRoomHandoff 本就接受非 a1 來源(§4.3 新 chip 確實能被 #review 消費)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#12: fix Review Room datalist 候選只列可 attach(active/created)session

ReviewSessionViewerPane 的 session input datalist 原用 Object.keys(shared.sessionsById)
把全量 session 全塞進候選。sessionsById(spec §5.2)是不分狀態的全量表,且 coordinator
從不刪除 session(active→closing→closed 永久保留),故長壽環境會累積大量 closed 過期 session。
原生 <datalist> 不顯示狀態,active 與 closed 的 session id 外觀無異;選到 closed 後才在按
「手動啟動」時發現 disabled——把過期 session 假裝成可 attach 候選(違反 N5 誠實鐵律)。

改為只取 status 為 active/created 的 session_id,與本 pane 既有 runtimeSessions 篩選
(line 123)與 sessionObserved/claimPrimary 的手動啟動 gate 同一組可 attach 狀態一致,
比照前一顆 task#8 對 KitGpuFleetPage 的 active-only 修法。新增回歸測試以 active/created/
closed/closing 混雜快照鎖住:只有 active/created 進 datalist,closed/closing 被排除。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#13: feat(console): 五接收頁補 incoming handoff 重驗(§4.2 誠實 verified/not_found)

新增 incomingHandoff.tsx 共用 useIncomingHandoff() hook + IncomingHandoffBanner;
串進 MinioDataPage / A1GovernanceWorkbenchPage / ConversionSchedulingPage /
SessionManagementPage / KitGpuFleetPage 五個接收頁,各自向頁面已抓取的權威資料
(folder.objects / minioObjects / jobs+records / rt.sessions.items /
shared.sessionsById)重驗 incoming id;查無一律誠實 not_found,不靜默 fallback
到其他紀錄。IN 為 sender-only 軸,依 spec §4.3 矩陣不加接收端。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#13: fix M 頁 incoming prefix handoff 落實接收端重驗(§4.2 誠實 not_found)

MinioDataPage 對帶 prefix 的 incoming handoff 原本無條件 `return true`,從不向已抓取的
權威資料(folder.folders/objects)查驗,結構上永不可能回 not_found,違反 task 接收端重驗
鐵律與 spec §4.2;且 prefix 從未寫回頁面 state,資料夾瀏覽器仍停在根目錄,banner『已重驗』
宣告為假(未落實 §4.3「A1 → M 回看選檔來源」)。

修法:
- verify predicate:prefix 分支改為向載入的 folder 重驗——folder.prefix 需等於請求 prefix
  (後端 minioClient.ts 回填該欄)且該層真有 folders/objects 才 verified;空層/未設定/尚未
  載入一律誠實 not_found,不靜默 fallback。
- 新增 effect:incoming prefix → 導覽到來源資料夾一次(讓 folder 真的載入該層再重驗),之後
  交還使用者手動導覽,不與 goUp/enterFolder 打架。
- minio_key 分支不動;其餘四接收頁不動。

測試:incomingHandoff.test.tsx 補 2 例(prefix verified:真的導覽到來源層+向該層重驗;
prefix not_found:導覽後空層誠實 not_found),先驗兩例以預期原因失敗再實作。web-viewer-sample
全套 493 測試(47 檔)綠、vite build 綠、tsc 對本次 2 檔零錯。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#13: fix M 頁 minio_key 接收端重驗導覽 + load 世代守門(§4.2 誠實 not_found)

CRITICAL:A1→M / CV→M chip 只帶 minio_key(從不帶 prefix),但接收端只查
「當層 folder.objects 是否含該 key」而不導覽。真實 S3 帶 Delimiter='/',巢狀 key
只落在其所在資料夾的 objects、根層 objects 不含它,故對真實 ≥3 層 key 恆誤報
not_found(正是 task 想落實的誠實 verified/not_found 被架構性打破)。修法:navigate
effect 除 prefix 外,minio_key 也導覽到 key 所在資料夾(末個 '/' 前路徑)後再重驗。

Important:掛載時「導覽 setPrefix」與「prefix 變更即重載」effect 併發兩個
getMinioFolder(根層+目標層),無守門則根層晚到的回應會蓋掉已導覽的正確 folder →
folder.prefix 退回 ""、假 not_found。load() 加遞增世代守門,過期回應丟棄、不覆蓋
畫面/錯誤/loading。

測試:incomingHandoff.test.tsx 兩個 minio_key fixture 改成尊重 delimiter 語意
(root 只含 CommonPrefix、深層 key 落在來源層),並新增 late-root 競態守門測試;
三者皆先 RED(無導覽 / 競態誤蓋)後 GREEN。全套 494 test 綠、tsc 對本次 2 檔零新增錯誤。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#13: fix 接收端重驗三缺陷(KG 物件注入假 verified/M 導覽後假 not_found/CV 截斷窗假 not_found)

- CRITICAL #1 KitGpuFleetPage:session 以 bracket 查 plain {} sessionsById 會命中 Object.prototype
  繼承名(constructor/toString…)恆真、對不存在 session 假報 verified(違反 §4.2 持有 ID≠已授權);
  改 Object.prototype.hasOwnProperty.call 只認真正的 own key。
- Important #2 useIncomingHandoff:接收端重驗是抵達時的一次性閘門,一旦 verified 就以 hash 簽章 latch;
  之後同一 handoff 因使用者手動導覽(M 頁 goUp/enterFolder 改 folder)而 verify 回 false 也不倒退成假 not_found。
- Important #4 ConversionSchedulingPage:jobs/records 回傳窗(limit 50)被截斷(count>items.length)時查無
  id 退 indeterminate(未明)而非誤報 not_found,比照本頁 ledgerChipStatus recordsIncomplete(§5.4 誠實鐵律)。
- HandoffVerifyStatus 增 indeterminate 中性態、banner 不掛 ec-warn-note;verify 回傳型別擴為
  boolean|"indeterminate",其餘四接收頁維持 boolean 不變(向後相容)。
- 測試 +6:latch/indeterminate 單元、KG constructor 注入、M go-up latch、CV jobs/records 截斷。
- 延後 Important #3(掛載載入期間假 not_found):需為五頁各接「權威資料尚未載入」訊號,CV(busy 起始 false、
  無 first-load flag)與 KG(空 sessionsById 無法區分未輪詢/真零)無乾淨訊號,暫緩併後續 task(reviewer 已授權)。

驗證:vitest 500/500(+6);tsc --noEmit 僅既有 windowParentMessage 錯(#273,與本次無關);
eslint 三檔零新增問題(stash 比對 HEAD 同 3 既有問題、行號僅位移)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 接收端重驗載入中回 indeterminate 不誤閃 not_found

Task14 Important #1:A1/SS/KG/M 四個接收頁的 incoming handoff verify
predicate 把「權威資料尚未載入」與「已載入但真的查無」都壓成 not_found,
導致掛載後第一個 fetch resolve 前的同步 render 誤閃 not_found 假警示。

比照 incomingHandoff.tsx 既有的 indeterminate 第三態(原僅 CV 用),
四頁在權威資料未載入時改回中性 indeterminate(未明)而非 false:
- A1:minioObjects===null
- SS:rt===null
- KG:useSharedStatus().stale===true(尚未輪詢過)
- M(minio_key 分支):folder===null(比照 prefix 分支既有的 !!folder 寫法)
CV 維持原狀(缺乾淨 loading 訊號,沿用既有截斷→indeterminate)。

各頁補一支回歸測試:以永不 resolve 的 Promise mock 掛載頁面,斷言
載入中 data-handoff-status 為 indeterminate 而非 not_found。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: test(e2e): 補跨軸 handoff 巡覽 walk-through 瀏覽器 E2E(§8, N7)

新增 cross-axis-handoff.spec.ts:5 個測試涵蓋七軸共享狀態列、M→CV/A1→Review/SS→Review
handoff 導覽 + 接收端重驗、以及 §8 生命週期 stitched walk-through(M→IN→CV→A1→Review
Room→A1 issue,深層 Kit GPU 段落誠實 skip)。已對 branch-isolated coordinator(:8005,
掛本分支 build:ui 產出,非部署區 :8004)跑真實 Playwright:2 pass + 3 honest skip(本
環境無 MinIO 物件/無 active session,符合 plan 預期),screenshot + trace 落
artifacts/e2e/。npm run verify(build + 504 unit tests + struct-log)全綠。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 跨軸 handoff E2E 誠實度標註與 CV 載入競態守門

依 quality 複審修兩處 Important(僅動 e2e/cross-axis-handoff.spec.ts,無 production code 變動):

Important #1:§8 walk-through 前補 COVERAGE & HONESTY NOTE — 明列 fixture-less 環境下僅
test 1(shared-rail)與 test 3(Review Room not-started)跑滿全程;深段(M→CV/IN→CV/A1
rule-run/Kit 四證據鏈)皆 honest-skip、目前 `not observed`,信心僅 code-review + 結構層級、
非 browser-E2E 佐證;並記錄取得深段真證據之法(branch coordinator :8005 + 真 MinIO source_ifc
fixture)。同時解掉深段註解對「§8 coverage & honesty note above」的懸空引用。

Important #2:CV records 初值 []、mount 後才 async fetch,且保留 parent 47f9975 未修的
截斷→not_found 載入窗。三處 data-handoff-status 斷言(isolated M→CV/§8 M-leg/§8 IN-leg)
改以 waitForResponse(/api/conversion/records)(promise-before-click 不漏接)等 ledger 落地,
讓斷言讀 CV 終態而非 pre-load not_found flash。

驗證:eslint --max-warnings 0 exit 0;playwright --list 5 支全編譯;git diff --cached --check
乾淨;detect_changes(staged)=0 symbols/low risk。未跑真 E2E(需 live coordinator + fixture,本機無)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 跨軸 handoff E2E 兩處競態守門(CV predicate 綁定 + skip 重試偵測)

quality 複審兩個 Important(皆為原始碼生命週期推導出的結構性競態,非實測 flake):

Important #1:waitForResponse predicate 原本只比對 `/api/conversion/records`
子字串,會被永遠掛載的 SharedStatusProvider 每 5s 背景 poll(limit=100)與 M 頁
mount fetch(limit=100)搶先 resolve,跟 CV 頁自己那次 loadRecords(pages.tsx:952,
limit=50)毫無關係 → 三處 predicate(M→CV 單測、§8 M-leg、§8 IN-leg)改綁 CV 專屬
`?limit=50`,不再誤吃背景 poll,真正守住 data-handoff-status 的 not_found 載入閃現。

Important #2:四處 skip/soft 偵測在 page.goto(waitUntil:'load')resolve 後立即
同步 .count(),未等 React mount 後 useEffect 的 on-mount fetch(getMinioFolder /
runtime status / ifc-ready jobs)渲染完 → 即使環境備妥真 fixture 也可能查太早回 0 而
誠實 skip,牴觸 §8 COVERAGE NOTE 敘事。改用 .waitFor({state:'visible', timeout:10s})
.then(bool) 重試偵測(比照本目錄既有慣例 a1-minio-governance-3d.spec.ts:28),只在真正
缺席時才 skip;genuinely-absent 仍在 ~10s 內落下。

line 159 host.count() 不在四處清單內、位於頁面已 render 之後且 viewer host 本就只在
手動 attach 後掛載,維持原樣(YAGNI)。

驗證:npx eslint(0 warning)、npx playwright test --list(5 支全編譯)、detect_changes
staged(changed_symbols=0、affected_processes=0、risk=low)、git diff --cached --check
(無 trailing whitespace)。未起真實 coordinator+fixture 實測(結構性守門)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 修正 cross-axis E2E 誠實標註與 indeterminate 斷言

Task 15 對抗複驗兩項未閉合 finding(皆為測試檔字串/斷言修正,不動測試邏輯):

- A1→Review Room leg 的 test.skip 理由與 §8 COVERAGE & HONESTY NOTE 原本把不可達
  歸因成缺真實 IFC rule-run 環境/fixture,但這是結構性死路:a1-open-review-room 需
  selectedSession 非空且 state.failed[0] 存在(a1ReviewRoomHandoffReason,
  pages.tsx:256-262;state 為純前端 useReducer, pages.tsx:283,306),本測試從不驅動
  選 session 或 rule-run,故無論接什麼環境都 100% skip。改成準確描述為測試範圍限制,
  非環境/fixture 限制(依原意不新增瀏覽器操作步驟,避免最後一個 task 增加自動化脆弱點)。

- 三處 data-handoff-status 斷言(M→CV 及 §8 M→CV/IN→CV leg)原本只接
  /verified|not_found/,未算進 indeterminate。CV verify predicate(pages.tsx:908-922)
  在 ledger 超過 getConversionRecords(50) 查詢窗時誠實回傳 indeterminate
  (incomingHandoff.tsx:9,49),屬誠實鐵律正確第三態;長期 ledger 累積超過 50 筆會使
  斷言間歇 flake。三處改為 /verified|not_found|indeterminate/ 並加註原因。

驗證:npx playwright test --list 解析成功(5 tests);detect_changes(staged)=0 changed
symbols / 0 affected processes / low risk;git diff --cached --check 乾淨。未跑瀏覽器
E2E(需 live coordinator,本次僅測試檔字串/斷言修正)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 補齊 cross-axis handoff E2E 之 HEAD 證據並修正 viewer-host 註解行號

Gap 1(必修):Step 2「services up 後跑一次」對 HEAD 從未執行過(前三個修改測試邏輯的
commit 皆自述未跑真 E2E,僅做 eslint / playwright --list 等靜態檢查)。本次對分支隔離
coordinator(:8005)補跑:
- 以 VITE_COORDINATOR_API_BASE=http://127.0.0.1:8005 重建 dist-ui,起隔離 branch
  coordinator(PORT=8005,不碰部署區 :8004)。
- E2E_COORDINATOR_BASE_URL=http://127.0.0.1:8005 playwright test cross-axis-handoff.spec.ts
  → 2 passed(shared-rail axis sweep、A1 no-embed / Review Room not-started)
  + 3 honest skip(M→CV、SS→Review、§8 walk-through;空 MinIO / 無 active session =
  not observed,skip != pass)。
- screenshot(cross-axis-rail-runtime.png、cross-axis-review-not-started.png)落 artifacts/e2e/。
- 已知界線:HEAD 的 CV load-race waitForResponse 守門與 indeterminate 斷言,只有在具真
  MinIO fixture + ledger >50 筆時才會被實際走到;本環境無,屬 not observed,非本 additive
  spec 範圍(誠實鐵律,未偽造)。

Gap 2(次要):line 55 註解引用 ReviewSessionViewerPane.tsx:313 過時,該 testid
(review-room-viewer-host)實際落在 :329(自 7b4f6a6 起即有偏移,非本次退化),改正行號。

驗證:npm run verify 綠(build + 504 unit + 10 struct-log);eslint 該檔 0 warning;
detect_changes(staged) changed_symbols=[] risk=low(僅註解,無 symbol 變更)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 修正跨軸 handoff E2E 誤導性註解(waitForResponse 守的是 screenshot 穩定畫面,非斷言嚴謹度)

quality Important #1:三處 data-handoff-status 斷言用 /verified|not_found|indeterminate/ 寬鬆 regex,
接受 CV 載入前的 transient not_found flash(records=[] & recordsTruncated=false → verify 回 false →
not_found;pages.tsx:865,904,908-922 + incomingHandoff.tsx:29-30)。因此 `await recordsSettled` 不改變
toHaveAttribute 的 pass/fail——真正效果是讓後面的 screenshot 拍到 CV 終態、而非 pre-load flash。

前次 commit(7d1b467/3a72b46/08b8327)措辭主張此守門是在「保護斷言讀終態不誤過」,實際只保護 screenshot
穩定畫面,會誤導後續維護者高估斷言的迴歸保護力。本次改動:
- 只改註解、不動斷言 regex(寬鬆 regex 在 fixture-less 環境是誠實的三態終值集合;收斂成 verified 會 flaky/說謊)。
- 校正 arm-site 與三處斷言周邊註解:明講 wait 買的是 screenshot frame、非更嚴斷言;標明此斷言為 wiring
  smoke test(banner 掛上 + CV 重驗成非-none 誠實態),per-input 鑑別(含 truncation→indeterminate)由
  incomingHandoff.test.tsx 單元測試負責。
- 移除會漂移的絕對行號自我引用,改用符號式指涉。

驗證:npx eslint(EXIT=0)、npx playwright test --list(5 tests 全編譯)與 baseline 一致;git diff --cached
--check 乾淨;detect_changes(staged) changed_symbols=[] risk=low(comment-only,無 production symbol)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 修正跨軸 handoff E2E 兩處 quality 發現(waitForResponse 註解誠實化 + 刪 A1 leg 不可達死碼)

Important #1:page.waitForResponse 在 HTTP response(header 收到)當下 resolve,不等 body 讀取 +
setRecords + React 重渲染;且 CV 的 transient 與 terminal 皆可能是 not_found、無 DOM signal 可辨別,
故 expect.poll 不可行、waitForTimeout 屬 anti-pattern。改採誠實作法:把 4 處「screenshot 呈現終態」
的過度承諾改弱為 best-effort/likely,明說不保證重渲染已入 DOM(isolated M→CV arm+inline、s8 M→CV
arm、IN leg)。斷言與 pass/fail 完全不變。

Important #2:a1-open-review-room 為 disabled={Boolean(a1ReviewRoomHandoffReason(...))},
selectedSession="" 時 reason 恆非空 → canOpen 恆 false → 原 test.skip(!canOpen) 之後的 183-203 行
為不可達死碼(任何環境、任何 fixture 皆到不了)。依「刪 code 拿到一樣結果視為 win」移除死碼,改以可達
且確定的 toBeVisible + toBeDisabled 斷言作 A1 終點(present-but-disabled 為真實可驗狀態),並同步修正
§8 誠實註解、測試標題與 screenshot 名稱,避免死碼冒充活路徑。

驗證:eslint 0 warning;playwright --list 仍 5 tests;isolated tsc(shim node global)exit 0;
detect_changes(staged) changed_symbols=0 / risk=low(純測試檔);git diff --cached --check 乾淨。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 跨軸 handoff E2E 之 M leg 軟性化解 A1 斷言遮蔽 + 補驗證層級誠實註解

- [task15-r2-important1] §8 walk-through 的 M leg 由 test.skip(!mHasConv) 改為軟性
  if (mHasConv){...}(比照同測試 IN leg 的 pattern)。Playwright 在 test body 內呼叫
  test.skip 且條件成立時會立即中止整支 test,原寫法在無 MinIO fixture(常態)時會於
  M leg 就整支中止,後段 A1 Review-Room CTA 斷言(toBeVisible/toBeDisabled)從未執行,
  與同測試 coverage note「present-but-DISABLED … holds in ANY environment」矛盾。改軟性
  if 後 A1 leg 在任何環境皆可達;同步把兩處 coverage note 的 test.skip 措辭更新為
  soft-gate,避免註解與程式碼互相矛盾。
- [task15-r2-important2] 在 A1 CTA 兩條斷言前補誠實註解:此斷言邏輯已對 pages.tsx 逐行
  核對(交付/Deliverables Panel 無條件渲染 pages.tsx:651、selectedSession 初值 ""
  pages.tsx:306 且不從後端還原 → a1ReviewRoomHandoffReason 恆非空 → disabled),但尚未
  經真實瀏覽器對運行中 coordinator 執行;live 驗證留待 spec-to-done P4 browser evidence
  階段,不在此重複搭分支 coordinator。未新增或修改任何測試邏輯。

僅改動測試檔(單一測試控制流 + 註解),未動 production symbol;eslint 與
playwright test --list 皆綠。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 補回 §8 跨軸 walk-through 之 Review Room + A1-issue 兩段並改容錯 CTA gate

必修 gap:§8 stitched walk-through 先前把 A1 leg 之後的 Review Room 與回 A1 建 Issue
兩段整段砍除(commit 5c0bb46),只斷言 a1-open-review-room present-but-disabled 就結束,
未涵蓋需求 Key Coverage Point #6 明列的 M→IN→CV→A1→Review Room→A1 issue 六段;需求僅把
deep Kit evidence(first_frame/stage_matched/datachannel/highlight)列為無 live session 可
誠實跳過的例外,並未豁免 Review Room 可達狀態與 A1 issue 段落。

修法:
- §8 walk-through 現在真正走完 reachable spine:A1 CTA→(容錯)→Review Room
  kit-not-started→回 A1 斷言 a1-step-issues present。Review Room 段經 CTA 自身的
  fixture-less target #review?source=a1(pages.tsx:711)到達,任何環境可達、不偽造 rule-run。
- 修正脆弱無條件 toBeDisabled:改成本檔其他 leg 一致的 waitFor→branch 容錯 pattern——
  CTA enabled(部署餵入 rule-run fixture)則點擊走真 A1→Review handoff、斷言
  #review?source=a1 URL;disabled(fixture-less)則斷言 present-but-disabled 再續走 stitch,
  不再會因未來 CTA 變 enabled 而硬 FAIL。
- deep Kit evidence 四點移到專用 test,以 waitFor→test.skip 提供誠實 not-observed
  runtime 訊號(skip != pass, N7),對齊需求 Steps「honest test.skip for non-observed cases」,
  不再只靠測試標題字串/註解揭露。

驗證:eslint exit 0;playwright --list = 6 tests(原 5+1);對 live coordinator :8004 跑兩支
§8 測試:reachable spine PASSED(21.1s,實走 A1 CTA disabled→review-room-kit-not-started→
a1-step-issues,截圖 s8-02/03/04)、deep Kit SKIPPED(誠實 N7 訊號);detect_changes(staged)
changed_symbols=0 / changed_files=1 / risk=low(純測試檔);git diff --cached --check 乾淨。

已知風險::8004 為部署區(main)build,缺本分支新 testid(shared-status-rail/a1-link-sessions)
故同檔 Test 1/3 於該環境失敗——與本次改動無關;§8 兩支用的是 pre-#286 已部署 testid 故有效受測。
分支隔離 stack(:8005)完整 live run + 真 rule-run 使 CTA enabled 之 handoff 串接仍屬 P4。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 跨軸 handoff E2E 兩處 quality 發現(deep Kit 死 skip 可達化 + SS→Review 補重驗斷言)

Important #1(§8 deep Kit evidence chain 永久死 skip):
原測試 goto #review?source=a1 後直接等 review-room-viewer-host,但該 host 只在
activePrimaryLease 為真時掛載,而 lease 唯一寫入點是 claimPrimary()、唯一呼叫處是手動啟動鈕
onClick(ReviewSessionViewerPane.tsx:185-211,295,324,329)。無 session、無點擊 → 永遠逾時 →
test.skip 在任何環境恆觸發,即便接上真 Kit GPU 也永不亮。改為從 #sessions 取真 active
session(帶 session-terminate 鈕的列,pages.tsx:1497)→ 手動 attach → 逐關 honest N7 skip
(無 active session / 鈕 disabled / 未掛載)→ 真 WebRTC frame 落地才斷言 first_frame +
datachannel_ready(onFirstFrame)。stage_matched / highlight_ack 誠實標為需 A1 rule-run
handoff payload、本路徑不帶,僅截圖。

Important #2(SS→Review 標題稱 re-verified 但未驗重驗):
原測試只驗 URL + review-room-kit-not-started,而後者只看 !activePrimaryLease
(ReviewSessionViewerPane.tsx:324),任何 id 恆顯示、無法證明接收端重驗。改為選 active
session、由 URL 擷取真 session id,斷言 runtime-evidence 區塊回填該 id(擋空字串/參數打錯
wiring regression)且 runtime session=observed(sessionObserved 真源,
ReviewSessionViewerPane.tsx:119,314)。

兩處改用 active(非 terminating)列,因 store.list() 回傳含 closed 全量、依 updated_at
desc(sessionStore.ts:70-79 / app.ts:2753 未過濾),.first() 常抓到剛關閉的列,Review Room
會誠實回 not_listed 而假失敗。

驗證:eslint 0 / playwright --list 6 tests / 隔離 tsc --noEmit(process shim)0。
未跑:live coordinator 真瀏覽器(本環境無 live Kit GPU,測試 honest-skip,非失敗)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 跨軸 handoff 深層 Kit E2E 兩處 quality 發現(manual-start 啟用競態 + primary lease 收尾)

Important #1(啟用競態假性 skip):deep Kit 測試以一次性 .isEnabled() 快照判斷 review-room-manual-
start 可否 attach,但其 disabled gate(!viewerOrigin || !sessionObserved)依賴元件 on-mount 只跑一次、
不輪詢的 runtime/status fetch(ReviewSessionViewerPane.tsx:289,124-142)。按鈕無條件掛載、toBeVisible
幾乎立即通過而不等 fetch resolve,快照可能讀到 fetch 前的 disabled,把真有 live Kit session 的情境假性
skip 掉。比照本檔 M/SS leg 既有慣例,改用會自動重試的 expect().toBeEnabled({timeout}).then(true/false)。

Important #2(佔用真實 session primary lease 無收尾):測試對 live Sessions 表挑到的「真實 active
session」claimPrimary() 佔其 primary viewer lease,全程無釋放;最壞情況真人操作員近 45s(lease TTL,
viewerLeaseStore.ts:78)無法 claim 自己 session 的 primary 檢視。新增 describe 級 test.afterEach,用
獨立 request fixture 主動 POST release endpoint 歸還 lease(比照 VG-01 收尾模式;但只 release lease、
不 close 這條真實共享 session,以免破壞真人 session — VG-01 明列此跨套件干擾陷阱)。lease_id/lease_token
於點擊 manual-start 當下自 claim response 擷取(token 僅 claim 回應 includeToken 暴露, app.ts:1185)。

僅改單一測試檔 web-viewer-sample/e2e/cross-axis-handoff.spec.ts,無 production code 變動。
驗證:npx eslint(exit 0)、npx playwright test --list(6 tests 正確解析)、gitnexus detect_changes
(scope=staged:1 檔、0 production symbol、0 affected process、risk low)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 補齊 cross-axis E2E best-effort catch 與 timeout 預算

- waitForResponse(?limit=50) 三處(recordsSettled/convRecordsSettled/inRecordsSettled)補 .catch(() => null):讓註解宣稱的 best-effort 名實相符,15s 內無回應不再拋例外阻斷後面的 banner/attribute 斷言(比照同檔 claimSettled 既有寫法)
- §8 lifecycle walk-through 補 test.setTimeout(180_000):fixture 齊全時測試體會疊加約 10 個網路等待,逼近/超過 playwright.config.ts 的 60s 全域上限
- §8 deep Kit test.setTimeout 300_000 → 450_000:逐步 timeout 加總約 310_000ms 原本零緩衝甚至超標,比照 viewer-embed-a1-highlight.spec.ts:46 的 2 倍緩衝慣例留出餘裕

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix 跨軸 handoff E2E 補驗接收端真的帶到 id(quality Important #1)

三處 CV 接收端斷言原本只驗「有 handoff 且非 none」(toHaveURL + banner 可見 +
data-handoff-status 三態),未驗 chip 真的把 id 帶到 URL、receiver 真的 surface
出該 id;與同檔 SS→Review 已加固的模式不一致。因 minio_key/job_id 在 handoff.ts
為 optional(buildHandoff:39 丟空值、parseHandoff:51-53),chip 漏帶時仍得非 null
handoff,CV verify 落到 not_found(pages.tsx:917/909,921),與真實 ledger miss
外觀相同,原斷言完全偵測不到此 wiring 迴歸。

比照 SS→Review 既有 idiom(new URLSearchParams(...).get(k) + not.toBe(""))加固:
- M→CV(獨立)/§8 M leg:擷取 URL hash 的 minio_key 斷言非空 + banner toContainText(minioKey)
- §8 IN leg:擷取 job_id 斷言非空 + banner toContainText(jobId)
- §8 A1 CTA canOpen 分支:擷取 session 斷言非空(sender half;此分支目前 dead-in-practice,
  receiver 端重驗由 SS→Review 測試的 evidence 斷言擁有)

banner toContainText 依 handoffIdText(incomingHandoff.tsx:35)surface id,與 verify
狀態正交(not_found banner 仍含 id),不影響既有三態斷言。純新增斷言與註解,無 production
code 變動。

驗證:npx eslint e2e/cross-axis-handoff.spec.ts --max-warnings 0(exit 0)、
npx playwright test --list(6 tests 全解析)、git diff --cached --check(乾淨)、
detect_changes staged(1 file、0 production symbols、low risk)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* task#14: fix §8 walk-through test.setTimeout 低估預算並修正註解

reviewer Important 發現:cross-axis-handoff.spec.ts 行176 的 test.setTimeout(180_000)
註解宣稱「~10 個循序 network waits、no measurement needed、3× headroom」,但實測
fixture-full 情境下該 test body 有 14 個有限逾時的循序 blocking waits:
M leg 70_000 + IN leg 70_000 + A1/Review-Room/A1-issue 70_000 = 210_000ms,
已超過原 180_000ms 預算(方向性低估);且 5 個 page.goto(config use{} 未設
navigationTimeout)與 3 張全頁截圖的時間未計入,retries:0 下一次「慢但會過」的
run 就會硬 flake——正是此 timeout 想防卻沒防足的失敗類型。

修法:比照同檔 deep-Kit test(行311)的實測預算做法,將 test.setTimeout 上調至
360_000(約 summed caps 的 1.7×,對齊 viewer-embed-a1-highlight.spec.ts:46 的
360_000 既有慣例),並改寫註解逐項列出 14 個 waits 的加總依據,移除
「no measurement needed」。純測試檔註解+字面值變更,無 production symbol 異動。

驗證:npx playwright test cross-axis-handoff.spec.ts --list(6 tests 正常解析);
git diff --cached --check 無 trailing whitespace;gitnexus detect_changes
(scope=staged)= 0 changed symbols / 0 affected processes / low risk。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 修正跨頁 handoff 缺欄位假 not_found 並補 CV/E2E/spec 缺口

四項對抗複驗 findings:

1. p5-critic honesty regression(CRITICAL):接收頁 verify 對「handoff 缺本軸
   欄位(sender 本來就不帶)」與「查過、真的沒有」不分,一律 fall through 回
   false→假 not_found(警示紅字「查無」)。新增第三態 not_applicable(中性、
   不掛 ec-warn-note、不說查無),修正三條已重現路徑:SS→A1 帶真實 active
   session(最嚴重,原本對使用中 session 假報查無)、a1-conv-link 預設無
   job_id、KG demo-row 無 session;KG/M 同類 fall-through 一併對齊為 not_applicable。
   新增 4 條回歸測試(1 hook 探針 + 3 頁面路徑)鎖住此行為。

2. p5-e2:補 CV 頁 minio_key 接收端重驗單元測試(verified 含中文 key /
   not_found / indeterminate),比照 A1 既有模式,鎖住原本零覆蓋的回歸風險。

3. p5-e3:deep-Kit E2E first_frame 180s 逾時後多讀一次 runtime/status 的
   kit_instance_bindings status,若該 session 的 binding=failed 則 skip 訊息
   標「Kit 已配置但 WebRTC/stage pipeline failed」,別於「環境無 Kit」。純加法
   讀取既有欄位、best-effort try/catch,未動 ReviewSessionViewerPane.tsx。

4. p5-spec-drift:A1→M「MinIO 來源」chip as-built 送 minio_key(key-level 更
   精確)而非 spec §4.3 範例的 prefix。於 spec §4.3 表下註、A1 chip、M prefix
   分支文件化此既知差異(minio_key 本列於型別,屬合規選擇);prefix 收件分支
   +其單元測試保留為未來「純資料夾回看」能力。

驗證:web-viewer-sample vitest 全 511 passed(含新增 7 條);tsc --noEmit 改
動檔零錯;Finding 1 走 red→green;e2e 經 playwright --list 編譯通過(deep-Kit
本環境無 live Kit,誠實 skip,未跑到 enriched 分支)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 消除 CV minio_key 接收端重驗測試競態並補強 not_found 弱斷言

incomingHandoff.test.tsx 三支 CV minio_key 測試改用 waitFor 輪詢取代固定 2-tick:
- verified(中文 key,p5r2-critic-cv-miniokey-test-flaky):loadRecords 是與 load 獨立的
  useEffect async 鏈,2-tick 排不乾時載入中的空 records 也顯 not_found,會閃
  AssertionError(expected not_found to be verified)。改輪詢等 banner settle 成 verified。
- not_found(p5r2-critic-cv-miniokey-notfound-weak-assertion):先輪詢等 ledger 列
  (idempotency_key mw_r)反映到 render 再斷言,證明 loadRecords 接線完整+已反映,區隔
  「已載入且查無」與「fetch 從未發生/未反映」,修掉「兩種都會過」的弱斷言(實測:severing
  loadRecords 後本測試由通過轉為失敗,前為通過)。
- indeterminate(truncated)姊妹測試:同款輪詢,避免固定 tick 誤讀載入中的 not_found。

純測試改動,未動 production code(pages.tsx 驗證後回復 pristine)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 補 ReviewRoomHandoff 缺欄位(rebase onto origin/main 後型別擴充)

rebase 到 origin/main 後,main 的 A1 3D decouple 工作(a334e49)為
ReviewRoomHandoff 新增 mappingInformationStatus/mappingIssueCode/
mappingIssueCount 三個欄位。本分支 task#12 新增的
ReviewSessionViewerPane.crosslinks.test.tsx 建構的 handoff stub 物件
未帶這三個欄位,vitest 因不做完整型別檢查而未攔到,tsc --noEmit 才抓到。
補三個 null 值(與物件內其餘診斷相關欄位一致,此測試不涉及 mapping
診斷情境)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(evidence): 補 P4 browser evidence 抽樣截圖與 summary(隨 PR 可審)

真實隔離 coordinator(:8005)+ 真實 IFC 轉檔(洲際好宅/給水,job
stream_conv_20260703090757_89c87388)+ 真實 seeded review session
(review_session_0e4ee1079ee1)取得的 vertical slice 證據:4 passed、
2 honest skip(MinIO 憑證缺席、deep-Kit GPU 影格未觀測)、0 failed。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 修復 reviewer 兩位獨立命中的接收端誠實鐵律缺口(P2…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants