Skip to content

feat(A1): 自動取得 primary viewer lease - #273

Merged
monkey1sai merged 5 commits into
mainfrom
feat/a1-auto-primary
Jul 2, 2026
Merged

monkey1sai merged 5 commits into
mainfrom
feat/a1-auto-primary

Conversation

@monkey1sai

@monkey1sai monkey1sai commented Jul 2, 2026 •

Copy link
Copy Markdown
Owner

目標

讓 A1 治理模型檢核頁在嵌入 live viewer 前,先由 coordinator 自動取得 primary viewer lease,避免 UI 直接用 query/client intent 自稱 primary。

變更摘要

  • 新增 coordinator-managed ViewerLeaseStore,支援 claim / heartbeat / release / status。
  • 新增 review session viewer lease API:claim / heartbeat / release / status。
  • stage-binding 現在強制需要有效 primary lease token;移除 legacy no-token primary bypass。
  • A1 頁面改成 claim primary lease 成功後才 mount EmbeddedViewer。
  • viewer lease token 不再放 iframe URL query,改用 parent/iframe postMessage handoff。
  • A1 session switch / unmount 會 release lease;first frame / stage loaded 會 heartbeat。
  • 增加 one-time iframe remount recovery:claim 後 30 秒沒有 first frame 時重掛一次 iframe,不重新 claim、不改 Kit lifecycle。
  • standalone /ui/open viewer 在沒有 parent 注入 token 時,會先 claim primary lease,再呼叫 stage-binding。
  • A1 first-frame report 不再把 viewer lease id 寫進 endpoint_id audit 欄位。
  • loaded_stage_url 依 route schema 保留到 2048 字元,避免長 artifact URL 被截短後 false mismatch。
  • 補 backend / frontend tests 與 E2E evidence screenshot。

Frontend Verification

Item Evidence
Frontend route http://127.0.0.1:5180/ui#a1
Coordinator URL http://127.0.0.1:8005
Main button(s) tested A1 embedded live viewer mount, first frame observed, stage matched; standalone BindingComposer claim → stage-binding → composeStageRequest covered by DOM/internals test
Fixture used Existing review session / conversion runtime artifact; no large IFC or USDC committed
Visible success state A1 live viewer reaches first frame and reports stage matched instead of remaining blank
E2E command npm run test:e2e -- --reporter=list e2e/viewer-embed-a1-highlight.spec.ts
Screenshot / trace docs/evidence/viewer-embed-a1-highlight/firstframe-stage-matched.png
Known gaps Red-highlight full E2E still has existing skip for for-session rule-run lineage on manually created sessions; unmapped row-level highlight remains existing NOT BUILT/fixme

Deploy Path Verification

Item Status
Affects runtime / docker / Kit / viewer / ports / env? Viewer/coordinator behavior changes only; no docker, Kit process lifecycle, port allocation, or env variable changes
Canonical deploy path updated? No deploy script change; canonical test-deploy rebuild remains .\scripts\dev\rebuild-test-deploy.ps1 -Build after merge
Deploy dry-run command Not run; repo contract forbids using -DryRun for test-deploy rebuild
Verify command Manual branch preview started coordinator on 127.0.0.1:8005 and Vite on 127.0.0.1:5180; both returned HTTP 200

Validation

Post-merge sanity after merging latest origin/main into this branch:

  • bim-review-coordinator: npm test -- tests/viewer-leases.test.ts tests/dev-console.test.ts tests/session-first-frame.test.ts tests/sessions.test.ts
    • Result: 4 files passed, 75 tests passed
  • web-viewer-sample: npm run test -- --run src/console/A1ViewerEmbed.test.tsx src/console/EmbeddedViewer.test.tsx
    • Result: 2 files passed, 35 tests passed
  • git diff --check --cached
    • Result: passed before commit

Follow-up verification for review feedback fixes on commit b2fffc9, repeated after merge commit 3932d23:

  • bim-review-coordinator: $env:TEMP=(Resolve-Path ..\.tmp).Path; $env:TMP=$env:TEMP; npm test -- viewer-leases.test.ts
    • Result: 1 file passed, 7 tests passed
  • web-viewer-sample: $env:TEMP=(Resolve-Path ..\.tmp).Path; $env:TMP=$env:TEMP; npm test -- src/console/A1ViewerEmbed.test.tsx src/console/windowParentMessage.dom.test.tsx
    • Result: 2 files passed, 46 tests passed
  • bim-review-coordinator: $env:TEMP=(Resolve-Path ..\.tmp).Path; $env:TMP=$env:TEMP; npm run build
    • Result: passed
  • web-viewer-sample: $env:TEMP=(Resolve-Path ..\.tmp).Path; $env:TMP=$env:TEMP; npm run build:ui
    • Result: passed with existing Vite chunk-size warning
  • git diff --check
    • Result: passed

Earlier full validation before the final origin/main merge, with no overlapping A1 code changes afterward:

  • bim-review-coordinator: npm run verify passed, 49 files / 518 tests
  • web-viewer-sample: npm run verify passed, 32 files / 435 tests + struct-log tests
  • web-viewer-sample: npm run build:ui passed
  • Browser harness E2E passed: viewer-harness.spec.ts + primary-spectator-authority.spec.ts, 3 tests
  • Live A1 E2E first-frame/stage-matched case passed

GitNexus

Initial implementation detect_changes reported risk_level: high, expected because it touched coordinator session routes and A1 viewer runtime flow. Follow-up detect_changes before merging latest origin/main reported risk_level: medium; changed scope is covered by targeted coordinator/frontend tests plus live A1 E2E evidence.

Notes

  • This PR does not manage Kit process lifecycle.
  • This PR does not change GPU fleet allocation.
  • This PR does not push lease tokens through URL query strings.

