Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
- 日期:2026-07-06
- 狀態:設計已由使用者確認(brainstorming 三決策:主從雙欄工作台/全域視圖去重化/命名「模型資料與轉檔」);已通過交叉對抗審批(2026-07-06,5 視角×2-lens 反駁、39 agents——CONFIRMED 5 項全數修正、PLAUSIBLE 9 項採納 9、REFUTED 3 項不採納,詳見 git 歷史第二次 commit)
- 範圍:`web-viewer-sample` console 前端;後端 API 一律不改(後端凍結契約:只打 coordinator `:8004`)
- PR evidence:documented exception for PR #303。Repo 自 #189 退役 active OpenSpec flow;本檔作為 `docs/superpowers/specs/*.md` formal spec evidence,供 `pr-review-agent` 對 behavior / code PR 判定需求來源。

## 0. PR #303 A1 addendum

PR #303 除 MD 三頁合一外,包含 A1 MinIO / local_fs 檢核入口修正,範圍限於前端 orchestration 與測試:

- `local_fs` 代表 governance-service 可直接讀取的 server-local IFC path;A1 可直接呼叫 governance rule-run。
- `MinIO` 代表 object key / bucket provenance,不可直接當成 `ifc_source_path` 丟給 governance-service;若使用者已選 review session,A1 SHALL 透過 coordinator `rule-runs/for-session` 由 session / intake context 解析 server-local IFC path。
- A1 檢核完成後,失敗列 SHALL 提供一鍵開啟 `#review` 的 Review Room handoff,攜帶 `rule_run_id`、`ifc_guid`、`usd_prim_path` 等非機密上下文;不得只因尚未手動選 review session 就把 handoff 按鈕 disabled。
- 實際 3D highlight 是否可視仍由 Review Room runtime 負責:session、viewer lease、first frame、DataChannel、stage match 與 mapping path 必須在 runtime 層觀測成立,A1 不 claim viewer lease。

## 1. 背景與驗證事實

Expand Down
6 changes: 3 additions & 3 deletions web-viewer-sample/src/console/A1CrossLinks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ describe("A1 cross-link chips", () => {
expect((sessions as HTMLButtonElement).disabled).toBe(true);
});

it("upgrades the existing a1-conv-link to carry source=a1 (spec §4.3 A1 → CV 『既有連結,補帶 source/id』)", () => {
it("upgrades the existing a1-conv-link to target #minio directly, carrying source=a1 (MD 三頁合一 Task 8:A1 → 模型資料與轉檔頁)", () => {
const html = renderToString(<A1GovernanceWorkbenchPage />);
const doc = new DOMParser().parseFromString(html, "text/html");
const convLink = doc.querySelector('[data-testid="a1-conv-link"]');
expect(convLink).not.toBeNull();
const href = convLink?.getAttribute("href") ?? "";
expect(href.startsWith("#conv")).toBe(true); // canonical hash (no slash), replacing the old #/conv
expect(href).toContain("source=a1"); // receiver (CV, Task 14) reads source; job_id appended when a conv job exists
expect(href.startsWith("#minio")).toBe(true); // MD 合一後直接指向 #minio,不再依賴 #conv → #minio alias 重導(Task 7/8)
expect(href).toContain("source=a1"); // receiver (ModelDataPage) reads source; job_id appended when a conv job exists
});
});
44 changes: 40 additions & 4 deletions web-viewer-sample/src/console/A1ViewerEmbed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,8 @@ describe("A1 3D review decoupling", () => {
});

