diff --git a/e2e/run-e2e-web.mjs b/e2e/run-e2e-web.mjs index 738dc82c..9d0ff10e 100644 --- a/e2e/run-e2e-web.mjs +++ b/e2e/run-e2e-web.mjs @@ -173,9 +173,9 @@ async function main() { // 播种的项目在这台 agentred 上的真实目录(= 新对话的 cwd)。它是 workDir 下的 // 一个子目录,run 结束随 workDir 一起消失。 mkdirSync(projectDir, { recursive: true }); - // 桌面端的设备指纹与 bootstrap.newBootFingerprint 同形(16 字节 hex),它同时是 - // 桌面端在 agentred 上的对端身份 —— 会话归属的前半段。 - const desktopFP = randomUUID().replace(/-/g, ""); + // 桌面端 identity 与 R12/R13 的 backend DeviceID 使用同一个 canonical + // fingerprint。server 设备行、Wails keychain 与会话 peer identity 全部透传此值。 + const desktopFP = desktopFingerprint(randomUUID().replace(/-/g, "")); seeded = runTool([ "seed", "--dsn", @@ -432,10 +432,18 @@ function reapOrphanVite(dir) { // ── pieces ────────────────────────────────────────────────────────────────── -function daemonFingerprint(instanceUUID) { +function canonicalFingerprint(instanceUUID) { return `sha256:${createHash("sha256").update(instanceUUID).digest("hex")}`; } +function daemonFingerprint(instanceUUID) { + return canonicalFingerprint(instanceUUID); +} + +export function desktopFingerprint(instanceUUID) { + return canonicalFingerprint(instanceUUID); +} + function locateAgentreCheckout(serverDir) { const override = process.env.AGENTRE_DIR; if (override) { diff --git a/e2e/web/runner-config.spec.ts b/e2e/web/runner-config.spec.ts index 65b63203..8f626158 100644 --- a/e2e/web/runner-config.spec.ts +++ b/e2e/web/runner-config.spec.ts @@ -24,6 +24,7 @@ import { parseMySQLAddress, parseRedis, summarizeStartupFailure, + desktopFingerprint, } from "../run-e2e-web.mjs"; /** 造一个和真 checkout 同形的目录:configs/ + runtime/keys/ 下的真密钥文件。 */ @@ -96,6 +97,17 @@ const ROTATION = `server: issuer: "agentre-server" `; +test("dual runner 给桌面端生成与 R12/R13 一致的 canonical fingerprint", () => { + const instanceUUID = "0123456789abcdef0123456789abcdef"; + + const fingerprint = desktopFingerprint(instanceUUID); + + expect(fingerprint).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(fingerprint).toBe( + "sha256:3eb1bd439947eb762998e566ccc2e099c791118b2f40579cc4f7da2b5061b7f9", + ); +}); + test("带引号的 JWT 路径改写成真实存在的绝对路径,而不是把引号拼进路径", () => { const dir = fakeCheckout(["jwt.key", "jwt.pub"]); diff --git a/frontend/src/__tests__/chat.test.tsx b/frontend/src/__tests__/chat.test.tsx index 13c1bf75..44bf935d 100644 --- a/frontend/src/__tests__/chat.test.tsx +++ b/frontend/src/__tests__/chat.test.tsx @@ -497,6 +497,123 @@ describe("对话页:行上的时间是最后活动时间", () => { }); }); +describe("对话页:R20 重复会话合并", () => { + const desktop = { + id: 3, + name: "工作 MacBook", + kind: "desktop", + fingerprint: "fp-desktop", + last_seen_at: 1754000000000, + status: 1, + online: true, + }; + + function duplicateFollows() { + return [ + { + device_fingerprint: "fp-agentred", + session_id: "42", + followed_at: 1754000000000, + invalid: false, + }, + { + device_fingerprint: "fp-desktop", + session_id: "42", + followed_at: 1754000000000, + invalid: false, + }, + ]; + } + + it("同键的桌面端与 agentred 摘要只呈现桌面端完整副本", async () => { + const compute = { + ...agentred, + fingerprint: "fp-agentred", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/follows") return { items: duplicateFollows() }; + if (path === "/v1/devices") return { devices: [compute, desktop] }; + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockImplementation((fingerprint) => ({ + ...connectedRelay(), + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...summary, + peerFingerprint: "fp-desktop", + title: + fingerprint === "fp-desktop" + ? "Complete desktop title" + : "Partial agentred title", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + })); + + renderChat(); + + // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果: + // 只剩桌面端完整副本,没有 agentred 退化副本,也不带历史不完整说明。 + expect( + (await screen.findAllByText("Complete desktop title")).length, + ).toBeGreaterThan(0); + expect(screen.queryByText("Partial agentred title")).toBeNull(); + expect(screen.queryByText(/History is incomplete/)).toBeNull(); + }); + + it("桌面端副本不在场时退到 agentred,并显示历史不完整说明", async () => { + const compute = { + ...agentred, + fingerprint: "fp-agentred", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/follows") { + return { items: [duplicateFollows()[0]] }; + } + if (path === "/v1/devices") { + return { devices: [compute, { ...desktop, online: false }] }; + } + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + ...connectedRelay(), + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...summary, + peerFingerprint: "fp-desktop", + title: "Partial agentred title", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + }); + + renderChat(); + + // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果: + // 桌面端副本不在场时退到 agentred 副本并带历史不完整说明。 + expect( + (await screen.findAllByText("Partial agentred title")).length, + ).toBeGreaterThan(0); + expect( + screen.getAllByText( + "History is incomplete — showing only the part retained by agentred.", + ).length, + ).toBeGreaterThan(0); + }); +}); + // R13:这一页的每一行都靠 runtime.session.list 解析(标题 / 状态 / 等待输入 / // 最后活动时间)。机器掉线再回来时必须**重新**解析一次:断连期间那条对话可能跑完 // 了、可能停下来等审批,而页面上还挂着断线前那一刻的状态——用户对着一个早就过时 diff --git a/frontend/src/__tests__/device-expand.test.tsx b/frontend/src/__tests__/device-expand.test.tsx index f8194c53..267bc617 100644 --- a/frontend/src/__tests__/device-expand.test.tsx +++ b/frontend/src/__tests__/device-expand.test.tsx @@ -151,6 +151,79 @@ describe("device row expand", () => { expect(within(card).getByText(/View this machine/i)).toBeTruthy(); }); + it("expanding an active desktop row shows conversation counts and an enter action", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { + devices: [ + { + ...listResponse.devices[1], + online: true, + is_this_device: false, + }, + ], + }; + } + if (path === "/v1/workspace/device-detail?device_id=2") { + return { + device_id: 2, + kind: "desktop", + projects: [], + }; + } + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + const card = (await screen.findByText("laptop")).closest( + '[data-slot="card"]', + ) as HTMLElement; + fireEvent.click( + within(card).getByRole("button", { name: /show details/i }), + ); + + expect(await within(card).findByText("Conversations")).toBeTruthy(); + expect(await within(card).findByText("3 conversations")).toBeTruthy(); + expect(within(card).getByText("1 waiting for you")).toBeTruthy(); + const link = within(card).getByRole("link", { + name: "View this desktop's conversations", + }); + expect(link.getAttribute("href")).toBe("/devices/2/sessions"); + }); + + it("an inactive desktop says Agentre is not running and cannot be entered", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return listResponse; + if (path === "/v1/workspace/device-detail?device_id=2") { + return { + device_id: 2, + kind: "desktop", + projects: [], + }; + } + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + const card = (await screen.findByText("laptop")).closest( + '[data-slot="card"]', + ) as HTMLElement; + expect(within(card).getByText(/Agentre is not running/)).toBeTruthy(); + expect(within(card).queryByText(/^Offline$/)).toBeNull(); + + fireEvent.click( + within(card).getByRole("button", { name: /show details/i }), + ); + expect( + await within(card).findByText( + /Agentre is not running on this computer\. Open Agentre to view its conversations\./, + ), + ).toBeTruthy(); + expect(within(card).queryByRole("link", { name: /conversations/i })).toBe( + null, + ); + }); + // 帧 47:浏览器行不接单,也**不可展开** —— 展开它只会去问一台没有项目、没有 // Agent 的「设备」,把 agentred 的那套详情套在浏览器上是错的。 it("a kind=web row has no expand control", async () => { diff --git a/frontend/src/__tests__/device-management.test.tsx b/frontend/src/__tests__/device-management.test.tsx index 0d01a034..dd30edbc 100644 --- a/frontend/src/__tests__/device-management.test.tsx +++ b/frontend/src/__tests__/device-management.test.tsx @@ -95,11 +95,13 @@ describe("device management page", () => { expect(screen.getByText(/linux/)).toBeTruthy(); expect(screen.getByText(/darwin/)).toBeTruthy(); // 在线态列来自 API 的 online 字段(真实中继在线态),不是 status 推算(R20): - // 逐行按 API 给的 online 值断言,而不是全页面找一个 "Online" 就算数。 + // 逐行断言;desktop 离线要表达 App 未运行,而不是笼统的机器离线。 const nucCard = screen.getByText("nuc-01").closest('[data-slot="card"]'); const laptopCard = screen.getByText("laptop").closest('[data-slot="card"]'); expect(within(nucCard as HTMLElement).getByText(/Online/)).toBeTruthy(); - expect(within(laptopCard as HTMLElement).getByText(/Offline/)).toBeTruthy(); + expect( + within(laptopCard as HTMLElement).getByText(/Agentre is not running/), + ).toBeTruthy(); }); it("revoke confirmation carries the R4 delay note, then revokes and removes the device", async () => { diff --git a/frontend/src/__tests__/device-sessions.test.tsx b/frontend/src/__tests__/device-sessions.test.tsx index faaa380f..c31e4cc4 100644 --- a/frontend/src/__tests__/device-sessions.test.tsx +++ b/frontend/src/__tests__/device-sessions.test.tsx @@ -116,8 +116,85 @@ describe("设备会话列表页", () => { expect(fakeClient.request).toHaveBeenCalledWith("runtime.session.list"); }); + it("在线桌面端复用同一套会话列表并消费完整 SessionSummary", async () => { + const desktop = { + ...deviceRow, + id: 1, + name: "工作 MacBook", + kind: "desktop", + fingerprint: "fp-desktop", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return { devices: [desktop] }; + if (path === "/v1/workspace/agents") { + return { agents: [{ sync_id: "ag-1", name: "后端 Agent" }] }; + } + if (path === "/v1/follows") return { items: [] }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...sessions[0], + peerFingerprint: "fp-desktop", + title: "桌面完整标题", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + relayState: "connected", + webDevice: { fingerprint: "fp-web", accessToken: "t", deviceId: 9 }, + webDeviceError: null, + }); + + renderPage(); + + expect(await screen.findByText("桌面完整标题")).toBeTruthy(); + // 桌面端列表等待输入时用状态点 + aria-label 表达(#10 起文字徽标只在移动端)。 + expect( + screen.getByTestId("session-dot-42").getAttribute("aria-label"), + ).toBe("Waiting for your input"); + expect(mockUseRelay).toHaveBeenCalledWith("fp-desktop"); + expect(screen.queryByText("Unnamed")).toBeNull(); + }); + + it("未运行的桌面端直达列表页时说明打开 Agentre,不借用机器离线措辞", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { + devices: [ + { + ...deviceRow, + kind: "desktop", + fingerprint: "fp-desktop", + online: false, + }, + ], + }; + } + if (path === "/v1/workspace/agents") return { agents: [] }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + client: null, + relayState: "disconnected", + webDevice: null, + webDeviceError: null, + }); + + renderPage(); + + expect(await screen.findByText(/Open Agentre to continue/)).toBeTruthy(); + expect(screen.queryByText(/This machine is offline/)).toBeNull(); + expect(mockUseRelay).not.toHaveBeenCalledWith("fp-desktop"); + }); + // 界面(决策 11 / 帧 45a):「行尾一个「换机器」入口**就地切换**」——不是把人送回 - // 设备页再从头下钻一次。就地列出账号下的其余 agentred,选中即换到那台机器的会话列表。 + // 设备页再从头下钻一次。就地列出账号下的其余目标,选中即换到那台机器的会话列表。 it("面包屑行尾「换机器」就地列出其余 agentred 并切过去", async () => { const other = { id: 2, diff --git a/frontend/src/__tests__/dispatch.test.ts b/frontend/src/__tests__/dispatch.test.ts index 74ac651e..3fdadbe7 100644 --- a/frontend/src/__tests__/dispatch.test.ts +++ b/frontend/src/__tests__/dispatch.test.ts @@ -53,6 +53,7 @@ const availablePlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", availability: "available", current: true, }, @@ -62,11 +63,38 @@ const availablePlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", cwd: "/srv/agentre-server", }, projects: [], }; +// R17:第一档可用的是桌面端时,派发计划选中它(kind=desktop),发起前界面据此 +// 说明 org/subagent/hook 可用(见 NewConversationDialog)。逻辑层照常用 runtime.run +// 把对话建到那台桌面端上,不注入 mcpServers。 +const desktopPlan: DispatchPlan = { + ...availablePlan, + tiers: [ + { + rank: 1, + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + availability: "available", + current: true, + }, + ], + chosen: { + device_fingerprint: "fp-desk", + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + cwd: "/Users/wyz/agentre-server", + }, +}; + const allUnavailablePlan: DispatchPlan = { agent_sync_id: "agent-1", tiers: [ @@ -219,6 +247,58 @@ describe("dispatchNewConversation(R15 派发 + R16 自关注)", () => { expect(mockedApi).not.toHaveBeenCalled(); }); + // R17:目标是桌面端时,同一套 runtime.run 把对话建到那台桌面端上——带着第一句、 + // 发起方身份与那台机器上的项目路径;不注入 mcpServers(org/subagent/hook 是桌面端 + // 本机内置工具,可用性由发起前的界面按 kind=desktop 如实说明,不由浏览器注入)。 + it("目标是桌面端时向桌面端发 runtime.run 建会话(不注入 mcpServers)", async () => { + const client = fakeClient(); + MockRelayClient.mockImplementation((() => client) as never); + + const out = await dispatchNewConversation({ + plan: desktopPlan, + message: "帮我看看这个项目", + sourceDevice, + }); + + const constructorOpts = MockRelayClient.mock.calls[0][0] as { + url: string; + jwt: string; + reconnect: boolean; + }; + expect(constructorOpts.url).toContain("fp-desk"); + expect(constructorOpts.jwt).toBe("web-jwt"); + expect(constructorOpts.reconnect).toBe(false); + + const [method, params] = client.request.mock.calls[0]; + const p = params as Record; + expect(method).toBe(MethodRun); + expect(p.sessionId).toBeGreaterThan(0); + expect(p.agentSyncId).toBe("agent-1"); + expect(p.cwd).toBe("/Users/wyz/agentre-server"); + expect(p.userText).toBe("帮我看看这个项目"); + expect(p.title).toBe("帮我看看这个项目"); + expect(p.backend).toEqual({ type: "claudecode" }); + expect(p.sourceDevice).toBe("fp-web"); + expect(p.sourceDeviceName).toBe("Chrome · macOS"); + expect(p.mcpServers).toBeUndefined(); + + // R16:桌面端建会话成功后同样立刻关注自己这条。 + expect(mockedApi).toHaveBeenCalledWith("/v1/follows", { + method: "POST", + body: JSON.stringify({ + device_fingerprint: "fp-desk", + session_id: "9001", + }), + }); + + expect(out).toEqual({ + sessionId: 9001, + deviceId: 30, + deviceFingerprint: "fp-desk", + }); + expect(client.close).toHaveBeenCalled(); + }); + // R16 的自关注是**派发成功之后**的收尾动作:runtime.run 一旦返回 ack,那台机器上 // 就已经真真切切多了一条会话。此时把关注写失败(网络抖动 / server 500)报成派发 // 失败,界面会说「联系不上这台机器,请重试」——用户一重试就凭空又开一条真会话, diff --git a/frontend/src/__tests__/new-conversation.test.tsx b/frontend/src/__tests__/new-conversation.test.tsx index 2423dc89..c66e8cc1 100644 --- a/frontend/src/__tests__/new-conversation.test.tsx +++ b/frontend/src/__tests__/new-conversation.test.tsx @@ -99,10 +99,36 @@ const finalPlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", cwd: "/srv/agentre-server", }, }; +// R17:目标是桌面端时,发起前如实说明 org / subagent / hook **可用**(真身在桌面端)。 +const desktopFinalPlan: DispatchPlan = { + agent_sync_id: "agent-1", + tiers: [ + { + rank: 1, + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + availability: "available", + current: true, + }, + ], + chosen: { + device_fingerprint: "fp-desk", + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + cwd: "/Users/wyz/agentre-server", + }, + projects: [{ sync_id: "proj-1", name: "agentre-server", configured: true }], +}; + const allUnavailablePlan: DispatchPlan = { agent_sync_id: "agent-1", tiers: [ @@ -248,6 +274,40 @@ describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => { ); }); + it("目标是桌面端时,确认步如实说明 org / subagent / hook 可用(不是沿用 agentred 的不可用文案)", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockFetchPlan.mockImplementation(async (_agent, project) => + project ? desktopFinalPlan : pickPlan, + ); + mockEnsureWebDevice.mockResolvedValue(webDevice); + mockDispatch.mockResolvedValue({ + sessionId: 9002, + deviceId: 30, + deviceFingerprint: "fp-desk", + }); + const onStarted = vi.fn(); + renderDialog(onStarted); + + fireEvent.click(await screen.findByText("后端 Agent")); + fireEvent.click(await screen.findByText("agentre-server")); + + // R17:目标是桌面端 → org / subagent / hook 可用(真身就在那台机器上)。 + expect( + await screen.findByText("org / subagent / hook are available here"), + ).toBeTruthy(); + // 不可用的文案不得出现。 + expect( + screen.queryByText("org / subagent / hook are not available here"), + ).toBeNull(); + // 屏 25:将运行在桌面端机器 · 路径。 + expect( + screen.getByText("Will run on 家里 Mac mini · /Users/wyz/agentre-server"), + ).toBeTruthy(); + }); + it("不输入第一句时「开始」按钮是禁用的(发出第一条消息之前什么都不会跑)", async () => { mockedApi.mockImplementation(async (path) => { if (path === "/v1/workspace/agents") return { agents }; diff --git a/frontend/src/__tests__/session-detail.test.tsx b/frontend/src/__tests__/session-detail.test.tsx index 3cf0ddf0..8d5e40f1 100644 --- a/frontend/src/__tests__/session-detail.test.tsx +++ b/frontend/src/__tests__/session-detail.test.tsx @@ -489,6 +489,39 @@ describe("会话详情页:老 agentred 与发送失败", () => { ).toBe(true); }); + it("桌面端在场但钉住的 agentred 不可用:历史仍可读、新写入停用并给专门说明", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { devices: [{ ...deviceRow, kind: "desktop" }] }; + } + throw new Error("unexpected: " + path); + }); + fakeClient.request.mockImplementation(async (method) => { + if (method === "runtime.session.list") { + return { sessions: [summary], supportsSessionMetadata: true }; + } + if (method === "runtime.session.pendingWaiters") { + return { toolPermissions: [], askUserQuestions: [] }; + } + if (method === "runtime.run") throw new Error("pinned agentred offline"); + throw new Error("unexpected: " + method); + }); + + renderPage(); + await screen.findByText(/重构登录页/); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "继续" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect( + await screen.findByText(/Conversation history is still available/), + ).toBeTruthy(); + expect(screen.getByRole("textbox").hasAttribute("disabled")).toBe(true); + expect(screen.queryByText(/draft is kept/)).toBeNull(); + }); + it("发送失败:就地报错,不静默吞掉", async () => { mockedApi.mockImplementation(async (path) => { if (path === "/v1/devices") return { devices: [deviceRow] }; diff --git a/frontend/src/__tests__/session-merge.test.ts b/frontend/src/__tests__/session-merge.test.ts new file mode 100644 index 00000000..26f27450 --- /dev/null +++ b/frontend/src/__tests__/session-merge.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { mergeSessionCopies } from "@/lib/sessionMerge"; + +const desktopSummary = { + sessionId: 42, + peerFingerprint: "fp-desktop", + title: "Complete desktop title", + agentSyncId: "ag-1", + lifecycleState: "idle", + latestSeq: 9, +}; + +const agentredSummary = { + ...desktopSummary, + title: "Partial agentred title", + latestSeq: 4, +}; + +describe("duplicate session merge", () => { + it("merges by peer fingerprint plus session id and prefers the desktop copy", () => { + const merged = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + { + sourceFingerprint: "fp-desktop", + sourceKind: "desktop", + summary: desktopSummary, + }, + ], + new Set(["fp-desktop"]), + ); + + expect(merged).toHaveLength(1); + expect(merged[0].sourceKind).toBe("desktop"); + expect(merged[0].summary.title).toBe("Complete desktop title"); + expect(merged[0].historyIncomplete).toBe(false); + + const reversed = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-desktop", + sourceKind: "desktop", + summary: desktopSummary, + }, + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + ], + new Set(["fp-desktop"]), + ); + expect(reversed).toHaveLength(1); + expect(reversed[0].sourceKind).toBe("desktop"); + }); + + it("does not merge equal session ids from different peer fingerprints", () => { + const merged = mergeSessionCopies([ + { + sourceFingerprint: "fp-agentred-a", + sourceKind: "agentred", + summary: agentredSummary, + }, + { + sourceFingerprint: "fp-agentred-b", + sourceKind: "agentred", + summary: { ...agentredSummary, peerFingerprint: "fp-other" }, + }, + ]); + + expect(merged).toHaveLength(2); + }); + + it("falls back to the agentred copy and marks its retained history incomplete", () => { + const merged = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + ], + new Set(["fp-desktop"]), + ); + + expect(merged).toHaveLength(1); + expect(merged[0].sourceKind).toBe("agentred"); + expect(merged[0].historyIncomplete).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/session-status.test.tsx b/frontend/src/__tests__/session-status.test.tsx index a40b2f60..8a7ae168 100644 --- a/frontend/src/__tests__/session-status.test.tsx +++ b/frontend/src/__tests__/session-status.test.tsx @@ -1,11 +1,11 @@ /** - * R11 测试接缝:四类不可达与失效各自可区分、可访问性的文字标注。 + * R11 测试接缝:六类失败/失效各自可区分、可访问性的文字标注。 * * 断言三件事: - * 1. 七个视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。 - * 2. 状态不只靠颜色:运行 / 等待输入 / 离线 / 解除授权都有文字(getByText 能取到)。 - * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类 - * (lost / machineOffline / revoked / loggedOut)走 role="alert"。 + * 1. 所有视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。 + * 2. 状态不只靠颜色:六类失败都有可见文字(getByText 能取到)。 + * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类走 + * role="alert"。 */ import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -37,11 +37,13 @@ const ALL_STATUSES: SessionViewStatus[] = [ "reconnecting", "lost", "machineOffline", + "desktopAppNotRunning", + "pinnedAgentredUnavailable", "revoked", "loggedOut", ]; -describe("会话状态:四类不可达与失效各自可区分", () => { +describe("会话状态:六类失败与失效各自可区分", () => { it("每种状态渲染出互不相同的可见文案", () => { const texts = new Map(); for (const status of ALL_STATUSES) { @@ -61,16 +63,25 @@ describe("会话状态:四类不可达与失效各自可区分", () => { seen.add(own); texts.set(status, own); } - // machineOffline / revoked / loggedOut 三者文案互不相同(四类不折叠)。 - expect(texts.get("machineOffline")).not.toBe(texts.get("revoked")); - expect(texts.get("machineOffline")).not.toBe(texts.get("loggedOut")); - expect(texts.get("revoked")).not.toBe(texts.get("loggedOut")); + const failures: SessionViewStatus[] = [ + "machineOffline", + "lost", + "revoked", + "loggedOut", + "desktopAppNotRunning", + "pinnedAgentredUnavailable", + ]; + expect(new Set(failures.map((status) => texts.get(status))).size).toBe(6); }); it("状态不只靠颜色:关键状态都有可见文字(而非仅图标/色块)", () => { renderStatus("machineOffline"); + renderStatus("desktopAppNotRunning"); + renderStatus("pinnedAgentredUnavailable"); renderStatus("revoked"); - expect(screen.getAllByRole("alert").length).toBe(2); + renderStatus("loggedOut"); + renderStatus("lost"); + expect(screen.getAllByRole("alert").length).toBe(6); // 文字存在且非空。 const alerts = screen.getAllByRole("alert"); for (const a of alerts) { @@ -86,17 +97,21 @@ describe("会话状态:四类不可达与失效各自可区分", () => { expect(screen.getAllByRole("alert").length).toBe(2); }); - it("deriveSessionViewStatus 把四类信号各自映射到不同状态", () => { + it("deriveSessionViewStatus 把六类失败信号各自映射到不同状态", () => { const base: { relayState: RelayState; meValid: boolean; webDeviceRevoked: boolean; machineOnline: boolean | null; + targetKind: "agentred" | "desktop"; + pinnedAgentredUnavailable: boolean; } = { relayState: "disconnected", meValid: true, webDeviceRevoked: false, machineOnline: true, + targetKind: "agentred", + pinnedAgentredUnavailable: false, }; expect(deriveSessionViewStatus({ ...base, meValid: false })).toBe( "loggedOut", @@ -107,6 +122,20 @@ describe("会话状态:四类不可达与失效各自可区分", () => { expect(deriveSessionViewStatus({ ...base, machineOnline: false })).toBe( "machineOffline", ); + expect( + deriveSessionViewStatus({ + ...base, + targetKind: "desktop", + machineOnline: false, + }), + ).toBe("desktopAppNotRunning"); + expect( + deriveSessionViewStatus({ + ...base, + relayState: "connected", + pinnedAgentredUnavailable: true, + }), + ).toBe("pinnedAgentredUnavailable"); expect(deriveSessionViewStatus({ ...base, relayState: "connected" })).toBe( "connected", ); diff --git a/frontend/src/components/session/ChatList.tsx b/frontend/src/components/session/ChatList.tsx index e15ed93c..2f8d81e2 100644 --- a/frontend/src/components/session/ChatList.tsx +++ b/frontend/src/components/session/ChatList.tsx @@ -58,6 +58,8 @@ export interface ChatSessionRow { deviceName: string; followedAt: number; summary: SessionSummary; + /** 桌面副本不在场而退到 agentred 副本时,只读得到执行端保留的部分历史。 */ + historyIncomplete?: boolean; /** 移动形态下钉在行上的 Agent 名称与头像色(R13,见 regroupByStatus)。 */ agentName?: string; agentColor?: string; @@ -76,6 +78,7 @@ export interface ChatOfflineRow { sessionId: number; deviceId: number; deviceName: string; + deviceKind: string; lastSeenAt: number; } @@ -234,6 +237,11 @@ function SessionRowBody({ {row2}

)} + {row.historyIncomplete && ( +

+ {t("session.list.historyIncomplete")} +

+ )} {/* 移动端保文字徽标(不只靠颜色);桌面由状态点 + aria 承担。 */} {isMobile && ( @@ -409,10 +417,15 @@ function OfflineRow({ } >

- {t("chat.offlineMachineWithTime", { - machine: row.deviceName, - time: formatTime(row.lastSeenAt), - })} + {t( + row.deviceKind === "desktop" + ? "chat.desktopAppNotRunningWithTime" + : "chat.offlineMachineWithTime", + { + machine: row.deviceName, + time: formatTime(row.lastSeenAt), + }, + )}

{/* 决策 16:移动的关注入口在详情页顶栏,不在列表行。 */} diff --git a/frontend/src/components/session/NewConversationDialog.tsx b/frontend/src/components/session/NewConversationDialog.tsx index 48fe51ce..f89a31bb 100644 --- a/frontend/src/components/session/NewConversationDialog.tsx +++ b/frontend/src/components/session/NewConversationDialog.tsx @@ -6,8 +6,9 @@ * - R15:派发计划(/v1/workspace/dispatch-target)按序列出执行目标链每一档的 * 原因(本机跳过 / 未配对 / 离线 / 项目路径缺失),只有第一档可用的 agentred * 才能继续;全部不可用时不静默失败,逐档把原因摆给用户(屏 24 的「现在选不了」)。 - * - R17:发起前就在确认步说明 org / subagent / hook 用不了以及原因——不是等 - * 工具调用失败了才报错。 + * - R17:发起前就在确认步说明 org / subagent / hook 在当前目标下是否可用——目标是 + * 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时轮 A 的 R17 + * 原样成立、不可用。不是等工具调用失败了才报错。 * - R16:确认后经 lib/dispatch.dispatchNewConversation 派发并立刻关注自己这条, * 于是它不经「关注」就出现在「对话」页。 */ @@ -360,18 +361,27 @@ export default function NewConversationDialog({ })}

)} - {/* R17:发起前就说明 org / subagent / hook 用不了以及原因。 */} -
-