Add coordinator-managed viewer leases for A1 embedded viewer authority, require lease tokens for stage binding, and pass viewer lease secrets via postMessage instead of URL query.

Also add heartbeat/release handling, one-time viewer remount recovery, targeted backend/frontend tests, and refreshed E2E evidence.
Copilot AI review requested due to automatic review settings July 2, 2026 03:09
@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@monkey1sai, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a8baffc-2949-49ab-ab9d-160f4d497239

📥 Commits

Reviewing files that changed from the base of the PR and between b2fffc9 and 1cb863d.

📒 Files selected for processing (4)
  • bim-review-coordinator/src/app.ts
  • docs/superpowers/specs/2026-07-02-a1-primary-viewer-lease-authority-design.md
  • web-viewer-sample/src/console/coordinatorClient.ts
  • web-viewer-sample/src/console/pages.tsx
📝 Walkthrough

Walkthrough

This PR adds a viewer lease management system to the BIM review coordinator, introducing a ViewerLeaseStore service, new API endpoints for claiming/heartbeating/releasing leases, and lease-token-based authorization for stage-binding. The web viewer sample is updated to claim leases, propagate tokens via postMessage, and gate governance actions on active primary leases.

Changes

Viewer Lease Feature

Layer / File(s) Summary
ViewerLeaseStore contracts and implementation
bim-review-coordinator/src/services/viewerLeaseStore.ts
New in-memory store with lease types, TTL expiration, idempotent claim replay, heartbeat/stage-match handling, release, and primary authorization.
Coordinator API wiring
bim-review-coordinator/src/app.ts
New claim/heartbeat/release/status endpoints, lease-token-gated stage-binding authorization, session-close lease release, and runtime status enrichment with lease data.
Coordinator tests
bim-review-coordinator/tests/viewer-leases.test.ts, bim-review-coordinator/tests/dev-console.test.ts
New viewer-lease integration suite and rewritten stage-binding authorization test using lease tokens.
Coordinator client lease API
web-viewer-sample/src/console/coordinatorClient.ts, web-viewer-sample/src/config/env.ts
New client methods (claimViewerLease, viewerLeaseHeartbeat, releaseViewerLease), lease types, and env fields for source client id/lease token.
EmbeddedViewer lease propagation
web-viewer-sample/src/console/EmbeddedViewer.tsx, EmbeddedViewer.test.tsx
New props and postMessage flow to deliver lease token to iframe without leaking it via URL.
Governance workbench lease lifecycle
web-viewer-sample/src/console/pages.tsx, A1ViewerEmbed.test.tsx
Claims/releases/heartbeats primary lease per session, recovers viewer on timeout, and gates 3D highlighting on an active lease.
Standalone window lease acquisition
web-viewer-sample/src/Window.tsx, windowParentMessage.dom.test.tsx
Standalone viewer claims a primary lease before applying bindings and authorizes stage-binding requests with the lease token.

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

Possibly related PRs

  • monkey1sai/AI-BIM-governance#184: Both PRs modify the coordinator's POST /api/review-sessions/:sessionId/stage-binding authorization path, with this PR replacing prior primary-client authority with lease-token-based authorization.

Sequence Diagram(s)

sequenceDiagram
  participant WebViewer
  participant CoordinatorApp
  participant ViewerLeaseStore

  WebViewer->>CoordinatorApp: POST /viewer-leases/claim (requested_role: primary)
  CoordinatorApp->>ViewerLeaseStore: claim(input)
  ViewerLeaseStore-->>CoordinatorApp: lease_id, lease_token
  CoordinatorApp-->>WebViewer: lease response

  WebViewer->>CoordinatorApp: POST /stage-binding (X-Viewer-Lease-Token)
  CoordinatorApp->>ViewerLeaseStore: authorizePrimary(sessionId, leaseId, token)
  ViewerLeaseStore-->>CoordinatorApp: authorized lease or null
  CoordinatorApp-->>WebViewer: binding result / 403

  WebViewer->>CoordinatorApp: POST /viewer-leases/:leaseId/heartbeat
  CoordinatorApp->>ViewerLeaseStore: heartbeat(sessionId, leaseId, token, input)
  ViewerLeaseStore-->>CoordinatorApp: updated lease
  CoordinatorApp-->>WebViewer: heartbeat ack

  WebViewer->>CoordinatorApp: POST /viewer-leases/:leaseId/release
  CoordinatorApp->>ViewerLeaseStore: release(sessionId, leaseId, token)
  ViewerLeaseStore-->>CoordinatorApp: released lease
  CoordinatorApp-->>WebViewer: release ack
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: A1 now automatically acquires a primary viewer lease.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/a1-auto-primary

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 makes the A1 governance workbench obtain a coordinator-managed primary viewer lease before embedding the live viewer, replacing the previous "any client can self-declare primary" model. A new in-memory ViewerLeaseStore issues bearer lease tokens with a 45s TTL / 15s heartbeat, and stage-binding is now gated on presenting a valid primary lease token (the legacy no-token bypass is removed). The lease token is handed to the iframe viewer via origin-restricted postMessage rather than the URL query string, so it does not leak through history/referrer/logs. The A1 page only mounts EmbeddedViewer after a successful primary claim, heartbeats on first-frame/stage-loaded, releases on session-switch/unmount, and performs a one-time iframe remount if no first frame arrives within 30s.

Changes:

  • New coordinator ViewerLeaseStore plus claim/heartbeat/release/status review-session routes, and runtime/status now surfaces primary_viewer_lease_id / viewer_leases.
  • Stage-binding now requires X-Viewer-Lease-Token authorized against an active primary lease; token is delivered to the viewer over postMessage, not URL query.
  • A1 page (pages.tsx) claims a primary lease, gates viewer mount + highlight on it, heartbeats, releases, and adds one-time remount recovery; backend/frontend tests + E2E evidence added.

Reviewed changes