it("rule-run result opens Review Room handoff with non-secret context instead of sending in-place highlight", async () => {
vi.spyOn(governanceClient, "createRuleRun").mockResolvedValue({ rule_run_id: "rr_a1", status: "queued" });
const directRunSpy = vi.spyOn(governanceClient, "createRuleRun").mockRejectedValue(new Error("selected session must use coordinator for-session proxy"));
const forSessionSpy = vi.spyOn(governanceClient, "createRuleRunForSession").mockResolvedValue({ rule_run_id: "rr_a1", status: "queued" });
vi.spyOn(governanceClient, "getRuleRun").mockResolvedValue(fakeRunStatus("succeeded"));
vi.spyOn(governanceClient, "getResults").mockResolvedValue([
{ ifc_guid: "2O2Fr$t4X7Zf8NOew3FLOH", usd_prim_path: "/World/Door_001", rule_code: "FIRE-RATING", severity: "error", status: "fail", message: "Fire rating missing" },
Expand All @@ -406,6 +407,10 @@ describe("A1 3D review decoupling", () => {
await act(async () => { q<HTMLButtonElement>("a1-step-run")!.click(); });
await flush();

expect(forSessionSpy).toHaveBeenCalledWith("review_session_x", {
ids_path: expect.stringContaining("sample-fire-rating.ids"),
});
expect(directRunSpy).not.toHaveBeenCalled();
const open = q<HTMLButtonElement>("a1-open-review-room")!;
expect(open.disabled).toBe(false);
await act(async () => { open.click(); });
Expand All @@ -420,9 +425,36 @@ describe("A1 3D review decoupling", () => {
expect(viewerBox.renderCount).toBe(0);
});

it("missing usd_prim_path opens Review Room with an honest mapping diagnostic", async () => {
it("opens Review Room from A1 results even before a review session is selected", async () => {
vi.spyOn(governanceClient, "createRuleRun").mockResolvedValue({ rule_run_id: "rr_a1", status: "queued" });
vi.spyOn(governanceClient, "getRuleRun").mockResolvedValue(fakeRunStatus("succeeded"));
vi.spyOn(governanceClient, "getResults").mockResolvedValue([
{ ifc_guid: "guid_no_session_yet", usd_prim_path: "/World/Door_002", rule_code: "FIRE-RATING", severity: "error", status: "fail", message: "Fire rating missing" },
]);

await renderA1();
await pickModel();
await act(async () => { q<HTMLButtonElement>("a1-step-run")!.click(); });
await flush();

const open = q<HTMLButtonElement>("a1-open-review-room")!;
expect(open.disabled).toBe(false);
await act(async () => { open.click(); });

expect(window.location.hash).toContain("#review?");
expect(window.location.hash).toContain("source=a1");
expect(window.location.hash).toContain("rule_run_id=rr_a1");
expect(window.location.hash).toContain("ifc_guid=guid_no_session_yet");
expect(window.location.hash).toContain("usd_prim_path=%2FWorld%2FDoor_002");
expect(window.location.hash).not.toContain("session=");
expect(window.location.hash).not.toContain("lease_token");
expect(viewerBox.renderCount).toBe(0);
});

it("missing usd_prim_path opens Review Room with an honest mapping diagnostic", async () => {
const directRunSpy = vi.spyOn(governanceClient, "createRuleRun").mockRejectedValue(new Error("selected session must use coordinator for-session proxy"));
const forSessionSpy = vi.spyOn(governanceClient, "createRuleRunForSession").mockResolvedValue({ rule_run_id: "rr_a1", status: "queued" });
vi.spyOn(governanceClient, "getRuleRun").mockResolvedValue(fakeRunStatus("succeeded"));
vi.spyOn(governanceClient, "getResults").mockResolvedValue([
{ ifc_guid: "guid_without_mapping", usd_prim_path: null, rule_code: "MAPPING", severity: "error", status: "fail", message: "missing mapping" },
]);
Expand All @@ -444,6 +476,10 @@ describe("A1 3D review decoupling", () => {
await act(async () => { q<HTMLButtonElement>("a1-step-run")!.click(); });
await flush();

expect(forSessionSpy).toHaveBeenCalledWith("review_session_x", {
ids_path: expect.stringContaining("sample-fire-rating.ids"),
});
expect(directRunSpy).not.toHaveBeenCalled();
const open = q<HTMLButtonElement>("a1-open-review-room")!;
expect(open.disabled).toBe(false);
await act(async () => { open.click(); });
Expand Down Expand Up @@ -493,7 +529,7 @@ describe("A1 3D review decoupling", () => {
expect(sp.get("minio_key")).toBeNull(); // sessions chip must not leak the minio key
});

it("A1 does not trigger conversion from the governance page; conversion is a #conv handoff", async () => {
it("A1 does not trigger conversion from the governance page; conversion is a #minio handoff", async () => {
vi.spyOn(coordinatorClient, "runtimeStatus")
.mockResolvedValue(fakeRuntimeStatus([]) as never);
const triggerSpy = vi.spyOn(coordinatorClient, "triggerConversion").mockRejectedValue(new Error("A1 must not trigger conversion"));
Expand All @@ -507,7 +543,7 @@ describe("A1 3D review decoupling", () => {

expect(triggerSpy).not.toHaveBeenCalled();
const href = q<HTMLAnchorElement>("a1-conv-link")?.getAttribute("href") ?? "";
expect(href).toContain("#conv?");
expect(href).toContain("#minio?");
expect(href).toContain("source=a1");
expect(href).toContain("minio_key=");
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// web-viewer-sample/src/console/EdgeConsole.aliasRedirect.test.tsx
// MD 三頁合一 Task 7(spec §5):#conv / #intake → #minio 的 URL 重寫式 alias(repo 第一個)。
// 三條驗收(brief Step 1,逐字對應):
// (1) renderToString(<EdgeConsole/>) 於 hash="#conv?job_id=J":純渲染不拋錯、輸出不含舊 CV 頁 h1
// (AliasRedirect 回 null、useEffect 不跑 → 不導航;hash 保持不變)。
// (2) DOM 掛載(createRoot + act)hash="#conv?job_id=J" → 重導 window.location.hash === "#minio?job_id=J"
// (replace 保 query)。
// (3) DOM 掛載 hash="#intake"(無 query)→ 重導 "#minio"。
import { act } from "react";
import { renderToString } from "react-dom/server";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import EdgeConsole from "./EdgeConsole";
import { coordinatorClient } from "./coordinatorClient";

describe("EdgeConsole alias 重導:#conv / #intake → #minio(MD 合一 Task 7)", () => {
const actEnvKey = "IS_REACT_ACT_ENVIRONMENT" as const;
let container: HTMLDivElement;
let prevActEnv: unknown;
let prevHash: string;

beforeEach(() => {
prevActEnv = (globalThis as Record<string, unknown>)[actEnvKey];
(globalThis as Record<string, unknown>)[actEnvKey] = true;
prevHash = window.location.hash;
container = document.createElement("div");
document.body.appendChild(container);
// 重導成功後 usePageHash 會切到 #minio → ModelDataPage 掛載並抓四源資料(getMinioFolder /
// getConversionRecords…)。stub 成空,讓測試聚焦「hash 是否被重寫」,不打真網路、不噴 loading 噪音。
vi.spyOn(coordinatorClient, "getMinioFolder").mockResolvedValue({
bucket: "bim-control",
prefix: "",
folders: [],
objects: [],
count: 0,
});
vi.spyOn(coordinatorClient, "getConversionRecords").mockResolvedValue({ count: 0, items: [] });
});
afterEach(() => {
document.body.removeChild(container);
vi.restoreAllMocks();
(globalThis as Record<string, unknown>)[actEnvKey] = prevActEnv;
window.location.hash = prevHash;
});

// 輪詢 window.location.hash 直到等於預期(重導在 AliasRedirect useEffect 內同步 replace,通常
// 首次 act flush 後即成立;迴圈為安全網並順帶 flush hashchange 觸發的 usePageHash re-render)。
async function waitForHash(expected: string, timeout = 1000): Promise<void> {
const start = Date.now();
while (window.location.hash !== expected) {
if (Date.now() - start > timeout) break;
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
}
expect(window.location.hash).toBe(expected);
}

it("renderToString #conv?job_id=J:不拋錯、輸出不含舊 CV 頁 h1(純渲染不觸發 useEffect → 不導航)", () => {
window.location.hash = "#conv?job_id=J";
let html = "";
expect(() => {
html = renderToString(<EdgeConsole />);
}).not.toThrow();
// 舊 CV 頁 h1(pages.tsx ConversionSchedulingPage:1036 `<h1>IFC→USD 轉檔排程</h1>`)不得出現——
// alias 分支只回 null,不掛舊頁本體。
expect(html).not.toContain("<h1>IFC→USD 轉檔排程</h1>");
// 純渲染未導航:renderToString 不跑 useEffect,hash 仍停在 #conv(未被 replace)。
expect(window.location.hash).toBe("#conv?job_id=J");
});

it("DOM 掛載 #conv?job_id=J → 重導 #minio?job_id=J(replace 保 query)", async () => {
window.location.hash = "#conv?job_id=J";
const root = createRoot(container);
await act(async () => {
root.render(<EdgeConsole />);
});
await waitForHash("#minio?job_id=J");
await act(async () => {
root.unmount();
});
});

it("DOM 掛載 #intake(無 query)→ 重導 #minio", async () => {
window.location.hash = "#intake";
const root = createRoot(container);
await act(async () => {
root.render(<EdgeConsole />);
});
await waitForHash("#minio");
await act(async () => {
root.unmount();
});
});
});
41 changes: 28 additions & 13 deletions web-viewer-sample/src/console/EdgeConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,12 @@ import {
AdminPage,
AppsPage,
AppVisionPage,
ConversionSchedulingPage,
CoordinatorPage,
FederationPage,
GpuReviewRoomPage,
HomePage,
IntakePage,
IssuesRuleCenterPage,
KitGpuFleetPage,
MinioDataPage,
OverviewPage,
ReportsPage,
ReviewRoomPage,
Expand All @@ -28,6 +25,9 @@ import {
ViewerPresentationPage,
VersionDiffPage,
} from "./pages";
// MD 三頁合一(Task 6/7):#minio 改由單一 ModelDataPage 承接(原 ConversionSchedulingPage / IntakePage /
// MinioDataPage 三頁合併)。舊三頁本體(export)保留於 pages.tsx 待 Task 9 統一移除,故此處不再 import。
import { ModelDataPage } from "./modelData/ModelDataPage";
// operator-tool 路由保留:#/kit、#/demo-control 原由 OperatorConsole 服務;換 EdgeConsole 後仍可達(非 silently 砍)。
import { KitConsolePage } from "./KitConsolePage";
import { RealIfcConsolePage } from "./RealIfcConsolePage";
Expand All @@ -50,6 +50,18 @@ function usePageHash(): [string, (k: string) => void] {
return [page, go];
}

// URL 重寫式 alias(spec §5,repo 第一個):舊 #conv / #intake deep link 一律重導到合一後的 #minio。
// 只能在 useEffect 內做(renderToString 純渲染不觸發 → SSR 不導航,避免 hydration 前搶跑);
// window.location.replace 不留 history 污染,並保留原 hash 的 query(如 job_id)供接收端重驗。
function AliasRedirect({ to }: { to: string }) {
useEffect(() => {
const raw = window.location.hash;
const q = raw.includes("?") ? raw.slice(raw.indexOf("?")) : "";
window.location.replace(`#${to}${q}`); // replace:不留 history 污染
}, [to]);
return null;
}

function renderBody(page: string, go: (k: string) => void) {
// app/<slug> → A4–A10 vision 詳頁(P3-1)。
if (page.startsWith("app/")) return <AppVisionPage slug={page.slice(4)} onOpen={go} />;
Expand All @@ -67,10 +79,11 @@ function renderBody(page: string, go: (k: string) => void) {
case "a10": return <AppVisionPage slug="robot-sim" onOpen={go} />;
case "viewer": return <ViewerPresentationPage />;
case "gpu": return <GpuReviewRoomPage />;
case "conv": return <ConversionSchedulingPage />;
// MD 合一(Task 7):舊 #conv(轉檔排程)/ #intake(進件)deep link 重導到 #minio;#minio 掛 ModelDataPage。
case "conv": case "intake": return <AliasRedirect to="minio" />;
case "sessions": return <SessionManagementPage />;
case "instances": return <KitGpuFleetPage />;
case "minio": return <MinioDataPage />;
case "minio": return <ModelDataPage />;
case "reports": return <ReportsPage />;
case "admin": return <AdminPage />;
case "spec": return <SpecPage />;
Expand All @@ -80,7 +93,6 @@ function renderBody(page: string, go: (k: string) => void) {
case "version-diff": return <VersionDiffPage />;
case "federation": return <FederationPage />;
case "coordinator": return <CoordinatorPage />;
case "intake": return <IntakePage />;
case "runtime": return <CoordinatorPage />;
case "review": return <ReviewRoomPage />;
case "semantic": return <SemanticViewerPage />;
Expand Down Expand Up @@ -108,7 +120,7 @@ const NAV_LABEL: Record<string, { tech: string; biz: string }> = {
conv: { tech: "Conversion Queue", biz: "IFC→USD 轉檔排程" },
sessions: { tech: "Session ATC", biz: "Session 管理" },
instances: { tech: "Kit/GPU Fleet", biz: "Kit / GPU 機隊" },
minio: { tech: "MinIO Data", biz: "MinIO 資料" },
minio: { tech: "Model Data & Conversion", biz: "模型資料與轉檔" },
reports: { tech: "Reports", biz: "報表中心" },
admin: { tech: "Admin", biz: "系統管理" },
spec: { tech: "Design Spec", biz: "設計規格說明" },
Expand All @@ -128,17 +140,17 @@ const COPILOT_PROMPTS: Record<string, string[]> = {
a2: ["v07 比 v06 改了什麼?", "哪些變更會影響成本?", "上一版的 issue 修掉了嗎?"],
a4: ["三樓所有沒填防火時效的防火門", "體積最大的 10 個房間", "屬於 L2 但分類碼空白的構件"],
a5: ["現在哪個區域溫度異常?", "列出逾期未處理的維保工單", "B1 機房本月用電趨勢"],
conv: ["哪些轉檔任務卡住或失敗了?", "把 988 的模型插隊優先轉", "列出 coverage < 95% 的任務"],
sessions: ["哪個 session 有 viewer 收不到 frame?", "把閒置超過 15 分鐘的 session 回收", "S-270 現在幾個人在看?"],
instances: ["哪台 GPU 還能接新 session?", "把新審查排到最閒的節點", "edge-gpu-02 的 VRAM 還夠嗎?"],
minio: ["270 專案有幾個模型?", "哪些模型還沒轉成 USD?", "model.ifc 最大的是哪一個?"],
// MD 合一(Task 7):原 conv(轉檔佇列)三條 prompts 併入 minio 陣列,刪 conv key(#conv 已 alias 至 #minio)。
minio: ["270 專案有幾個模型?", "哪些模型還沒轉成 USD?", "model.ifc 最大的是哪一個?", "哪些轉檔任務卡住或失敗了?", "把 988 的模型插隊優先轉", "列出 coverage < 95% 的任務"],
};

// FlowBar(P3-3):5 步操作員心智模型 Intake→Convert→Meeting→Mark→Record。
// state 為各步真實落地狀態(asbuilt / p15);非資料宣稱,純流程示意。
const FLOW: { n: string; tech: string; biz: string; state: Prov; page: string }[] = [
{ n: "①", tech: "Intake", biz: "接收建模來源", state: "asbuilt", page: "intake" },
{ n: "②", tech: "Convert", biz: "自動轉換 3D", state: "asbuilt", page: "intake" },
{ n: "①", tech: "Intake", biz: "接收建模來源", state: "asbuilt", page: "minio" },
{ n: "②", tech: "Convert", biz: "自動轉換 3D", state: "asbuilt", page: "minio" },
{ n: "③", tech: "Meeting", biz: "建立審查會議", state: "asbuilt", page: "runtime" },
{ n: "④", tech: "Mark", biz: "標記問題位置", state: "p15", page: "review" },
{ n: "⑤", tech: "Record", biz: "紀錄回寫雲端", state: "asbuilt", page: "runtime" },
Expand Down Expand Up @@ -185,8 +197,11 @@ export default function EdgeConsole() {
// #gpu/#review(Review Room 兩個入口)沒有自己的 AxisKey,歸類到 runtime(RT 供應 ready 狀態);
// 其餘非七軸頁(home/a2/admin…)預設回 a1(治理優先頁),不新增第八個 axis。
const AXIS_SET: readonly AxisKey[] = ["a1", "conv", "sessions", "instances", "minio", "intake", "runtime"];
const railAxis: AxisKey = (AXIS_SET as readonly string[]).includes(page) ? (page as AxisKey)
: page === "gpu" || page === "review" ? "runtime" : "a1";
// MD 合一(Task 7):#conv / #intake 已 alias 至 #minio,其 axis 脈絡歸到 minio(合一後的模型資料與轉檔頁)。
// AXIS_SET 陣列與 AxisKey 七軸型別不變;僅以 effectivePage 決定高亮的軸。
const effectivePage = page === "conv" || page === "intake" ? "minio" : page;
const railAxis: AxisKey = (AXIS_SET as readonly string[]).includes(effectivePage) ? (effectivePage as AxisKey)
: effectivePage === "gpu" || effectivePage === "review" ? "runtime" : "a1";

return (
<SharedStatusProvider>
Expand Down
Loading
Loading