- {t("chat.r17Title")} -

-

- {t("chat.r17Body")} -

-
+ {/* R17:发起前如实说明当前目标下 org / subagent / hook 是否可用——目标是 + 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时 + 轮 A 的 R17 原样成立、不可用。不是等工具调用失败了才报错。 */} + {confirmChoice && ( +
+

+ {confirmChoice.kind === "desktop" + ? t("chat.r17DesktopTitle") + : t("chat.r17Title")} +

+

+ {confirmChoice.kind === "desktop" + ? t("chat.r17DesktopBody") + : t("chat.r17Body")} +

+
+ )}
)} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 7b2dea33..365019d7 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -66,6 +66,7 @@ interface SessionCounts { const ACTIVE = 1; const KIND_AGENTRED = "agentred"; +const KIND_DESKTOP = "desktop"; // 只有 ApiError 才带可展示的服务端文案;其余(代理返回非 JSON 的 502 → SyntaxError、 // 离线 → TypeError)同样是失败,必须说出来 —— 静默吞掉会让页面渲染成「还没有任何 @@ -98,7 +99,9 @@ function statusLabel(d: DeviceItem, t: (key: string) => string): string { if (d.status !== ACTIVE) return t("device.manage.statusRevoked"); return d.online ? t("device.manage.statusOnline") - : t("device.manage.statusOffline"); + : d.kind === KIND_DESKTOP + ? t("device.manage.desktopAppNotRunningShort") + : t("device.manage.statusOffline"); } /** 设备是否可撤销:撤销端点只认属于本账号的 ACTIVE 设备;已撤销的没有凭据可撤。 */ @@ -107,9 +110,9 @@ function isRevocable(d: DeviceItem): boolean { } /** - * 一台 agentred 的会话计数(总条数 / 等待处理 / 在跑)。server 一条会话都不存 - * (硬不变量),唯一真相源是那台 agentred 的 relay session.list —— 现连现问, - * 展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。 + * 一台目标设备(桌面端或 agentred)的会话计数(总条数 / 等待处理 / 在跑)。 + * server 一条会话都不存(硬不变量),唯一真相源是那台设备的 relay session.list —— + * 现连现问,展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。 * * 「在跑」数是设备卡副行 cardSummary 的 {{m}};「对话」一节用 total/waiting。 */ @@ -146,10 +149,10 @@ function useSessionCounts( /** * 设备行展开的详情:agentred 列「能跑的 Agent」(带档位)、「已配置的项目」与 - * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端只列「项目」。 - * 对话一节:在线的 agentred 给「查看这台机器的对话」入口;离线的不可进入, - * 就地标明离线与最后在线时间(R4)。会话计数由外层 useSessionCounts 提供, - * 这里不再各自连一次中继。 + * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端列项目。 + * 对话一节:两类目标复用同形入口 —— 在线给「查看这台机器 / 这个桌面端的对话」; + * 不可达时 agentred 保留既有离线措辞,桌面端明确说明 Agentre App 没有运行并提示打开它。 + * 会话计数由外层 useSessionCounts 提供,这里不再各自连一次中继。 * * 只渲染真实数据;N1/N2 旁白(「只显示是否配置」「Agent 属于账号」)不进入产品。 */ @@ -177,6 +180,7 @@ function DeviceExpandDetail({ const detail = state.data; if (!detail) return null; const isAgentred = detail.kind === KIND_AGENTRED; + const isDesktop = detail.kind === KIND_DESKTOP; return (
@@ -237,7 +241,7 @@ function DeviceExpandDetail({
)} - {isAgentred && ( + {(isAgentred || isDesktop) && (
{t("device.manage.sessions")} @@ -263,12 +267,20 @@ function DeviceExpandDetail({ data-testid={`device-sessions-link-${device.id}`} className="inline-flex w-fit items-center text-xs font-medium text-primary-text hover:underline" > - {t("device.manage.viewSessions")} + {t( + isDesktop + ? "device.manage.viewDesktopSessions" + : "device.manage.viewSessions", + )} ) : (

- {t("device.manage.offlineNotEnterable")} + {t( + isDesktop + ? "device.manage.desktopAppNotRunningDetail" + : "device.manage.offlineNotEnterable", + )} {device.last_seen_at > 0 ? ` ${new Date(device.last_seen_at).toLocaleString()}` : ""} @@ -316,8 +328,11 @@ function DeviceRow({ detailState: { loading: boolean; error: unknown; data: DeviceDetail | null }; t: (key: string, opts?: Record) => string; }) { - // 只有展开的在线 agentred 才去问中继;其余设备 fingerprint 传 null,不连。 - const sessionActive = isExpanded && d.kind === KIND_AGENTRED && d.online; + // 只有展开的在线 agentred / 桌面端才去问中继;其余设备 fingerprint 传 null,不连。 + const sessionActive = + isExpanded && + (d.kind === KIND_AGENTRED || d.kind === KIND_DESKTOP) && + d.online; const counts = useSessionCounts( sessionActive ? d.fingerprint : null, sessionActive, diff --git a/internal/api/workspace/workspace.go b/internal/api/workspace/workspace.go index 036b0ddd..b56b14ce 100644 --- a/internal/api/workspace/workspace.go +++ b/internal/api/workspace/workspace.go @@ -52,6 +52,9 @@ type DispatchTierItem struct { DeviceID int64 `json:"device_id,omitempty"` DeviceName string `json:"device_name,omitempty"` BackendType string `json:"backend_type,omitempty"` + // Kind 是这一档指向的设备种类(desktop / agentred)。R17 发起前据此如实说明 + // org/subagent/hook 在目标上是否可用;无设备的档(本机相对 / 未配对)不带它。 + Kind string `json:"kind,omitempty"` // Availability 是 available / offline / unpaired / skipped_for_web / // project_path_missing 之一。 Availability string `json:"availability"` @@ -66,7 +69,9 @@ type DispatchChoiceItem struct { DeviceID int64 `json:"device_id"` DeviceName string `json:"device_name"` BackendType string `json:"backend_type"` - Cwd string `json:"cwd,omitempty"` + // Kind 是选中目标设备种类(desktop / agentred),R17 发起前据此说明工具可用性。 + Kind string `json:"kind"` + Cwd string `json:"cwd,omitempty"` } type DispatchTargetResponse struct { diff --git a/internal/controller/relay_ctr/relay_test.go b/internal/controller/relay_ctr/relay_test.go index e2af0dff..62514749 100644 --- a/internal/controller/relay_ctr/relay_test.go +++ b/internal/controller/relay_ctr/relay_test.go @@ -227,6 +227,53 @@ func TestRelayEndpointsRequireDeviceJWTAndDaemonRenewsOnFrames(t *testing.T) { } } +func TestDesktopRelayTargetCanBeAddressedThroughEndpoints(t *testing.T) { + gin.SetMode(gin.TestMode) + testutils.Redis() + mini := miniredis.RunT(t) + signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre") + require.NoError(t, err) + + controller := gomock.NewController(t) + devices := mock_device_repo.NewMockDeviceRepo(controller) + desktop := &device_entity.Device{ + ID: 10, UserID: 7, Kind: device_entity.KindDesktop, Fingerprint: "fp-desktop", Status: 1, + } + devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil) + devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil) + + config := relay_svc.Config{InstanceID: "server-a", OnlineTTL: time.Second} + redisClient := newRelayRedisClient(t, mini) + server := newRelayServer(t, signer, relay_svc.New( + config, devices, redisClient, relay_svc.NewRedisForwarder(config, redisClient), + )) + targetToken, _, err := signer.Sign(jwt.Claims{ + UID: desktop.UserID, DID: desktop.ID, Kind: device_entity.KindDesktop, + }, time.Hour) + require.NoError(t, err) + clientToken, _, err := signer.Sign(jwt.Claims{ + UID: desktop.UserID, DID: 4, Kind: device_entity.KindAgentred, + }, time.Hour) + require.NoError(t, err) + + targetConn, _, err := websocket.DefaultDialer.Dial( + wsURL(server.URL, "/v1/relay/daemon"), + http.Header{"Authorization": {"Bearer " + targetToken}}, + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, targetConn.Close()) }) + + clientConn, response, err := websocket.DefaultDialer.Dial( + wsURL(server.URL, "/v1/relay/client?daemon_fingerprint="+desktop.Fingerprint), + http.Header{"Authorization": {"Bearer " + clientToken}}, + ) + if response != nil { + t.Cleanup(func() { require.NoError(t, response.Body.Close()) }) + } + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, clientConn.Close()) }) +} + func TestRelayLifecycleRejectsOversizedMessagesAndDetaches(t *testing.T) { gin.SetMode(gin.TestMode) testutils.Redis() @@ -321,15 +368,15 @@ func TestRelayClientForwardingErrorStillClosesClientConnection(t *testing.T) { require.Error(t, err) } -// PrepareDaemon 的准入判据(本账号名下、活跃的 agentred)被拒时必须走 403,而且 -// 必须**在 websocket upgrade 之前**答复:一个 desktop 端的 device JWT 拿不到升级后 -// 的连接,也就没机会占住这个账号+指纹的中继路由。403 这一支此前没有任何测试。 +// PrepareDaemon 的准入判据(本账号名下、活跃且可寻址的设备)被拒时必须走 403, +// 而且必须**在 websocket upgrade 之前**答复:不支持的设备种类拿不到升级后的连接, +// 也就没机会占住这个账号+指纹的中继路由。 func TestRelayDaemonForbiddenAnswers403BeforeUpgrading(t *testing.T) { gin.SetMode(gin.TestMode) testutils.Redis() signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre") require.NoError(t, err) - token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindDesktop}, time.Hour) + token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindWeb}, time.Hour) require.NoError(t, err) stub := &relayStub{ diff --git a/internal/controller/workspace_ctr/workspace.go b/internal/controller/workspace_ctr/workspace.go index 29aa95b7..30a04a30 100644 --- a/internal/controller/workspace_ctr/workspace.go +++ b/internal/controller/workspace_ctr/workspace.go @@ -57,7 +57,8 @@ func (w *Workspace) DispatchTarget(c *gin.Context, req *api.DispatchTargetReques for _, t := range plan.Tiers { resp.Tiers = append(resp.Tiers, api.DispatchTierItem{ Rank: t.Rank, DeviceID: t.DeviceID, DeviceName: t.DeviceName, - BackendType: t.BackendType, Availability: t.Availability, Current: t.Current, + BackendType: t.BackendType, Kind: t.Kind, + Availability: t.Availability, Current: t.Current, }) } if plan.Chosen != nil { @@ -66,6 +67,7 @@ func (w *Workspace) DispatchTarget(c *gin.Context, req *api.DispatchTargetReques DeviceID: plan.Chosen.DeviceID, DeviceName: plan.Chosen.DeviceName, BackendType: plan.Chosen.BackendType, + Kind: plan.Chosen.Kind, Cwd: plan.Chosen.Cwd, } } diff --git a/internal/service/relay_svc/relay.go b/internal/service/relay_svc/relay.go index 021646ad..c225667a 100644 --- a/internal/service/relay_svc/relay.go +++ b/internal/service/relay_svc/relay.go @@ -155,19 +155,23 @@ func New(config Config, devices device_repo.DeviceRepo, redisClient *goredis.Cli } func (s *relaySvc) PrepareDaemon(ctx context.Context, accountID, deviceID int64, kind string) (Route, error) { - if kind != device_entity.KindAgentred { + if !isAddressableKind(kind) { return Route{}, ErrDaemonForbidden } device, err := s.devices.Find(ctx, deviceID) if err != nil { return Route{}, err } - if device == nil || device.UserID != accountID || device.Kind != device_entity.KindAgentred || !device.IsActive() { + if device == nil || device.UserID != accountID || !isAddressableKind(device.Kind) || !device.IsActive() { return Route{}, ErrDaemonForbidden } return Route{AccountID: accountID, Fingerprint: device.Fingerprint, InstanceID: s.config.InstanceID}, nil } +func isAddressableKind(kind string) bool { + return kind == device_entity.KindAgentred || kind == device_entity.KindDesktop +} + func (s *relaySvc) RegisterDaemon(ctx context.Context, route Route) error { if err := s.redis.Set(ctx, routeKey(route.AccountID, route.Fingerprint), route.InstanceID, s.config.OnlineTTL).Err(); err != nil { return fmt.Errorf("register relay daemon: %w", err) @@ -198,7 +202,7 @@ func (s *relaySvc) ConnectClient(ctx context.Context, accountID int64, fingerpri if err != nil { return Route{}, err } - if device == nil || device.Kind != device_entity.KindAgentred || !device.IsActive() { + if device == nil || !isAddressableKind(device.Kind) || !device.IsActive() { return Route{}, ErrDaemonNotFound } diff --git a/internal/service/relay_svc/relay_test.go b/internal/service/relay_svc/relay_test.go index 59a42c91..c400cf4e 100644 --- a/internal/service/relay_svc/relay_test.go +++ b/internal/service/relay_svc/relay_test.go @@ -39,6 +39,13 @@ func activeDaemon() *device_entity.Device { } } +func activeDesktop() *device_entity.Device { + return &device_entity.Device{ + ID: 10, UserID: 7, Kind: device_entity.KindDesktop, + Fingerprint: "fp-desktop", Status: 1, + } +} + func TestIsDaemonOnline(t *testing.T) { ctx := context.Background() svc, mini, _ := newRelayForTest(t, fakeForwarder{}) @@ -155,26 +162,34 @@ func TestAttachClientDetachSignalsChannelCloseToDaemon(t *testing.T) { } } -// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃的 -// agentred 设备才能把自己登记成中转目标。没有这几条,一个 desktop 端的 device JWT -// 就能冒充计算节点占住这个账号+指纹的中继路由,或者拿别人账号下的 deviceID 登记。 -// 这些守卫此前一条测试都没有,整段删掉全绿。 -func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T) { +func TestDesktopCanRegisterAndBeResolvedWithinAccount(t *testing.T) { ctx := context.Background() + svc, _, devices := newRelayForTest(t, fakeForwarder{}) + desktop := activeDesktop() + devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil) + devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil) - t.Run("非 agentred 设备种类", func(t *testing.T) { - svc, _, _ := newRelayForTest(t, fakeForwarder{}) - // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。 - _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindDesktop) - require.ErrorIs(t, err, ErrDaemonForbidden) - }) + route, err := svc.PrepareDaemon(ctx, desktop.UserID, desktop.ID, device_entity.KindDesktop) + require.NoError(t, err) + require.Equal(t, Route{ + AccountID: desktop.UserID, Fingerprint: desktop.Fingerprint, InstanceID: "server-a", + }, route) + require.NoError(t, svc.RegisterDaemon(ctx, route)) + + resolved, err := svc.ConnectClient(ctx, desktop.UserID, desktop.Fingerprint) + require.NoError(t, err) + require.Equal(t, route, resolved) +} + +// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃且可寻址的 +// agentred 或 desktop 设备才能把自己登记成中转目标。kind、账号归属和撤销状态必须在 +// websocket upgrade 前复核,避免无权设备占住账号+指纹的中继路由。 +func TestPrepareDaemonAcceptsOnlyThisAccountsActiveAddressableDevices(t *testing.T) { + ctx := context.Background() - t.Run("kind=web 无法登记为可寻址目标(R3 守卫断言)", func(t *testing.T) { + t.Run("kind=web 无法登记为可寻址目标", func(t *testing.T) { svc, _, _ := newRelayForTest(t, fakeForwarder{}) - // 浏览器与桌面端一样是中继上的纯出站调用方:持有有效设备 JWT 只能连 - // /v1/relay/client,绝不能把自己登记成可被寻址的目标(也不能被派活)。 - // 这条由 PrepareDaemon 的既有 kind 判定执行——同样在读库之前就被拒, - // 连 Find 都不该发生(devices mock 没有 EXPECT)。 + // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。 _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindWeb) require.ErrorIs(t, err, ErrDaemonForbidden) }) @@ -188,17 +203,14 @@ func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T) require.ErrorIs(t, err, ErrDaemonForbidden) }) - t.Run("设备已被撤销", func(t *testing.T) { + t.Run("撤销一台 desktop 不影响其它设备", func(t *testing.T) { svc, _, devices := newRelayForTest(t, fakeForwarder{}) - revoked := activeDaemon() + revoked := activeDesktop() revoked.Status = 2 - devices.EXPECT().Find(gomock.Any(), int64(9)).Return(revoked, nil) - _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred) + devices.EXPECT().Find(gomock.Any(), revoked.ID).Return(revoked, nil) + _, err := svc.PrepareDaemon(ctx, revoked.UserID, revoked.ID, device_entity.KindDesktop) require.ErrorIs(t, err, ErrDaemonForbidden) - }) - t.Run("本账号活跃的 agentred 才放行", func(t *testing.T) { - svc, _, devices := newRelayForTest(t, fakeForwarder{}) devices.EXPECT().Find(gomock.Any(), int64(9)).Return(activeDaemon(), nil) route, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred) require.NoError(t, err) @@ -834,11 +846,22 @@ func TestDefaultIsNeverNilWithoutRegistration(t *testing.T) { func TestRelayClientFailuresAreDistinguishable(t *testing.T) { ctx := context.Background() - t.Run("daemon is not registered to this account", func(t *testing.T) { + t.Run("target belongs to another account", func(t *testing.T) { svc, _, devices := newRelayForTest(t, fakeForwarder{}) - devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-unknown").Return(nil, nil) + // FindByFingerprint 按账号限定查询;另一个账号里的同指纹对账号 7 不可见。 + devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-other-account").Return(nil, nil) + + _, err := svc.ConnectClient(ctx, 7, "fp-other-account") + require.ErrorIs(t, err, ErrDaemonNotFound) + }) + + t.Run("revoked desktop is not addressable", func(t *testing.T) { + svc, _, devices := newRelayForTest(t, fakeForwarder{}) + revoked := activeDesktop() + revoked.Status = 2 + devices.EXPECT().FindByFingerprint(gomock.Any(), revoked.UserID, revoked.Fingerprint).Return(revoked, nil) - _, err := svc.ConnectClient(ctx, 7, "fp-unknown") + _, err := svc.ConnectClient(ctx, revoked.UserID, revoked.Fingerprint) require.ErrorIs(t, err, ErrDaemonNotFound) }) diff --git a/internal/service/workspace_svc/workspace.go b/internal/service/workspace_svc/workspace.go index 5ce46f07..d0c1e8d5 100644 --- a/internal/service/workspace_svc/workspace.go +++ b/internal/service/workspace_svc/workspace.go @@ -89,11 +89,14 @@ type DeviceDetailView struct { // WebDispatchTier 是 R15 派发计划里执行目标链上的一档:从 web 给「某 Agent + // 某项目」派活时,这一档为什么能用 / 不能用。Availability 取值见常量。 +// Kind 是这一档指向的设备种类(device_entity.KindDesktop / KindAgentred)——R17 +// 发起前要按它如实说明 org/subagent/hook 在目标上是否可用。 type WebDispatchTier struct { Rank int DeviceID int64 DeviceName string BackendType string + Kind string Availability string // Current 标记按顺序取第一个可用的会落到这一档。至多一档为 true。 Current bool @@ -112,7 +115,10 @@ type WebDispatchChoice struct { DeviceID int64 DeviceName string BackendType string - Cwd string + // Kind 是选中目标设备种类(device_entity.KindDesktop / KindAgentred),R17 + // 发起前据此说明三个内置工具是否可用。 + Kind string + Cwd string } // WebDispatchPlan 是「从 web 给某 Agent + 某项目派活」的完整计划:有序逐档说明 + @@ -448,6 +454,31 @@ func (s *workspaceSvc) WebDispatchPlan( return v } + // 每个设备「配了哪些项目的路径」:agentred 的路径在同步组 project_location(跟着 + // 账号在桌面端之间流动,决策 7);桌面端的本机路径不流动(决策 6),只存在于上报组 + // device_local_paths(按上报设备分命名空间)。两者不能混用,按设备种类各取各的。 + desktopLocations := map[int64]map[string]string{} + locationsFor := func(dev *device_entity.Device) (map[string]string, error) { + if dev.Kind != device_entity.KindDesktop { + return locationsByFP[dev.Fingerprint], nil + } + if m, ok := desktopLocations[dev.ID]; ok { + return m, nil + } + rows, err := sync_repo.SyncLocalPath().ListByDevice(ctx, userID, dev.ID) + if err != nil { + return nil, err + } + m := make(map[string]string, len(rows)) + for _, row := range rows { + if row != nil && row.ProjectSyncID != "" { + m[row.ProjectSyncID] = row.Path + } + } + desktopLocations[dev.ID] = m + return m, nil + } + chains := buildAgentChains(rows) var chain *agentChain for i := range chains { @@ -462,6 +493,9 @@ func (s *workspaceSvc) WebDispatchPlan( plan := &WebDispatchPlan{AgentSyncID: agentSyncID} currentAssigned := false + // chosenCwd 是选中档(第一个可用)所选项目在那台机器上的绝对路径;case 块内 + // 变量出不了 switch,先摆在这里由选中档写入。 + var chosenCwd string for _, t := range chain.Targets { tier := WebDispatchTier{Rank: t.Rank, BackendType: t.BackendType} switch { @@ -478,17 +512,23 @@ func (s *workspaceSvc) WebDispatchPlan( } tier.DeviceID = dev.ID tier.DeviceName = dev.Name + tier.Kind = dev.Kind if !dev.IsActive() || !isOnline(t.Fingerprint) { tier.Availability = AvailabilityOffline break } + locations, lerr := locationsFor(dev) + if lerr != nil { + return nil, lerr + } if projectSyncID != "" { - if _, configured := locationsByFP[t.Fingerprint][projectSyncID]; !configured { + if _, configured := locations[projectSyncID]; !configured { tier.Availability = AvailabilityProjectPathMissing break } } tier.Availability = AvailabilityAvailable + chosenCwd = locations[projectSyncID] } if !currentAssigned && tier.Availability == AvailabilityAvailable { tier.Current = true @@ -498,17 +538,21 @@ func (s *workspaceSvc) WebDispatchPlan( DeviceID: tier.DeviceID, DeviceName: tier.DeviceName, BackendType: tier.BackendType, + Kind: tier.Kind, } // 选中档的 cwd:所选项目在这台机器上的绝对路径(见 WebDispatchChoice.Cwd // 注释,这是 R19 在主动派活场景下的唯一例外)。未选项目时留空。 - plan.Chosen.Cwd = locationsByFP[t.Fingerprint][projectSyncID] + plan.Chosen.Cwd = chosenCwd } plan.Tiers = append(plan.Tiers, tier) } // picker 用:选中的那一档机器上已配置的项目清单(按 sync_id 排序稳定)。 if plan.Chosen != nil { - configured := locationsByFP[plan.Chosen.DeviceFingerprint] + configured, cerr := locationsFor(deviceByFP[plan.Chosen.DeviceFingerprint]) + if cerr != nil { + return nil, cerr + } ids := make([]string, 0, len(configured)) for id := range configured { ids = append(ids, id) diff --git a/internal/service/workspace_svc/workspace_test.go b/internal/service/workspace_svc/workspace_test.go index b08b5219..11a6252c 100644 --- a/internal/service/workspace_svc/workspace_test.go +++ b/internal/service/workspace_svc/workspace_test.go @@ -426,3 +426,111 @@ func TestWebDispatchPlan_GivenUnknownAgent_ThenNotFound(t *testing.T) { _, err := svc.WebDispatchPlan(ctx, 7, "agent-missing", "") assert.Error(t, err) } + +// R17:浏览器把新对话派到一台桌面端上。桌面端在派发计划里是与 agentred 同地位的 +// 具名目标(决策 10)——第一档可用的是桌面端时,计划必须选中它并把「该桌面端自己 +// 上报的本机路径」作为 cwd 带出(决策 6:桌面端路径只存在于上报组 device_local_paths, +// 不在同步组 project_location 里),而不是误判成 project_path_missing。选中的档还要 +// 携带 kind=desktop,供发起前如实说明 org/subagent/hook 在桌面端上可用(R17)。 +func TestWebDispatchPlan_GivenDesktopFirstAvailable_ThenChoosesDesktopWithLocalPathAndKind(t *testing.T) { + ctx, mObj, mPath, mDev, svc := setupWorkspaceTest(t) + SetOnlineChecker(fakeOnlineChecker{online: map[string]bool{"fp-desk": true, "fp-agentred": false}}) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return([]*sync_entity.SyncObject{ + {Kind: sync_entity.KindAgent, SyncID: "agent-1", Payload: mustJSON(t, map[string]any{"name": "后端 Agent"})}, + {Kind: sync_entity.KindProject, SyncID: "proj-1", Payload: mustJSON(t, map[string]any{"name": "agentre-server"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-local", AgentredFingerprint: "", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-desk", AgentredFingerprint: "fp-desk", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-agentred", AgentredFingerprint: "fp-agentred", + Payload: mustJSON(t, map[string]any{"type": "codex"})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t1", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-local", "sort_order": 0})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t2", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-desk", "sort_order": 1})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t3", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-agentred", "sort_order": 2})}, + // 桌面端不写同步组 project_location;它的路径只在上报组 device_local_paths。 + }, nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return([]*device_entity.Device{ + {ID: 20, UserID: 7, Name: "书房小主机", Kind: device_entity.KindAgentred, Fingerprint: "fp-agentred", Status: 1}, + {ID: 30, UserID: 7, Name: "家里 Mac mini", Kind: device_entity.KindDesktop, Fingerprint: "fp-desk", Status: 1}, + }, nil) + mPath.EXPECT().ListByDevice(ctx, int64(7), int64(30)).Return([]*sync_entity.DeviceLocalPath{ + {UserID: 7, DeviceID: 30, ProjectSyncID: "proj-1", Path: "/Users/wyz/agentre-server"}, + }, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + require.NoError(t, err) + require.Len(t, plan.Tiers, 3) + + // 本机相对引用照旧跳过(R15d 删除后无相对槽位,这里是最老档位的遗留形态)。 + assert.Equal(t, AvailabilitySkippedForWeb, plan.Tiers[0].Availability) + + // 第一档可用的是桌面端:被选中,且不是 project_path_missing(路径来自它自己的上报)。 + assert.Equal(t, AvailabilityAvailable, plan.Tiers[1].Availability) + assert.Equal(t, "家里 Mac mini", plan.Tiers[1].DeviceName) + assert.Equal(t, device_entity.KindDesktop, plan.Tiers[1].Kind) + assert.True(t, plan.Tiers[1].Current) + + assert.Equal(t, AvailabilityOffline, plan.Tiers[2].Availability) + assert.Equal(t, "书房小主机", plan.Tiers[2].DeviceName) + + require.NotNil(t, plan.Chosen) + assert.Equal(t, "fp-desk", plan.Chosen.DeviceFingerprint) + assert.Equal(t, device_entity.KindDesktop, plan.Chosen.Kind) + assert.Equal(t, "家里 Mac mini", plan.Chosen.DeviceName) + assert.Equal(t, "/Users/wyz/agentre-server", plan.Chosen.Cwd) + + // picker 用:桌面端已配置的项目清单同样从它的上报组路径来。 + require.Len(t, plan.Projects, 1) + assert.Equal(t, "agentre-server", plan.Projects[0].Name) + assert.Equal(t, "proj-1", plan.Projects[0].SyncID) +} + +// R17 的不可用边界:桌面端在线但没配所选项目的路径 → 该档如实标 project_path_missing +// (逐档原因,不静默),继续按顺序取下一档配了路径的 agentred。 +func TestWebDispatchPlan_GivenDesktopMissingProjectPath_ThenSkipsToNextTargetWithPath(t *testing.T) { + ctx, mObj, mPath, mDev, svc := setupWorkspaceTest(t) + SetOnlineChecker(fakeOnlineChecker{online: map[string]bool{"fp-desk": true, "fp-agentred": true}}) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return([]*sync_entity.SyncObject{ + {Kind: sync_entity.KindAgent, SyncID: "agent-1", Payload: mustJSON(t, map[string]any{"name": "后端 Agent"})}, + {Kind: sync_entity.KindProject, SyncID: "proj-1", Payload: mustJSON(t, map[string]any{"name": "agentre-server"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-desk", AgentredFingerprint: "fp-desk", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-agentred", AgentredFingerprint: "fp-agentred", + Payload: mustJSON(t, map[string]any{"type": "codex"})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t1", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-desk", "sort_order": 0})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t2", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-agentred", "sort_order": 1})}, + {Kind: sync_entity.KindProjectLocation, SyncID: "loc-1", ProjectSyncID: "proj-1", + AgentredFingerprint: "fp-agentred", Payload: mustJSON(t, map[string]any{"path": "/srv/agentre-server"})}, + // 桌面端上报组里没有 proj-1 的路径(它的 ListByDevice 返回空)。 + }, nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return([]*device_entity.Device{ + {ID: 30, UserID: 7, Name: "家里 Mac mini", Kind: device_entity.KindDesktop, Fingerprint: "fp-desk", Status: 1}, + {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-agentred", Status: 1}, + }, nil) + mPath.EXPECT().ListByDevice(ctx, int64(7), int64(30)).Return(nil, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + require.NoError(t, err) + require.Len(t, plan.Tiers, 2) + + assert.Equal(t, AvailabilityProjectPathMissing, plan.Tiers[0].Availability) + assert.Equal(t, "家里 Mac mini", plan.Tiers[0].DeviceName) + assert.False(t, plan.Tiers[0].Current) + + assert.Equal(t, AvailabilityAvailable, plan.Tiers[1].Availability) + assert.True(t, plan.Tiers[1].Current) + assert.Equal(t, "公司 Mac mini", plan.Tiers[1].DeviceName) + assert.Equal(t, device_entity.KindAgentred, plan.Tiers[1].Kind) + + require.NotNil(t, plan.Chosen) + assert.Equal(t, "fp-agentred", plan.Chosen.DeviceFingerprint) + assert.Equal(t, device_entity.KindAgentred, plan.Chosen.Kind) + assert.Equal(t, "/srv/agentre-server", plan.Chosen.Cwd) +}