Copilot reviewed 11 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
bim-review-coordinator/src/services/viewerLeaseStore.ts New lease store: claim/heartbeat/release/expire, token issuance, binding selection
bim-review-coordinator/src/app.ts Lease routes, stage-binding token gate, session-close release, runtime-status lease exposure
web-viewer-sample/src/console/coordinatorClient.ts New lease client methods + lease types; header-bearing POST helper
web-viewer-sample/src/console/pages.tsx A1 claim/heartbeat/release effects, mount gating, remount recovery, highlight gating
web-viewer-sample/src/console/EmbeddedViewer.tsx New identity/token props; token via postMessage, not URL
web-viewer-sample/src/Window.tsx Accept lease token via postMessage; send it in stage-binding header
web-viewer-sample/src/config/env.ts Add sourceClientId resolution; viewerLeaseToken not read from query
bim-review-coordinator/tests/viewer-leases.test.ts New backend coverage for lease lifecycle + stage-binding auth
bim-review-coordinator/tests/dev-console.test.ts Update CH-C to lease-token stage-binding authority
web-viewer-sample/src/console/A1ViewerEmbed.test.tsx Frontend coverage for claim/release/remount/heartbeat
web-viewer-sample/src/console/EmbeddedViewer.test.tsx Coverage for token-via-postMessage, no token in URL

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

Comment thread web-viewer-sample/src/Window.tsx Outdated
Comment on lines +983 to +987
headers: {
"Content-Type": "application/json",
...(reviewEnv.viewerLeaseToken ? { "X-Viewer-Lease-Token": reviewEnv.viewerLeaseToken } : {}),
},
body: JSON.stringify({ source_client_id: reviewEnv.sourceClientId, role: "primary", binding_revision_id: revisionId, primary_artifact_id: primary.artifact_id }),
Comment thread web-viewer-sample/src/console/pages.tsx Outdated
loaded_stage_url: m.stageUrl,
datachannel_ready: true,
}).catch(() => {});
void coordinatorClient.reportFirstFrame(selectedSession, activePrimaryLease.lease_id).catch(() => {});
Comment on lines +158 to +160
if (typeof input.loaded_stage_url === "string" || input.loaded_stage_url === null) {
lease.loaded_stage_url = cleanOptionalString(input.loaded_stage_url);
}

@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: b1c8367909

ℹ️ 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".

Comment on lines +408 to +410
.catch((e) => {
if (alive) setViewerLeaseErr(String(e));
})

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 Retry primary lease claims after conflicts

When the first claim gets primary_already_claimed because another A1 tab still owns the primary lease or closed without sending release, this catch only stores the error. The effect depends only on selectedSession/viewerOrigin, so it never issues another lease operation; because stale leases are expired only when a lease API is touched, the A1 viewer stays unmounted until the operator reloads or switches sessions even after the 45s TTL. Please add a retry/status poll or an explicit reclaim path for this transient conflict.

Useful? React with 👍 / 👎.

Comment on lines +432 to +435
void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(loadedStageUrl ? { loaded_stage_url: loadedStageUrl } : {}),
datachannel_ready: firstFrame,
}).catch(() => {});

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 Reclaim the lease when heartbeat fails

If the browser tab is suspended, the network drops heartbeats past the lease TTL, or the coordinator restarts, the next heartbeat returns 404/invalid token, but this handler swallows the failure and leaves activePrimaryLease set locally. The iframe then continues using an expired token, so primary-gated operations such as stage binding keep failing with 403 until a full reload/session switch. Please clear the stale lease and re-claim (or surface a retry state) on heartbeat failure.

Useful? React with 👍 / 👎.

Comment on lines +1387 to +1391
const authorizedLease = viewerLeaseStore.authorizePrimary(session.session_id, sourceClientId, leaseToken);
if (!authorizedLease) {
response.status(403).json({ detail: "stage binding requires an active primary viewer lease" });
return;
}

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 binding authority when primary leases rotate

When a primary lease has already applied a binding and then releases or expires, a later primary can successfully pass authorizePrimary here, but the per-session stageBindingAuthority map still contains the previous lease id and the following registry check returns another client holds primary authority. This breaks normal primary handoff after any binding has been applied; clear or update the authority when the lease is released/expired or when a new active primary is authorized.

Useful? React with 👍 / 👎.

Comment thread web-viewer-sample/src/Window.tsx Outdated
Comment on lines +983 to +986
headers: {
"Content-Type": "application/json",
...(reviewEnv.viewerLeaseToken ? { "X-Viewer-Lease-Token": reviewEnv.viewerLeaseToken } : {}),
},

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 Acquire a lease before standalone viewer binding

For the existing standalone /ui/open viewer path, there is no parent postMessage handoff and no code path in Window.tsx that claims a viewer lease, so reviewEnv.viewerLeaseToken remains empty. Because this fetch only sends X-Viewer-Lease-Token when that value was injected by the A1 iframe parent, every non-harness BindingComposer apply from the standalone primary viewer now hits the backend's new 403 token requirement.

Useful? React with 👍 / 👎.

status: lease.status,
kit_instance_id: lease.kit_instance_id,
stream_config: lease.stream_config,
client_nonce: lease.client_nonce,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not expose replay nonces in public lease views

This field is returned by public lease views such as runtime status and the 409 primary_lease response, but claim() treats viewer_id + client_nonce as enough to replay an existing lease and the claim route then returns lease_token. Any caller who can read or trigger the public lease response can copy these fields and re-claim the active primary token, bypassing the new token gate for stage-binding.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status blocked
Risk high
PR 273
Head feat/a1-auto-primary / 3932d239ac53ecd5dda9ee2d04619023ba77b0db
Base main / d9db1026a210294e5571802d147a299205bffdd9

Blockers

  • [high] Behavior, workflow, code, or repo-boundary changes require an OpenSpec change id or documented exception.

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

  • Optional AI adapter is not required by policy and was skipped.

@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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bim-review-coordinator/src/app.ts (1)

1392-1396: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Let the current active lease replace stale stage-binding authority.

After the first primary lease applies a binding, stageBindingAuthority keeps that lease id forever. If that lease expires/releases and a new primary lease is claimed, authorizePrimary succeeds for the new lease but this legacy first-wins check still rejects it.

🐛 Proposed fix: key binding authority to the authorized active lease
-    const reg = stageBindingAuthority.get(session.session_id);
-    if (reg && reg.primaryClientId !== sourceClientId) {
-      response.status(403).json({ detail: "another client holds primary authority for this session", primary_client_id: reg.primaryClientId });
-      return;
-    }
+    const reg = stageBindingAuthority.get(session.session_id);
@@
-    const current = reg ?? { primaryClientId: sourceClientId, revisions: [] };
+    const current =
+      reg && reg.primaryClientId === authorizedLease.lease_id
+        ? reg
+        : { primaryClientId: authorizedLease.lease_id, revisions: reg?.revisions ?? [] };
🤖 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/app.ts` around lines 1392 - 1396, The
primary-authority check in authorizePrimary is still using the stale first-wins
entry from stageBindingAuthority, so a newly claimed active lease can be
rejected after the original lease expires or releases. Update this logic to
validate against the currently authorized active lease for the session instead
of the legacy stored primaryClientId, and make sure the binding authority is
refreshed or replaced when a new primary lease is successfully claimed. Use the
authorizePrimary flow and stageBindingAuthority lookup to locate the check.
🧹 Nitpick comments (3)
bim-review-coordinator/src/app.ts (1)

1219-1226: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid appending every lease heartbeat to the event log.

With heartbeat_after_ms at 15s, this creates unbounded append-only audit growth per active viewer. Persist only state transitions or changed observations, e.g. first frame, loaded stage URL change, datachannel readiness change, or stage-match change.

🤖 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/app.ts` around lines 1219 - 1226, The
viewerLeaseHeartbeat event logging is appending every heartbeat and causing
unbounded audit growth. Update the logic around the eventLog.append call in
viewer lease heartbeat handling to persist only meaningful state transitions or
changed observations, such as first_frame, loaded_stage_url, datachannel_ready,
or stage_match changes. Use the existing lease/session heartbeat processing path
in app.ts to compare against prior values before appending, and skip logging
when nothing has changed.
web-viewer-sample/src/console/A1ViewerEmbed.test.tsx (1)

15-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid coverage of the lease lifecycle; consider adding a heartbeat-failure test.

The claim/release/no-op/remount/heartbeat-on-first-frame tests are thorough. One gap: no test verifies behavior when coordinatorClient.viewerLeaseHeartbeat rejects — worth adding once error surfacing is implemented (see pages.tsx heartbeat-swallowing comment) to guard the fix.

Also applies to: 73-104, 117-126, 161-215, 234-263, 279-283

🤖 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/A1ViewerEmbed.test.tsx` around lines 15 - 18,
Add a test that covers heartbeat failure in the lease lifecycle, specifically
when coordinatorClient.viewerLeaseHeartbeat rejects. Extend the existing
A1ViewerEmbed.test.tsx coverage around the lease/heartbeat flow and assert the
expected surfaced behavior once pages.tsx stops swallowing heartbeat errors. Use
the EmbeddedViewer mock, coordinatorClient.viewerLeaseHeartbeat, and the
existing claim/release/remount/heartbeat-on-first-frame patterns to locate the
right test area.
web-viewer-sample/src/console/windowParentMessage.dom.test.tsx (1)

286-350: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a negative-path test for lease-claim/stage-binding failure.

This test only covers the happy path. _ensurePrimaryViewerLease/applyThroughCoordinator also have explicit failure branches (no lease token → "尚未取得 primary viewer lease"; non-OK stage-binding response → "coordinator 後端權威拒絕") that aren't exercised here.

🤖 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/windowParentMessage.dom.test.tsx` around lines
286 - 350, Add a negative-path test around operableApp(), _applyBinding(), and
the lease flow to cover failure handling that is currently untested.
Specifically, verify the branch in _ensurePrimaryViewerLease when no lease token
is available and the branch in applyThroughCoordinator when the stage-binding
fetch returns a non-OK response. Mock fetch so one scenario omits the viewer
lease token and another returns a failing stage-binding response, then assert
the expected error messages are surfaced and composeStageRequest is not sent.
🤖 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 `@bim-review-coordinator/src/services/viewerLeaseStore.ts`:
- Around line 93-96: The replay nonce is being exposed through public lease
projections, which allows callers to reuse it and replay claim flows. Update the
viewerLeaseStore flow around findReplay and publicLease so client_nonce is never
included in any status/conflict/runtime/public lease payloads, while keeping it
only in the internal path used to detect replays and return lease_token on an
actual replay. Review the claim/replay handling and all public projection
builders together to ensure viewer_id/client_nonce are stripped from externally
visible lease objects.

In `@web-viewer-sample/src/console/coordinatorClient.ts`:
- Around line 53-64: jsonPostWithHeaders currently performs lease-critical fetch
calls without any timeout or abort handling, so a hung coordinator can block
claimViewerLease, viewerLeaseHeartbeat, and releaseViewerLease indefinitely.
Update jsonPostWithHeaders to accept/use an AbortController-based timeout,
propagate it through the coordinator lease helpers, and ensure the request fails
fast with a clear timeout error instead of leaving A1GovernanceWorkbenchPage
waiting forever for activePrimaryLease.

In `@web-viewer-sample/src/console/pages.tsx`:
- Around line 428-445: The heartbeat calls are swallowing repeated failures
without any operator-visible signal, so update the viewer lease heartbeat flow
to track consecutive `viewerLeaseHeartbeat` errors instead of using empty
catches. In the `useEffect` interval logic and the related
`onFirstFrame`/`onStageLoaded` handlers, use the existing `viewerLeaseErr` state
or a similar warning path to surface a message once failures cross a threshold,
and reset the counter on success so operators know when to reselect or retry.
- Around line 758-767: The primary viewer lease failure state currently only
shows viewerLeaseErr and gives no in-page retry path, so add an explicit retry
action in the A1 viewer embed flow. Update the conditional UI around
activePrimaryLease/viewerLeaseErr in pages.tsx to render a retry button
alongside the failure message, and wire it to a handler that re-attempts the
lease claim without requiring selectedSession or viewerOrigin to change. Ensure
the retry path is observable with a stable test ID and covers loading, failure,
and success states in the A1ViewerEmbed test coverage.
- Around line 797-803: The `onStageLoaded` handler in `pages.tsx` hardcodes
`datachannel_ready: true`, which should instead reflect the real `firstFrame`
state used by the heartbeat logic. Update the `viewerLeaseHeartbeat` call inside
`onStageLoaded` to send the same readiness value tracked elsewhere in this
component, and keep the existing `loaded_stage_url` behavior tied to `u` so
`selectedSession`, `activePrimaryLease`, and
`coordinatorClient.viewerLeaseHeartbeat` stay consistent.

In `@web-viewer-sample/src/Window.tsx`:
- Around line 861-929: The standalone lease-claim request in
_ensurePrimaryViewerLease() has no timeout, so a hung fetch can block the flow
indefinitely. Update the fetch to /viewer-leases/claim to use an AbortController
with the same timeout pattern used in coordinatorClient.ts/jsonPostWithHeaders,
and make sure the controller is cleaned up in the finally path. Keep the
existing _ensurePrimaryViewerLease(), standaloneViewerLeaseClaim, and response
handling behavior intact while adding abort/timeout handling.

---

Outside diff comments:
In `@bim-review-coordinator/src/app.ts`:
- Around line 1392-1396: The primary-authority check in authorizePrimary is
still using the stale first-wins entry from stageBindingAuthority, so a newly
claimed active lease can be rejected after the original lease expires or
releases. Update this logic to validate against the currently authorized active
lease for the session instead of the legacy stored primaryClientId, and make
sure the binding authority is refreshed or replaced when a new primary lease is
successfully claimed. Use the authorizePrimary flow and stageBindingAuthority
lookup to locate the check.

---

Nitpick comments:
In `@bim-review-coordinator/src/app.ts`:
- Around line 1219-1226: The viewerLeaseHeartbeat event logging is appending
every heartbeat and causing unbounded audit growth. Update the logic around the
eventLog.append call in viewer lease heartbeat handling to persist only
meaningful state transitions or changed observations, such as first_frame,
loaded_stage_url, datachannel_ready, or stage_match changes. Use the existing
lease/session heartbeat processing path in app.ts to compare against prior
values before appending, and skip logging when nothing has changed.

In `@web-viewer-sample/src/console/A1ViewerEmbed.test.tsx`:
- Around line 15-18: Add a test that covers heartbeat failure in the lease
lifecycle, specifically when coordinatorClient.viewerLeaseHeartbeat rejects.
Extend the existing A1ViewerEmbed.test.tsx coverage around the lease/heartbeat
flow and assert the expected surfaced behavior once pages.tsx stops swallowing
heartbeat errors. Use the EmbeddedViewer mock,
coordinatorClient.viewerLeaseHeartbeat, and the existing
claim/release/remount/heartbeat-on-first-frame patterns to locate the right test
area.

In `@web-viewer-sample/src/console/windowParentMessage.dom.test.tsx`:
- Around line 286-350: Add a negative-path test around operableApp(),
_applyBinding(), and the lease flow to cover failure handling that is currently
untested. Specifically, verify the branch in _ensurePrimaryViewerLease when no
lease token is available and the branch in applyThroughCoordinator when the
stage-binding fetch returns a non-OK response. Mock fetch so one scenario omits
the viewer lease token and another returns a failing stage-binding response,
then assert the expected error messages are surfaced and composeStageRequest is
not sent.
🪄 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: ee8bf363-b016-4990-86bc-4de724cce65b

📥 Commits

Reviewing files that changed from the base of the PR and between df85d8b and b2fffc9.

⛔ Files ignored due to path filters (3)
  • artifacts/e2e/primary-spectator-authority.png is excluded by !**/*.png
  • artifacts/e2e/viewer-harness-boot.png is excluded by !**/*.png
  • docs/evidence/viewer-embed-a1-highlight/firstframe-stage-matched.png is excluded by !**/*.png
📒 Files selected for processing (12)
  • bim-review-coordinator/src/app.ts
  • bim-review-coordinator/src/services/viewerLeaseStore.ts
  • bim-review-coordinator/tests/dev-console.test.ts
  • bim-review-coordinator/tests/viewer-leases.test.ts
  • web-viewer-sample/src/Window.tsx
  • web-viewer-sample/src/config/env.ts
  • web-viewer-sample/src/console/A1ViewerEmbed.test.tsx
  • web-viewer-sample/src/console/EmbeddedViewer.test.tsx
  • web-viewer-sample/src/console/EmbeddedViewer.tsx
  • web-viewer-sample/src/console/coordinatorClient.ts
  • web-viewer-sample/src/console/pages.tsx
  • web-viewer-sample/src/console/windowParentMessage.dom.test.tsx

Comment on lines +93 to +96
const existingReplay = this.findReplay(input);
if (existingReplay) {
return { ok: true, lease: existingReplay, idempotent_replay: true };
}

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.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

CRITICAL: Keep replay nonces out of public lease projections.

client_nonce is used by findReplay to recover an existing lease, and the claim route returns lease_token on replay. Because publicLease exposes viewer_id and client_nonce via status/conflict/runtime projections, any caller can read them, replay the claim, and obtain primary stage-binding authority.

🔒 Proposed fix: do not expose replay nonce in public lease payloads
 export interface PublicViewerLease {
   lease_id: string;
   session_id: string;
   viewer_id: string;
   user_id: string;
   display_name: string | null;
   role: ViewerLeaseRole;
   status: ViewerLeaseStatus;
   kit_instance_id: string | null;
   stream_config: KitInstanceBinding["stream_config"] | null;
-  client_nonce: string | null;
   claimed_at: string;
   expires_at: string;
   last_heartbeat_at: string | null;
@@
     status: lease.status,
     kit_instance_id: lease.kit_instance_id,
     stream_config: lease.stream_config,
-    client_nonce: lease.client_nonce,
     claimed_at: lease.claimed_at,

Also applies to: 233-243, 258-281

🤖 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 93 -
96, The replay nonce is being exposed through public lease projections, which
allows callers to reuse it and replay claim flows. Update the viewerLeaseStore
flow around findReplay and publicLease so client_nonce is never included in any
status/conflict/runtime/public lease payloads, while keeping it only in the
internal path used to detect replays and return lease_token on an actual replay.
Review the claim/replay handling and all public projection builders together to
ensure viewer_id/client_nonce are stripped from externally visible lease
objects.

Comment on lines +53 to +64
async function jsonPostWithHeaders<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
const res = await fetch(`${COORD_BASE}${path}`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", ...headers },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`coordinator ${path} -> ${res.status} ${await errorDetail(res)}`);
}
return res.json() as Promise<T>;
}

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 | 🟠 Major | ⚡ Quick win

No timeout/abort on lease-critical network calls.

fetch here has no AbortController/timeout. This helper now backs claimViewerLease/viewerLeaseHeartbeat/releaseViewerLease, which gate whether the 3D viewer ever mounts (A1GovernanceWorkbenchPage only renders EmbeddedViewer once activePrimaryLease resolves). If the coordinator hangs, the claim promise never settles and the UI stays stuck on "claiming primary viewer lease…" indefinitely with no escape hatch.

🛡️ Suggested fix: bound requests with a timeout
-async function jsonPostWithHeaders<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
-  const res = await fetch(`${COORD_BASE}${path}`, {
-    method: "POST",
-    headers: { Accept: "application/json", "Content-Type": "application/json", ...headers },
-    body: JSON.stringify(body ?? {}),
-  });
+async function jsonPostWithHeaders<T>(path: string, body: unknown, headers: Record<string, string>, timeoutMs = 8000): Promise<T> {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  let res: Response;
+  try {
+    res = await fetch(`${COORD_BASE}${path}`, {
+      method: "POST",
+      headers: { Accept: "application/json", "Content-Type": "application/json", ...headers },
+      body: JSON.stringify(body ?? {}),
+      signal: controller.signal,
+    });
+  } finally {
+    clearTimeout(timer);
+  }

Also applies to: 423-451

🤖 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/coordinatorClient.ts` around lines 53 - 64,
jsonPostWithHeaders currently performs lease-critical fetch calls without any
timeout or abort handling, so a hung coordinator can block claimViewerLease,
viewerLeaseHeartbeat, and releaseViewerLease indefinitely. Update
jsonPostWithHeaders to accept/use an AbortController-based timeout, propagate it
through the coordinator lease helpers, and ensure the request fails fast with a
clear timeout error instead of leaving A1GovernanceWorkbenchPage waiting forever
for activePrimaryLease.

Comment on lines +428 to +445
useEffect(() => {
if (!selectedSession || !activePrimaryLease) return;
const heartbeatMs = Math.max(5000, activePrimaryLease.heartbeat_after_ms || 15000);
const timer = window.setInterval(() => {
void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(loadedStageUrl ? { loaded_stage_url: loadedStageUrl } : {}),
datachannel_ready: firstFrame,
}).catch(() => {});
}, heartbeatMs);
return () => window.clearInterval(timer);
}, [
selectedSession,
activePrimaryLease?.lease_id,
activePrimaryLease?.lease_token,
activePrimaryLease?.heartbeat_after_ms,
firstFrame,
loadedStageUrl,
]);

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 | 🟠 Major | ⚡ Quick win

Heartbeat failures are silently swallowed — no user-facing signal or retry.

viewerLeaseHeartbeat(...).catch(() => {}) appears in the interval effect and both onFirstFrame/onStageLoaded handlers. If heartbeats fail repeatedly (network blip, coordinator restart), the primary lease can expire server-side with zero indication to the operator — 3D highlight/binding-apply will then start failing "for no visible reason," undermining the exclusivity guarantee this PR is meant to establish.

Consider tracking consecutive heartbeat failures and surfacing a warning (e.g., reuse viewerLeaseErr) once a threshold is hit, so operators know to reselect the session / retry.

Also applies to: 783-803

🤖 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 428 - 445, The
heartbeat calls are swallowing repeated failures without any operator-visible
signal, so update the viewer lease heartbeat flow to track consecutive
`viewerLeaseHeartbeat` errors instead of using empty catches. In the `useEffect`
interval logic and the related `onFirstFrame`/`onStageLoaded` handlers, use the
existing `viewerLeaseErr` state or a similar warning path to surface a message
once failures cross a threshold, and reset the counter on success so operators
know when to reselect or retry.

Comment on lines +758 to +767
<div data-testid="a1-primary-lease"><Field k="primary lease" v={activePrimaryLease ? activePrimaryLease.lease_id : viewerLeaseBusy ? t("claiming…", "claiming…") : t("not_observed(尚未取得 primary)", "not_observed (primary not claimed yet)")} prov={activePrimaryLease ? "asbuilt" : "p1"} /></div>
</div>
{viewerLeaseErr && <p className="ec-warn-note" data-testid="a1-viewer-lease-error">{t("primary viewer lease 取得失敗:", "Failed to claim primary viewer lease: ")}{viewerLeaseErr}</p>}
{/* S3:iframe 內 viewer 自帶 GovernanceOverlay 失敗清單(會與 console 左側清單重複,造成「console 25 筆 / iframe 說無失敗」矛盾 UX)。
解法分兩端:(a) viewer 端在嵌入模式把 overlay 的 failedElements 餵空使清單收合(Task 2「S3 收合」step,已落地);
(b) console 端此處只把 iframe 當高亮引擎,唯一權威失敗清單 = 左側 state.failed 記分板,iframe 不另顯第二份清單。 */}
{viewerOrigin === null ? (
<p className="ec-warn-note" data-testid="a1-viewer-origin-missing">{t("viewer 入口未取得(runtime/status 無 configured_endpoints.viewer.browser_url_base 或 coordinator 連不上),3D 暫不可用", "viewer entry not available (runtime/status has no configured_endpoints.viewer.browser_url_base, or coordinator unreachable); 3D temporarily unavailable")}</p>
) : !activePrimaryLease ? (
<p className="ec-warn-note" data-testid="a1-viewer-primary-missing">{viewerLeaseBusy ? t("正在向 coordinator 取得 primary viewer lease…", "Claiming primary viewer lease from coordinator…") : t("尚未取得 primary viewer lease,3D viewer 暫不載入", "Primary viewer lease not available yet; 3D viewer is not mounted")}</p>

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 | 🟠 Major | ⚡ Quick win

No retry affordance when primary viewer lease claim fails.

viewerLeaseErr is displayed but there's no explicit "retry" action; the claim effect only re-fires on selectedSession/viewerOrigin change, and reselecting the same session is intentionally a no-op (per A1ViewerEmbed.test.tsx "重新選同一個 session 不會 release primary lease"). An operator hitting a transient claim failure has no in-page way to retry other than switching away and back to the session.

As per path instructions, "User-facing features must include route, explicit button, default fixture, loading/success/failure/retry states, observable ID, and Playwright/Chrome E2E evidence."

🤖 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 758 - 767, The primary
viewer lease failure state currently only shows viewerLeaseErr and gives no
in-page retry path, so add an explicit retry action in the A1 viewer embed flow.
Update the conditional UI around activePrimaryLease/viewerLeaseErr in pages.tsx
to render a retry button alongside the failure message, and wire it to a handler
that re-attempts the lease claim without requiring selectedSession or
viewerOrigin to change. Ensure the retry path is observable with a stable test
ID and covers loading, failure, and success states in the A1ViewerEmbed test
coverage.

Source: Path instructions

Comment on lines +797 to +803
onStageLoaded={(u) => {
if (u) setLoadedStageUrl(u);
void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(u ? { loaded_stage_url: u } : {}),
datachannel_ready: true,
}).catch(() => {});
}}

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 | 🟡 Minor | ⚡ Quick win

onStageLoaded hardcodes datachannel_ready: true instead of using real firstFrame state.

The interval heartbeat effect at Line 434 correctly reports datachannel_ready: firstFrame, but this handler always sends true regardless of actual state. It currently "works" only because stage_loaded is documented to always follow first_frame in the viewer's emission order — but that's an implicit cross-component assumption, not something enforced here. If it's ever violated, this writes fabricated readiness evidence into the persisted lease record.

💚 Proposed fix
                   onStageLoaded={(u) => {
                     if (u) setLoadedStageUrl(u);
                     void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
                       ...(u ? { loaded_stage_url: u } : {}),
-                      datachannel_ready: true,
+                      datachannel_ready: firstFrame,
                     }).catch(() => {});
                   }}
📝 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
onStageLoaded={(u) => {
if (u) setLoadedStageUrl(u);
void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(u ? { loaded_stage_url: u } : {}),
datachannel_ready: true,
}).catch(() => {});
}}
onStageLoaded={(u) => {
if (u) setLoadedStageUrl(u);
void coordinatorClient.viewerLeaseHeartbeat(selectedSession, activePrimaryLease.lease_id, activePrimaryLease.lease_token, {
...(u ? { loaded_stage_url: u } : {}),
datachannel_ready: firstFrame,
}).catch(() => {});
}}
🤖 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 797 - 803, The
`onStageLoaded` handler in `pages.tsx` hardcodes `datachannel_ready: true`,
which should instead reflect the real `firstFrame` state used by the heartbeat
logic. Update the `viewerLeaseHeartbeat` call inside `onStageLoaded` to send the
same readiness value tracked elsewhere in this component, and keep the existing
`loaded_stage_url` behavior tied to `u` so `selectedSession`,
`activePrimaryLease`, and `coordinatorClient.viewerLeaseHeartbeat` stay
consistent.

Comment on lines +861 to +929
private async _ensurePrimaryViewerLease(): Promise<string | null> {
if (reviewEnv.viewerLeaseToken) return reviewEnv.viewerLeaseToken;
if (this.standaloneViewerLease?.lease_token) return this.standaloneViewerLease.lease_token;

const sessionId = this.state.reviewSessionId;
if (!sessionId || window.parent !== window) return null;

if (!this.standaloneViewerLeaseClaim) {
this.standaloneViewerLeaseClaim = fetch(`${reviewEnv.coordinatorApiBase}/api/review-sessions/${encodeURIComponent(sessionId)}/viewer-leases/claim`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
viewer_id: reviewEnv.sourceClientId,
user_id: reviewEnv.defaultUserId,
display_name: reviewEnv.defaultDisplayName,
requested_role: "primary",
client_nonce: `standalone:${reviewEnv.sourceClientId}:${sessionId}`,
preferred_kit_instance_id: this.state.activeStreamEndpoint.kitInstanceId,
}),
})
.then(async (response) => {
if (!response.ok) {
this._appendReviewEvent(`primary viewer lease 取得失敗(${response.status})`);
return null;
}
const lease = await response.json() as StandaloneViewerLease;
if (lease.role !== "primary" || !lease.lease_id || !lease.lease_token) {
this._appendReviewEvent(`primary viewer lease 不是 primary(role=${lease.role})`);
return null;
}
this.standaloneViewerLease = lease;
reviewEnv.viewerLeaseToken = lease.lease_token;
reviewEnv.sourceClientId = lease.lease_id;
this._appendReviewEvent(`已取得 primary viewer lease:${lease.lease_id}`);
return lease;
})
.catch((error) => {
this._appendReviewEvent(`primary viewer lease 取得失敗:${error instanceof Error ? error.message : String(error)}`);
return null;
})
.finally(() => {
this.standaloneViewerLeaseClaim = null;
});
}

const lease = await this.standaloneViewerLeaseClaim;
return lease?.lease_token ?? null;
}

private _releaseStandaloneViewerLease(): void {
const lease = this.standaloneViewerLease;
const sessionId = this.state.reviewSessionId;
if (!lease || !sessionId) return;

this.standaloneViewerLease = null;
if (reviewEnv.viewerLeaseToken === lease.lease_token) {
reviewEnv.viewerLeaseToken = "";
}
void fetch(`${reviewEnv.coordinatorApiBase}/api/review-sessions/${encodeURIComponent(sessionId)}/viewer-leases/${encodeURIComponent(lease.lease_id)}/release`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Viewer-Lease-Token": lease.lease_token,
},
body: "{}",
keepalive: true,
}).catch(() => {});
}

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 | 🟠 Major | ⚡ Quick win

No timeout on the standalone lease-claim fetch.

Same concern as coordinatorClient.ts's jsonPostWithHeaders: this fetch to /viewer-leases/claim has no AbortController/timeout. Since _applyBinding's coordinator path awaits this before applying any stage binding, a hung request leaves govBindingApplyState stuck at "applying" with no way for the operator to recover other than reloading.

🤖 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/Window.tsx` around lines 861 - 929, The standalone
lease-claim request in _ensurePrimaryViewerLease() has no timeout, so a hung
fetch can block the flow indefinitely. Update the fetch to /viewer-leases/claim
to use an AbortController with the same timeout pattern used in
coordinatorClient.ts/jsonPostWithHeaders, and make sure the controller is
cleaned up in the finally path. Keep the existing _ensurePrimaryViewerLease(),
standaloneViewerLeaseClaim, and response handling behavior intact while adding
abort/timeout handling.

@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: 3932d239ac

ℹ️ 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".

Comment on lines +862 to +863
if (reviewEnv.viewerLeaseToken) return reviewEnv.viewerLeaseToken;
if (this.standaloneViewerLease?.lease_token) return this.standaloneViewerLease.lease_token;

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 Refresh standalone leases before reusing cached tokens

In the standalone /ui/open viewer path, the newly claimed primary lease is never heartbeated, so it expires after the coordinator's 45s TTL. Any later binding attempt still returns this cached token here and sends it to /stage-binding, which then 403s until the page is reloaded because the code never clears or reclaims the expired lease; please heartbeat the standalone lease or retry a fresh claim when the cached token is stale/rejected.

Useful? React with 👍 / 👎.

response.status(404).json({ detail: "Viewer lease not found or token invalid." });
return;
}
eventLog.append(session.session_id, "viewerLeaseHeartbeat", {

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 Do not append every heartbeat to the audit log

For any embedded A1 viewer left open, the console posts lease heartbeats every ~15s, and this line appends each pulse to the per-session EventLog (and its structured lifecycle mirror). Since /api/review-sessions/:sessionId/events reads the whole JSONL synchronously, long review sessions or several viewers will turn routine liveness traffic into thousands of audit records and increasingly slow event reads; keep heartbeat state in the lease store and only append meaningful transitions such as claim/release/first-frame changes.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status passed
Risk low
PR 273
Head feat/a1-auto-primary / 1cb863d82d6f86658b7050657864c10cb7e5159d
Base main / d9db1026a210294e5571802d147a299205bffdd9

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.

@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: 1cb863d82d

ℹ️ 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".

Comment on lines +405 to +407
.then((lease) => {
if (alive) setViewerLease(lease);
})

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 leases claimed after effect cancellation

If the operator switches sessions or leaves the A1 page while the claim request is still in flight, the cleanup only flips alive to false; a later successful response is then ignored here and never stored, so the release effect below never runs for that lease. That leaves an active primary lease on the old session until it lazily expires, blocking other primary claims during the TTL window; release the returned lease when alive is already false.

Useful? React with 👍 / 👎.

Comment on lines +873 to +877
viewer_id: reviewEnv.sourceClientId,
user_id: reviewEnv.defaultUserId,
display_name: reviewEnv.defaultDisplayName,
requested_role: "primary",
client_nonce: `standalone:${reviewEnv.sourceClientId}:${sessionId}`,

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 per-tab nonce for standalone leases

When two standalone /ui/open tabs use the same session and user (the default dev_user_001 path does this), this deterministic viewer_id/client_nonce pair makes the second tab hit the store's idempotent replay path and receive the first tab's primary lease token instead of conflicting or getting its own identity. Both tabs can then act as the same primary, and closing either tab releases the shared lease out from under the other; generate a per-tab viewer id/nonce for standalone claims.

Useful? React with 👍 / 👎.

@monkey1sai
monkey1sai merged commit 1f97127 into main Jul 2, 2026
12 checks passed
@monkey1sai
monkey1sai deleted the feat/a1-auto-primary branch July 2, 2026 03:58
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