diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a36ec8d..514b39b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,12 +182,18 @@ jobs: -exec cp --parents '{}' ../redacted-failure-evidence/test-results/ \; ) fi + # 脱敏规则落到独立的 sed 脚本再 -f 引用:规则里同时含有 " 和 ',内联进 + # shell 单引号串就得逐个 '"'"' 转义,错一处整行都不再是合法 shell。这一步 + # 只在 failure() 下跑,于是这类错误要等到第一次真的失败才暴露——那时正好 + # 拿不到本该用来定位的证据。heredoc 定界符加引号,内容原样落盘,不再转义。 + cat > "$RUNNER_TEMP/redact.sed" <<'REDACT' + s#([^[:space:]"']+)@tcp\(([^)]+)\)/([^?[:space:]"']+)(\?[^[:space:]"']*)?#@tcp(\2)/\3#g + s#((redis-)?password[=:][[:space:]]*)[^,;[:space:]]+#\1#Ig + REDACT find e2e/runtime -name server.log -type f -print0 2>/dev/null | while IFS= read -r -d '' log; do run_id=$(basename "$(dirname "$log")") mkdir -p "e2e/redacted-failure-evidence/runtime/$run_id" - sed -E \ - -e 's#([^[:space:]"'"']+)@tcp\(([^)]+)\)/([^?[:space:]"'"']+)(\?[^[:space:]"'"']*)?#@tcp(\2)/\3#g' \ - -e 's#((redis-)?password[=:][[:space:]]*)[^,;[:space:]]+#\1#Ig' \ + sed -E -f "$RUNNER_TEMP/redact.sed" \ "$log" > "e2e/redacted-failure-evidence/runtime/$run_id/server.log" done diff --git a/cmd/server/main.go b/cmd/server/main.go index d76cd82c..e3b74dd5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "agentre-server/internal/repository/device_flow_repo" "agentre-server/internal/repository/device_repo" "agentre-server/internal/repository/device_token_repo" + "agentre-server/internal/repository/exec_order_repo" "agentre-server/internal/repository/follow_repo" "agentre-server/internal/repository/sync_repo" "agentre-server/internal/repository/user_identity_repo" @@ -71,6 +72,7 @@ func main() { sync_repo.RegisterSyncAvatar(sync_repo.NewSyncAvatar()) sync_repo.RegisterSyncLocalPath(sync_repo.NewSyncLocalPath()) follow_repo.RegisterFollow(follow_repo.NewFollow()) + exec_order_repo.RegisterExecOrder(exec_order_repo.NewExecOrder()) deps := &api.RouterDeps{Cfg: serverCfg, Signer: signer} diff --git a/frontend/src/__tests__/dispatch.test.ts b/frontend/src/__tests__/dispatch.test.ts index 3fdadbe7..74c0394d 100644 --- a/frontend/src/__tests__/dispatch.test.ts +++ b/frontend/src/__tests__/dispatch.test.ts @@ -14,10 +14,12 @@ import { RelayClient } from "@/lib/relayClient"; import { deriveTitle, dispatchNewConversation, + fetchDispatchPlan, newSessionId, pickFirstAvailable, type DispatchPlan, } from "@/lib/dispatch"; +import { ensureWebDevice, getFingerprint } from "@/lib/webDevice"; import { MethodRun } from "@/lib/wire"; vi.mock("@/lib/api", async (importOriginal) => { @@ -30,11 +32,18 @@ vi.mock("@/lib/relayClient", async (importOriginal) => { }); vi.mock("@/lib/webDevice", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, deviceDisplayName: () => "Chrome · macOS" }; + return { + ...actual, + deviceDisplayName: () => "Chrome · macOS", + ensureWebDevice: vi.fn(), + getFingerprint: vi.fn(), + }; }); const mockedApi = vi.mocked(api); const MockRelayClient = vi.mocked(RelayClient); +const mockEnsureWebDevice = vi.mocked(ensureWebDevice); +const mockGetFingerprint = vi.mocked(getFingerprint); const availablePlan: DispatchPlan = { agent_sync_id: "agent-1", @@ -340,3 +349,33 @@ describe("dispatchNewConversation(R15 派发 + R16 自关注)", () => { expect(client.close).toHaveBeenCalled(); }); }); + +// 派发计划按**调用方设备自己的**排列解析:浏览器取计划时带上自己的指纹, +// 服务端据此重排执行目标链再走「取第一个可用」,Chosen 与逐档原因随之改变。 +// 取不到设备身份时照常取计划(回落账号顺序,不报错)——派发不该因为一个偏好 +// 读不到就失败,也不该为了凑一个指纹先把这台浏览器注册成一台设备。 +describe("fetchDispatchPlan(按调用方设备的顺序解析)", () => { + it("带上这台浏览器的设备指纹", async () => { + mockGetFingerprint.mockReturnValue(sourceDevice.fingerprint); + mockedApi.mockResolvedValue(availablePlan); + + const plan = await fetchDispatchPlan("agent-1", "proj-1"); + + expect(mockedApi).toHaveBeenCalledWith( + "/v1/workspace/dispatch-target?agent_sync_id=agent-1&project_sync_id=proj-1&device_fingerprint=fp-web", + ); + expect(plan).toBe(availablePlan); + }); + + it("这台浏览器还没有设备身份时照常取计划,不带指纹、也不注册一台", async () => { + mockGetFingerprint.mockReturnValue(null); + mockedApi.mockResolvedValue(availablePlan); + + await fetchDispatchPlan("agent-1"); + + expect(mockedApi).toHaveBeenCalledWith( + "/v1/workspace/dispatch-target?agent_sync_id=agent-1", + ); + expect(mockEnsureWebDevice).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/exec-order.test.ts b/frontend/src/__tests__/exec-order.test.ts new file mode 100644 index 00000000..6f9df54c --- /dev/null +++ b/frontend/src/__tests__/exec-order.test.ts @@ -0,0 +1,181 @@ +/** + * 这个浏览器自己的派发顺序:排列的纯计算(决策 7 / 10 / 11),以及设备身份在哪 + * 一侧取得。 + * + * 排列以 backend sync_id 数组表达 —— rank 是位置性的(重排即变),device_id 也不 + * 唯一(一台机器可挂多个 backend)。skipped_for_web 的档在浏览器语境下永远不可 + * 派发,不参与排序:它钉在原位,可移动的档跨过它换位。 + * + * 身份取得是**读写分侧**的:读路径只认已经存在的身份,不因为「想读一份偏好」就 + * 凭空建出一台设备行——总览页是纯读页,打开它不该在用户的设备列表里多一台机器; + * 注册只发生在用户真排了一次序的写路径上。 + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { api } from "@/lib/api"; +import { + callerDeviceFingerprint, + isMovableTier, + reorderTargets, + saveExecTargetOrder, +} from "@/lib/execOrder"; +import { ensureWebDevice, markWebDeviceRevoked } from "@/lib/webDevice"; + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: vi.fn() }; +}); + +const mockedApi = vi.mocked(api); + +function makeJwt(expSeconds: number): string { + const header = window.btoa(JSON.stringify({ alg: "HS256" })); + const payload = window + .btoa(JSON.stringify({ sub: "1", exp: expSeconds })) + .replace(/=/g, ""); + return `${header}.${payload}.sig`; +} + +/** 走真实的 webDevice 注册一次,拿到这台浏览器的持久指纹。 */ +async function registerThisBrowser(): Promise { + mockedApi.mockResolvedValueOnce({ + access_token: makeJwt(Math.floor(Date.now() / 1000) + 900), + device_id: 7, + }); + const { fingerprint } = await ensureWebDevice(); + return fingerprint; +} + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + mockedApi.mockReset(); +}); + +const local = { backend_sync_id: "b-local", availability: "skipped_for_web" }; +const nuc = { backend_sync_id: "b-nuc", availability: "available" }; +const mac = { backend_sync_id: "b-mac", availability: "offline" }; +const pi = { backend_sync_id: "b-pi", availability: "unpaired" }; + +describe("isMovableTier", () => { + it("只有 skipped_for_web 不可移动;离线 / 未配对的档照样能排", () => { + expect(isMovableTier(local)).toBe(false); + expect(isMovableTier(nuc)).toBe(true); + expect(isMovableTier(mac)).toBe(true); + expect(isMovableTier(pi)).toBe(true); + }); +}); + +describe("reorderTargets", () => { + it("相邻两档换位,返回新的 backend sync_id 排列", () => { + expect(reorderTargets([nuc, mac, pi], 1, -1)).toEqual([ + "b-mac", + "b-nuc", + "b-pi", + ]); + expect(reorderTargets([nuc, mac, pi], 1, 1)).toEqual([ + "b-nuc", + "b-pi", + "b-mac", + ]); + }); + + it("skipped_for_web 档钉在原位:可移动的档跨过它换位(决策 11)", () => { + expect(reorderTargets([nuc, local, mac], 2, -1)).toEqual([ + "b-mac", + "b-local", + "b-nuc", + ]); + }); + + it("越界方向返回 null(第一个不能再上移、最后一个不能再下移)", () => { + expect(reorderTargets([local, nuc, mac], 1, -1)).toBeNull(); + expect(reorderTargets([local, nuc, mac], 2, 1)).toBeNull(); + }); + + it("对 skipped_for_web 档本身调用返回 null:它不可移动", () => { + expect(reorderTargets([local, nuc, mac], 0, 1)).toBeNull(); + }); + + it("没有 backend sync_id 的档既不可移动也不进排列(服务端只收非空标识)", () => { + const anonymous = { availability: "unpaired" }; + expect(isMovableTier(anonymous)).toBe(false); + expect(reorderTargets([nuc, anonymous, mac], 1, -1)).toBeNull(); + expect(reorderTargets([nuc, anonymous, mac], 2, -1)).toEqual([ + "b-mac", + "b-nuc", + ]); + }); +}); + +describe("callerDeviceFingerprint(读路径只认已有身份,不注册)", () => { + it("这台浏览器还没注册过时返回 null,且一个请求都不发", () => { + expect(callerDeviceFingerprint()).toBeNull(); + // 读一份偏好不得建出一台设备行:总览页是纯读页,打开它不该让用户的设备 + // 列表凭空多一台机器(也正是 e2e「真实空态」守着的那条断言)。 + expect(mockedApi).not.toHaveBeenCalled(); + }); + + it("注册过就返回持久化的指纹:关标签页丢的是 token,不是设备身份", async () => { + const fingerprint = await registerThisBrowser(); + // 标签页会话结束 = sessionStorage 里的设备 JWT 没了,但这台设备还在账号里, + // 它排的顺序也还在——重开一个标签页必须还按自己的顺序读。 + window.sessionStorage.clear(); + mockedApi.mockClear(); + + expect(callerDeviceFingerprint()).toBe(fingerprint); + expect(mockedApi).not.toHaveBeenCalled(); + }); + + it("已被解除授权时返回 null:不拿一个服务端必拒的身份去读", async () => { + await registerThisBrowser(); + markWebDeviceRevoked(); + mockedApi.mockClear(); + + expect(callerDeviceFingerprint()).toBeNull(); + expect(mockedApi).not.toHaveBeenCalled(); + }); +}); + +describe("saveExecTargetOrder(写路径才注册)", () => { + it("第一次排序时才注册这台浏览器,并按注册到的指纹提交", async () => { + mockedApi + .mockResolvedValueOnce({ + access_token: makeJwt(Math.floor(Date.now() / 1000) + 900), + device_id: 7, + }) + .mockResolvedValueOnce({}); + + const fingerprint = await saveExecTargetOrder({ + agentSyncId: "agent-1", + backendSyncIds: ["b-nuc", "b-mac"], + }); + + // 注册发生在这里而不是打开页面时:顺序的持有者是设备,用户真排了一次序, + // 这台浏览器才需要成为一台有身份的设备。 + expect(mockedApi.mock.calls[0][0]).toBe("/v1/oauth/device/register"); + expect(mockedApi.mock.calls[1][0]).toBe("/v1/workspace/exec-target-order"); + expect(JSON.parse(String(mockedApi.mock.calls[1][1]?.body))).toEqual({ + device_fingerprint: fingerprint, + agent_sync_id: "agent-1", + backend_sync_ids: ["b-nuc", "b-mac"], + }); + // 返回指纹,调用方据此按自己的顺序重读这条链,不必再猜一次身份。 + expect(callerDeviceFingerprint()).toBe(fingerprint); + }); + + it("已注册过就直接复用,不再注册一台", async () => { + const fingerprint = await registerThisBrowser(); + mockedApi.mockClear(); + mockedApi.mockResolvedValueOnce({}); + + expect( + await saveExecTargetOrder({ + agentSyncId: "agent-1", + backendSyncIds: ["b-nuc"], + }), + ).toBe(fingerprint); + expect(mockedApi).toHaveBeenCalledTimes(1); + expect(mockedApi.mock.calls[0][0]).toBe("/v1/workspace/exec-target-order"); + }); +}); diff --git a/frontend/src/__tests__/new-conversation.test.tsx b/frontend/src/__tests__/new-conversation.test.tsx index c66e8cc1..9d46a1a3 100644 --- a/frontend/src/__tests__/new-conversation.test.tsx +++ b/frontend/src/__tests__/new-conversation.test.tsx @@ -14,7 +14,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "@/lib/api"; import { dispatchNewConversation, fetchDispatchPlan } from "@/lib/dispatch"; -import { ensureWebDevice } from "@/lib/webDevice"; +import { ensureWebDevice, getFingerprint } from "@/lib/webDevice"; import { useRelayMachine } from "@/hooks/use-relay"; import i18n from "@/i18n"; import { ThemeProvider } from "@/lib/theme"; @@ -36,7 +36,7 @@ vi.mock("@/lib/dispatch", async (importOriginal) => { }); vi.mock("@/lib/webDevice", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, ensureWebDevice: vi.fn() }; + return { ...actual, ensureWebDevice: vi.fn(), getFingerprint: vi.fn() }; }); vi.mock("@/hooks/use-relay", () => ({ useRelayMachine: vi.fn() })); @@ -44,6 +44,7 @@ const mockedApi = vi.mocked(api); const mockFetchPlan = vi.mocked(fetchDispatchPlan); const mockDispatch = vi.mocked(dispatchNewConversation); const mockEnsureWebDevice = vi.mocked(ensureWebDevice); +const mockGetFingerprint = vi.mocked(getFingerprint); const mockUseRelay = vi.mocked(useRelayMachine); const agents = [ @@ -165,6 +166,9 @@ beforeEach(async () => { mockFetchPlan.mockReset(); mockDispatch.mockReset(); mockEnsureWebDevice.mockReset(); + // 默认这台浏览器还没有设备身份:读路径回落账号顺序,且不得为此注册一台。 + mockGetFingerprint.mockReset(); + mockGetFingerprint.mockReturnValue(null); mockUseRelay.mockReset(); mockUseRelay.mockReturnValue({ client: null, @@ -191,7 +195,7 @@ function renderDialog(onStarted: () => void = () => {}) { describe("新对话弹层:R15 派发计划逐档原因", () => { it("全部档不可用时逐档给出原因而不是静默失败(屏 24 的「现在选不了」)", async () => { mockedApi.mockImplementation(async (path) => { - if (path === "/v1/workspace/agents") return { agents }; + if (path.startsWith("/v1/workspace/agents")) return { agents }; throw new Error("unexpected: " + path); }); mockFetchPlan.mockResolvedValue(allUnavailablePlan); @@ -216,7 +220,7 @@ describe("新对话弹层:R15 派发计划逐档原因", () => { describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => { it("确认步先呈现三个工具不可用的说明(不是等调用失败),派发成功后立刻关注自己这条", async () => { mockedApi.mockImplementation(async (path, init) => { - if (path === "/v1/workspace/agents") return { agents }; + if (path.startsWith("/v1/workspace/agents")) return { agents }; if (path === "/v1/follows" && init?.method === "POST") return {}; throw new Error("unexpected: " + path); }); @@ -276,7 +280,7 @@ describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => { it("目标是桌面端时,确认步如实说明 org / subagent / hook 可用(不是沿用 agentred 的不可用文案)", async () => { mockedApi.mockImplementation(async (path) => { - if (path === "/v1/workspace/agents") return { agents }; + if (path.startsWith("/v1/workspace/agents")) return { agents }; throw new Error("unexpected: " + path); }); mockFetchPlan.mockImplementation(async (_agent, project) => @@ -310,7 +314,7 @@ describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => { it("不输入第一句时「开始」按钮是禁用的(发出第一条消息之前什么都不会跑)", async () => { mockedApi.mockImplementation(async (path) => { - if (path === "/v1/workspace/agents") return { agents }; + if (path.startsWith("/v1/workspace/agents")) return { agents }; throw new Error("unexpected: " + path); }); mockFetchPlan.mockImplementation(async (_agent, project) => @@ -333,7 +337,7 @@ describe("对话页空态主动作", () => { mockedApi.mockImplementation(async (path) => { if (path === "/v1/follows") return { items: [] }; if (path === "/v1/devices") return { devices: [] }; - if (path === "/v1/workspace/agents") return { agents }; + if (path.startsWith("/v1/workspace/agents")) return { agents }; throw new Error("unexpected: " + path); }); render( @@ -355,3 +359,43 @@ describe("对话页空态主动作", () => { expect(await screen.findByText("Pick an agent")).toBeTruthy(); }); }); + +describe("新对话弹层:「当前」标记必须和真派发目标是同一档", () => { + // 弹层原先取 /v1/workspace/agents 时不带 device_fingerprint,却照样渲染每个 + // Agent 的 current 档:服务端在没有指纹时按账号 sort_order 解析,于是弹层说的 + // 「当前」是**账号顺序的赢家**,而总览页(带指纹)说的是这台浏览器自己的赢家。 + // 同一账号同一时刻两处给出不同答案,其中一处必然与真实派发目标不符。 + it("取 Agent 清单时带上这台浏览器的设备指纹", async () => { + mockGetFingerprint.mockReturnValue("fp-this-browser"); + mockedApi.mockImplementation(async (path) => { + if (path.startsWith("/v1/workspace/agents")) return { agents }; + throw new Error("unexpected: " + path); + }); + renderDialog(); + + await screen.findByText("后端 Agent"); + expect( + mockedApi.mock.calls.some( + ([path]) => + path === "/v1/workspace/agents?device_fingerprint=fp-this-browser", + ), + ).toBe(true); + }); + + // 拿不到指纹(没排过序 / 已被解除授权)时不附加空参数,读路径照常回落账号顺序, + // 更不为了凑一个指纹把这台浏览器注册成设备 —— 打开一个弹层不该多出一台机器。 + it("拿不到指纹时不附加查询参数,也不注册设备", async () => { + mockEnsureWebDevice.mockRejectedValue(new Error("no device")); + mockedApi.mockImplementation(async (path) => { + if (path.startsWith("/v1/workspace/agents")) return { agents }; + throw new Error("unexpected: " + path); + }); + renderDialog(); + + await screen.findByText("后端 Agent"); + expect( + mockedApi.mock.calls.some(([path]) => path === "/v1/workspace/agents"), + ).toBe(true); + expect(mockEnsureWebDevice).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/overview.test.tsx b/frontend/src/__tests__/overview.test.tsx index 6326c0be..8cc569e2 100644 --- a/frontend/src/__tests__/overview.test.tsx +++ b/frontend/src/__tests__/overview.test.tsx @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "@/lib/api"; import { useRelayMachine, type UseRelayMachineResult } from "@/hooks/use-relay"; import { ThemeProvider } from "@/lib/theme"; +import { ensureWebDevice, getFingerprint } from "@/lib/webDevice"; import i18n from "@/i18n"; import Overview from "@/pages/Overview"; @@ -19,9 +20,15 @@ vi.mock("@/lib/api", async (importOriginal) => { return { ...actual, api: vi.fn() }; }); vi.mock("@/hooks/use-relay", () => ({ useRelayMachine: vi.fn() })); +vi.mock("@/lib/webDevice", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureWebDevice: vi.fn(), getFingerprint: vi.fn() }; +}); const mockedApi = vi.mocked(api); const mockUseRelay = vi.mocked(useRelayMachine); +const mockEnsureWebDevice = vi.mocked(ensureWebDevice); +const mockGetFingerprint = vi.mocked(getFingerprint); const fakeClient = { request: vi.fn(), @@ -64,6 +71,13 @@ beforeEach(async () => { // 默认中继未连:只有显式设置 connectedRelay 的用例才挂得到等待会话。 mockUseRelay.mockReturnValue(disconnectedRelay()); fakeClient.request.mockReset(); + // 默认这台浏览器还没有设备身份(没排过序 / 已被解除授权):链按账号顺序读。 + // 打开这一页不得注册出一台设备,所以默认让 ensureWebDevice 一被调用就失败 —— + // 谁在读路径上碰了它,用例当场就红。要排序的用例自己给一台。 + mockGetFingerprint.mockReset(); + mockGetFingerprint.mockReturnValue(null); + mockEnsureWebDevice.mockReset(); + mockEnsureWebDevice.mockRejectedValue(new Error("no web device")); }); describe("overview page", () => { @@ -849,3 +863,253 @@ describe("overview: 跨机器同号会话的 waiters 隔离", () => { expect(screen.getByText("View details")).toBeTruthy(); }); }); + +// ── 这个浏览器自己的派发顺序(决策 10 / 11) ───────────────────────────── +// Agent 卡上的执行目标链每一档给出上移 / 下移:一次移动即提交 +// POST /v1/workspace/exec-target-order(backend sync_id 排列 + 本机指纹), +// 随后重新拉取该 Agent 的链,「当前」标记当场跟着改变。 +// skipped_for_web 的档(浏览器语境下永不可派发)只读、不可移动,仍留在链里。 +describe("overview: 这个浏览器自己的派发顺序", () => { + const webDevice = { fingerprint: "fp-web", accessToken: "t", deviceId: 9 }; + const AGENTS_PATH = "/v1/workspace/agents?device_fingerprint=fp-web"; + const ORDER_PATH = "/v1/workspace/exec-target-order"; + + const tierById: Record> = { + "b-local": { + backend_sync_id: "b-local", + is_local_reference: true, + availability: "skipped_for_web", + }, + "b-nuc": { + backend_sync_id: "b-nuc", + is_local_reference: false, + device_id: 20, + device_name: "Study NUC", + backend_type: "claude_code", + availability: "available", + }, + "b-mac": { + backend_sync_id: "b-mac", + is_local_reference: false, + device_id: 21, + device_name: "Office Mac mini", + backend_type: "codex", + availability: "available", + }, + }; + + /** 一个 Agent 的链:order 是这台浏览器看到的次序,current 落在第一个可用档。 */ + function agentChain(order: string[]) { + const firstAvailable = order.find((id) => id !== "b-local"); + return { + sync_id: "ag-1", + name: "Backend Agent", + has_available_target: true, + exec_targets: order.map((id, i) => ({ + ...tierById[id], + rank: i + 1, + current: id === firstAvailable, + })), + }; + } + + function chipTexts(): string[] { + return screen + .getAllByTestId("exec-target") + .map((el) => el.textContent ?? ""); + } + + function chipOf(name: RegExp): HTMLElement { + const chip = screen + .getAllByText(name) + .map((el) => el.closest('[data-testid="exec-target"]')) + .find(Boolean); + if (!chip) throw new Error("no exec target chip for " + name); + return chip as HTMLElement; + } + + it("每一档给出上移/下移;skipped_for_web 档只读、不可移动", async () => { + mockGetFingerprint.mockReturnValue(webDevice.fingerprint); + mockedApi.mockImplementation(async (path) => { + if (path === AGENTS_PATH) + return { agents: [agentChain(["b-local", "b-nuc", "b-mac"])] }; + throw new Error("unexpected: " + path); + }); + + renderOverview(); + await screen.findByText("Backend Agent"); + + // 链按**这台浏览器**的顺序读:读端点带上本机指纹。 + expect(mockedApi).toHaveBeenCalledWith(AGENTS_PATH); + + // 两个可移动档各有一对上移/下移;本机档一个都没有(决策 11)。 + const ups = screen.getAllByRole("button", { name: "Move up" }); + const downs = screen.getAllByRole("button", { name: "Move down" }); + expect(ups.length).toBe(2); + expect(downs.length).toBe(2); + expect( + chipOf(/Skipped for web dispatch/).querySelectorAll("button").length, + ).toBe(0); + + // 端点方向禁用:第一个可移动档不能再上移,最后一个不能再下移。 + expect((ups[0] as HTMLButtonElement).disabled).toBe(true); + expect((downs[downs.length - 1] as HTMLButtonElement).disabled).toBe(true); + expect((ups[1] as HTMLButtonElement).disabled).toBe(false); + expect((downs[0] as HTMLButtonElement).disabled).toBe(false); + }); + + it("一次移动即提交并重新拉取该 Agent 的链,「当前」标记随之更新", async () => { + mockGetFingerprint.mockReturnValue(webDevice.fingerprint); + mockEnsureWebDevice.mockResolvedValue(webDevice); + let agentsCalls = 0; + mockedApi.mockImplementation(async (path) => { + if (path === AGENTS_PATH) { + agentsCalls += 1; + return { + agents: [ + agentChain( + agentsCalls === 1 + ? ["b-local", "b-nuc", "b-mac"] + : ["b-local", "b-mac", "b-nuc"], + ), + ], + }; + } + if (path === ORDER_PATH) return {}; + throw new Error("unexpected: " + path); + }); + + renderOverview(); + await screen.findByText(/Currently running on Study NUC/); + + fireEvent.click( + within(chipOf(/Office Mac mini/)).getByRole("button", { + name: "Move up", + }), + ); + + // 用户当场看到自己这一下改变了派发目标。 + await waitFor(() => + expect( + screen.getByText(/Currently running on Office Mac mini/), + ).toBeTruthy(), + ); + expect(chipTexts()[1]).toContain("Office Mac mini"); + // 「当前」标记落到链上被移上来的那一档,不只是那句摘要变了。 + expect(chipOf(/Office Mac mini/).getAttribute("data-current")).toBe("true"); + + // 提交的是 backend sync_id 排列 + 这台浏览器的指纹;本机档钉在原位。 + const post = mockedApi.mock.calls.find((c) => c[0] === ORDER_PATH); + expect(post?.[1]).toMatchObject({ method: "POST" }); + expect(JSON.parse(String(post?.[1]?.body))).toEqual({ + device_fingerprint: "fp-web", + agent_sync_id: "ag-1", + backend_sync_ids: ["b-local", "b-mac", "b-nuc"], + }); + // 提交之后重新拉了一次该 Agent 的链(不是只改本地状态)。 + expect(agentsCalls).toBe(2); + }); + + it("提交失败保持原顺序并就地说明,不静默", async () => { + mockGetFingerprint.mockReturnValue(webDevice.fingerprint); + mockEnsureWebDevice.mockResolvedValue(webDevice); + let agentsCalls = 0; + mockedApi.mockImplementation(async (path) => { + if (path === AGENTS_PATH) { + agentsCalls += 1; + return { agents: [agentChain(["b-local", "b-nuc", "b-mac"])] }; + } + if (path === ORDER_PATH) throw new Error("order save failed"); + throw new Error("unexpected: " + path); + }); + + renderOverview(); + await screen.findByText(/Currently running on Study NUC/); + + fireEvent.click( + within(chipOf(/Office Mac mini/)).getByRole("button", { + name: "Move up", + }), + ); + + // 就地说明(不是只往控制台打日志)。 + expect( + await screen.findByText( + "Could not apply the new order. Please try again.", + ), + ).toBeTruthy(); + // 顺序原封不动:「当前」仍在原来那一档,链的次序不变,也没有重新拉取。 + expect(screen.getByText(/Currently running on Study NUC/)).toBeTruthy(); + const texts = chipTexts(); + expect(texts[1]).toContain("Study NUC"); + expect(texts[2]).toContain("Office Mac mini"); + expect(agentsCalls).toBe(1); + }); + + // 打开总览页是纯读:它不得把这台浏览器注册成一台设备。否则用户只是看了一眼 + // 控制台,账号的设备列表里就凭空多一台机器 —— e2e 的「真实空态」正是踩在这上面 + // 挂的(新用户打开 /overview 后 /devices 不再是空的)。 + it("打开这一页不注册设备:按账号顺序读,排序控件照常给", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/workspace/agents") + return { agents: [agentChain(["b-local", "b-nuc", "b-mac"])] }; + throw new Error("unexpected: " + path); + }); + + renderOverview(); + await screen.findByText("Backend Agent"); + + // 没有设备身份 → 读端点不带指纹,服务端回落账号顺序。 + expect(mockedApi).toHaveBeenCalledWith("/v1/workspace/agents"); + // 而且一次注册都没发生:读一份偏好不该有建出一台设备行的副作用。 + expect(mockEnsureWebDevice).not.toHaveBeenCalled(); + // 控件照常给:注册在写路径,把控件按身份藏起来会让这台浏览器永远排不了序。 + expect(screen.getAllByRole("button", { name: "Move up" }).length).toBe(2); + }); + + it("第一次排序时才注册这台浏览器,并按注册到的指纹提交与重读", async () => { + mockEnsureWebDevice.mockResolvedValue(webDevice); + let accountOrderCalls = 0; + let deviceOrderCalls = 0; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/workspace/agents") { + accountOrderCalls += 1; + return { agents: [agentChain(["b-local", "b-nuc", "b-mac"])] }; + } + if (path === AGENTS_PATH) { + deviceOrderCalls += 1; + return { agents: [agentChain(["b-local", "b-mac", "b-nuc"])] }; + } + if (path === ORDER_PATH) return {}; + throw new Error("unexpected: " + path); + }); + + renderOverview(); + await screen.findByText(/Currently running on Study NUC/); + expect(mockEnsureWebDevice).not.toHaveBeenCalled(); + + fireEvent.click( + within(chipOf(/Office Mac mini/)).getByRole("button", { + name: "Move up", + }), + ); + + await waitFor(() => + expect( + screen.getByText(/Currently running on Office Mac mini/), + ).toBeTruthy(), + ); + // 排序这一下才把这台浏览器注册成一台设备(顺序的持有者是设备)。 + expect(mockEnsureWebDevice).toHaveBeenCalledTimes(1); + expect( + JSON.parse( + String( + mockedApi.mock.calls.find((c) => c[0] === ORDER_PATH)?.[1]?.body, + ), + ), + ).toMatchObject({ device_fingerprint: webDevice.fingerprint }); + // 重读走的是刚注册到的那台设备的顺序,不是再按账号顺序读一遍。 + expect(accountOrderCalls).toBe(1); + expect(deviceOrderCalls).toBe(1); + }); +}); diff --git a/frontend/src/components/session/NewConversationDialog.tsx b/frontend/src/components/session/NewConversationDialog.tsx index f89a31bb..8bbc5c50 100644 --- a/frontend/src/components/session/NewConversationDialog.tsx +++ b/frontend/src/components/session/NewConversationDialog.tsx @@ -27,6 +27,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { api, ApiError } from "@/lib/api"; +import { callerDeviceFingerprint } from "@/lib/execOrder"; import { dispatchNewConversation, fetchDispatchPlan, @@ -136,18 +137,28 @@ export default function NewConversationDialog({ const [dispatchError, setDispatchError] = useState(null); // 每次打开重新拉 Agent 清单(异步回调里 setState,不触发 cascading render)。 + // + // 必须带上这台浏览器的设备指纹:服务端没有指纹时按账号 sort_order 解析,标出的 + // 「当前」是账号顺序的赢家,而不是这台浏览器真正会派到的那一档——下面每个 Agent + // 都在渲染 current,不带指纹就是在断言一件与真实派发不符的事。取不到指纹时不附加 + // 空参数,读路径照常回落账号顺序、不报错(决策 9 读侧)。 useEffect(() => { if (!open) return; let alive = true; - api<{ agents: AgentItem[] }>("/v1/workspace/agents") - .then((d) => { + const fp = callerDeviceFingerprint(); + const qs = fp ? `?device_fingerprint=${encodeURIComponent(fp)}` : ""; + void (async () => { + try { + const d = await api<{ agents: AgentItem[] }>( + `/v1/workspace/agents${qs}`, + ); if (!alive) return; setAgents(d.agents); setLoaded(true); - }) - .catch((e: unknown) => { + } catch (e: unknown) { if (alive) setLoadError(e); - }); + } + })(); return () => { alive = false; }; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 414773b9..74c463a5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -72,6 +72,9 @@ "unpaired": "Not paired", "empty": "No agents yet.", "loadError": "Could not load your agents. Please try again.", + "moveUp": "Move up", + "moveDown": "Move down", + "reorderError": "Could not apply the new order. Please try again.", "tiles": { "online": "Devices online", "waiting": "Waiting on you", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e4539440..2174a399 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -72,6 +72,9 @@ "unpaired": "未配对", "empty": "还没有任何 Agent。", "loadError": "无法加载你的 Agent 列表,请重试。", + "moveUp": "上移", + "moveDown": "下移", + "reorderError": "新顺序没能生效,请重试。", "tiles": { "online": "设备在线", "waiting": "等你处理", diff --git a/frontend/src/lib/dispatch.ts b/frontend/src/lib/dispatch.ts index 2330677e..8b2f3ba1 100644 --- a/frontend/src/lib/dispatch.ts +++ b/frontend/src/lib/dispatch.ts @@ -16,6 +16,7 @@ * 这是 R19 红线在主动派活场景下的唯一例外(见 workspace_svc.WebDispatchChoice)。 */ import { api } from "@/lib/api"; +import { callerDeviceFingerprint } from "@/lib/execOrder"; import { RelayClient } from "@/lib/relayClient"; import { relayClientUrl } from "@/lib/relayUrl"; import { deviceDisplayName, type WebDevice } from "@/lib/webDevice"; @@ -56,13 +57,20 @@ export interface DispatchPlan { projects: { sync_id: string; name: string; configured: boolean }[]; } -/** 取某 Agent(+ 可选项目)的派发计划。project_sync_id 为空 = picker 阶段只看机器。 */ +/** 取某 Agent(+ 可选项目)的派发计划。project_sync_id 为空 = picker 阶段只看机器。 + * + * 计划按**调用方设备自己的**排列解析:带上这台浏览器的指纹,服务端据此重排执行 + * 目标链后再走「取第一个可用」,Chosen 与逐档原因因此跟着用户在总览页排的顺序 + * 走。这台浏览器还没有设备身份(没排过序)时不带,服务端回落账号顺序、不报错 —— + * 取计划不该为了一个偏好先把自己注册成一台设备。 */ export async function fetchDispatchPlan( agentSyncId: string, projectSyncId?: string, ): Promise { const qs = new URLSearchParams({ agent_sync_id: agentSyncId }); if (projectSyncId) qs.set("project_sync_id", projectSyncId); + const fingerprint = callerDeviceFingerprint(); + if (fingerprint) qs.set("device_fingerprint", fingerprint); return api(`/v1/workspace/dispatch-target?${qs.toString()}`); } diff --git a/frontend/src/lib/execOrder.ts b/frontend/src/lib/execOrder.ts new file mode 100644 index 00000000..90eb3d58 --- /dev/null +++ b/frontend/src/lib/execOrder.ts @@ -0,0 +1,113 @@ +/** + * 这个浏览器自己的派发顺序(决策 7 / 9 / 10 / 11)。 + * + * 顺序的持有者是**设备**(这台浏览器),被排序的是 backend:排列以 backend + * sync_id 数组表达,因为 rank 是位置性的(重排即变)、device_id 也不唯一 + * (一台机器可以挂多个 backend)。同一账号换一个浏览器就是另一台设备、另一份顺序。 + * + * 排列交给服务端解析,浏览器不自行挑档:服务端按调用方设备的排列重排执行目标链 + * 后再走既有的「跳过本机相对引用、取第一个可用」循环,因此「当前」标记与真实 + * 派发目标始终是同一档。 + */ +import { api } from "@/lib/api"; +import { + ensureWebDevice, + getFingerprint, + isWebDeviceRevoked, +} from "@/lib/webDevice"; + +/** 排序只需要一档上的这两个字段(ISP:不依赖整个 ExecTargetItem)。 */ +export interface OrderableTier { + /** 跨机稳定且逐档唯一的标识,是排列唯一能用的锚点。 */ + backend_sync_id?: string; + availability: string; +} + +/** + * 这一档能不能被这台浏览器排序。 + * + * skipped_for_web 是 device_id 为空的「本机」相对引用:在浏览器语境下它永远没有 + * 指代对象、永远不可派发,给它一个可排的位置纯是噪音(决策 11)——它照常显示, + * 只是不可移动。没有 backend sync_id 的档同理:排列以 sync_id 表达,无从指代的 + * 一档说不出「排第几」(服务端也只收非空标识)。 + */ +export function isMovableTier(tier: OrderableTier): boolean { + return tier.availability !== "skipped_for_web" && !!tier.backend_sync_id; +} + +/** + * 把第 index 档往前(-1)/ 往后(+1)挪一位,返回要提交的 backend sync_id 排列; + * 这一档不可移动、或它已经是第一个 / 最后一个可移动的档时返回 null —— 调用方据此 + * 禁用按钮,而不是发一次什么都不改的提交。 + * + * 换位只发生在两个**可移动**的档之间,不可移动的档钉在原位:用户挪一台机器时, + * 不会顺手把「本机」那一档也挪走。 + */ +export function reorderTargets( + tiers: OrderableTier[], + index: number, + direction: -1 | 1, +): string[] | null { + const movable = tiers.map((_, i) => i).filter((i) => isMovableTier(tiers[i])); + const pos = movable.indexOf(index); + if (pos < 0) return null; + const swapPos = pos + direction; + if (swapPos < 0 || swapPos >= movable.length) return null; + + const next = [...tiers]; + next[movable[pos]] = tiers[movable[swapPos]]; + next[movable[swapPos]] = tiers[movable[pos]]; + return next + .map((tier) => tier.backend_sync_id ?? "") + .filter((syncID) => syncID !== ""); +} + +/** + * 这台浏览器**已有的**设备指纹;还没注册过 / 已被解除授权时是 null。 + * + * 这是一次纯本地读,不发请求、更不注册:读一份偏好不该有建出一台设备行的副作用。 + * 总览页是纯读页,打开它就在账号的设备列表里多一台机器,是用户没要求过的后果 + * (e2e 的「真实空态」正是守这条)。注册留给写路径 —— 用户真排了一次序,这台 + * 浏览器才需要成为一台有身份的设备。 + * + * 读的是 localStorage 里的指纹而不是 sessionStorage 里的设备 JWT:token 随标签页 + * 会话失效,设备身份和它排的顺序不随之消失,重开标签页仍按自己的顺序读。 + * + * 读路径(取链、取派发计划)取不到就回落账号顺序、不报错 —— 派发不该因为一个偏好 + * 读不到就失败。指纹在服务端解析不到活跃设备时同样回落(决策 9 的读侧)。 + */ +export function callerDeviceFingerprint(): string | null { + // 已解除授权的指纹服务端必拒,带上去只会换来一次注定失败的解析。 + if (isWebDeviceRevoked()) return null; + return getFingerprint(); +} + +export interface SaveExecTargetOrderInput { + agentSyncId: string; + backendSyncIds: string[]; +} + +/** + * 保存「这台浏览器把某个 Agent 的执行目标排成这个次序」,返回落库用的设备指纹。 + * + * 顺序的持有者是设备,所以这里先确保这台浏览器**有**设备身份:第一次排序即注册 + * (已注册则直接复用,按指纹幂等、不新增设备行)。注册放在写路径而不是读路径, + * 是因为只有到这一步用户才真的表达了「我要这台浏览器有自己的顺序」。 + * + * 失败照常抛出,由界面就地说明 —— 顺序存不下时不得只在控制台留一行日志。已被 + * 解除授权时 ensureWebDevice 抛 WebDeviceRevokedError,同样交给界面表达。 + */ +export async function saveExecTargetOrder( + input: SaveExecTargetOrderInput, +): Promise { + const { fingerprint } = await ensureWebDevice(); + await api("/v1/workspace/exec-target-order", { + method: "POST", + body: JSON.stringify({ + device_fingerprint: fingerprint, + agent_sync_id: input.agentSyncId, + backend_sync_ids: input.backendSyncIds, + }), + }); + return fingerprint; +} diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 71b980a5..4dfb8654 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -5,6 +5,8 @@ import { AlertTriangle, ArrowRight, Bot, + ChevronDown, + ChevronUp, Clock3, Gauge, Monitor, @@ -23,6 +25,12 @@ import type { } from "@/components/session/DecisionPanel"; import { useRelayMachine } from "@/hooks/use-relay"; import { api, ApiError } from "@/lib/api"; +import { + callerDeviceFingerprint, + isMovableTier, + reorderTargets, + saveExecTargetOrder, +} from "@/lib/execOrder"; import type { RelayClient } from "@/lib/relayClient"; import { formatRelativeTime } from "@/lib/sessionView"; import { cn } from "@/lib/utils"; @@ -40,6 +48,8 @@ type Availability = "available" | "offline" | "unpaired" | "skipped_for_web"; interface ExecTargetItem { rank: number; + /** 这一档跨机稳定且逐档唯一的标识,排序用它表达(rank 是位置性的、device_id 不唯一)。 */ + backend_sync_id?: string; is_local_reference: boolean; device_id?: number; device_name?: string; @@ -308,11 +318,21 @@ function reasonKey(target: ExecTargetItem): string | null { } } +/** 一档上的排序控件;null = 这一档不可移动(或这台浏览器排不了序)。 */ +interface TargetMove { + canMoveUp: boolean; + canMoveDown: boolean; + busy: boolean; + onMove: (direction: -1 | 1) => void; +} + function TargetChip({ target, + move, t, }: { target: ExecTargetItem; + move: TargetMove | null; t: (key: string, opts?: Record) => string; }) { const reason = reasonKey(target); @@ -322,6 +342,7 @@ function TargetChip({ const suffix = target.backend_type ? ` · ${target.backend_type}` : ""; return ( )} + {move && ( + + + + + )} ); } +/** 这台浏览器对某个 Agent 的排序能力;null = 没有设备身份,顺序无处可存。 */ +interface AgentReorder { + busy: boolean; + failed: boolean; + onMove: (index: number, direction: -1 | 1) => void; +} + function AgentRow({ agent, + reorder, t, }: { agent: AgentItem; + // 排序控件恒在:顺序的持有者是设备,而这台浏览器的设备身份是到第一次排序时 + // 才注册的——按「有没有身份」把控件藏起来,它就永远等不到那一次排序。 + reorder: AgentReorder; t: (key: string, opts?: Record) => string; }) { const current = agent.exec_targets.find((tt) => tt.current); + // 只有一档可排时排序没有意义(换不到别处去),不给控件添噪音。 + const orderable = agent.exec_targets.filter(isMovableTier).length > 1; return (
@@ -394,10 +454,30 @@ function AgentRow({ className="size-3 shrink-0 text-muted-foreground" /> )} - + reorder.onMove(i, direction), + } + : null + } + t={t} + /> ))}
+ {reorder.failed && ( +

+ {t("overview.reorderError")} +

+ )}
); } @@ -415,26 +495,44 @@ export default function Overview() { ); const [clients, setClients] = useState>({}); const [waiters, setWaiters] = useState>({}); + // 同一时刻至多一个移动在飞(在飞期间所有排序按钮都禁用),因此这两个都是单槽位: + // 失败说明跟踪的是「最近一次移动」,下一次移动成功即收敛掉。 + const [reorderingAgent, setReorderingAgent] = useState(null); + const [reorderFailedAgent, setReorderFailedAgent] = useState( + null, + ); + + /** 取账号级 Agent 清单。带上本机指纹 = 链按**这台浏览器自己的**排列返回。 */ + const fetchAgents = useCallback(async (deviceFingerprint: string | null) => { + const qs = deviceFingerprint + ? `?device_fingerprint=${encodeURIComponent(deviceFingerprint)}` + : ""; + const got = await api<{ agents: AgentItem[] }>(`/v1/workspace/agents${qs}`); + return got.agents; + }, []); useEffect(() => { let alive = true; - api<{ agents: AgentItem[] }>("/v1/workspace/agents") - .then((got) => { + // 先拿这台浏览器**已有的**设备身份再读链:否则卡片上排第一、标着「当前」的 + // 那一档,会和真派发时选中的不是同一档。这是一次纯本地读,没排过序的浏览器 + // 拿到 null,按账号顺序读、不报错,也不会因为打开这一页就多出一台设备。 + void (async () => { + try { + const list = await fetchAgents(callerDeviceFingerprint()); if (alive) { - setAgents(got.agents); + setAgents(list); setLoadError(null); } - }) - .catch((e: unknown) => { + } catch (e: unknown) { if (alive) setLoadError(e ?? new Error("agent list load failed")); - }) - .finally(() => { + } finally { if (alive) setLoading(false); - }); + } + })(); return () => { alive = false; }; - }, []); + }, [fetchAgents]); // 设备 / 关注是锦上添花:各自取不到就保持空,统计卡走诚实空态,不阻塞整页。 useEffect(() => { @@ -600,6 +698,46 @@ export default function Overview() { .slice(0, 3); }, [waitingSessions, waiters, devicesByFp, agentsBySync, t]); + /** + * 把某个 Agent 的一档执行目标上移 / 下移:一次移动即提交,随后重新拉取这条链。 + * + * 顺序由服务端解析(排列是收敛的偏好,不是权威),所以本地不先乐观改一版 —— + * 「当前」标记与真实派发目标因此始终是同一档,用户当场看到自己这一下改到了哪。 + * 提交或重读失败时顺序原封不动,并在这张卡上就地说明(写入是按主键覆盖的, + * 重试无副作用)。 + */ + async function moveExecTarget( + agent: AgentItem, + index: number, + direction: -1 | 1, + ) { + if (reorderingAgent !== null) return; + const backendSyncIds = reorderTargets(agent.exec_targets, index, direction); + if (!backendSyncIds) return; + setReorderingAgent(agent.sync_id); + try { + // 顺序的持有者是设备,第一次排序时这台浏览器才注册成一台设备;拿回落库用 + // 的指纹接着按**自己的**顺序重读这条链,不再猜一次身份。 + const deviceFingerprint = await saveExecTargetOrder({ + agentSyncId: agent.sync_id, + backendSyncIds, + }); + const list = await fetchAgents(deviceFingerprint); + const fresh = list.find((a) => a.sync_id === agent.sync_id); + if (fresh) { + setAgents( + (prev) => + prev?.map((a) => (a.sync_id === fresh.sync_id ? fresh : a)) ?? prev, + ); + } + setReorderFailedAgent(null); + } catch { + setReorderFailedAgent(agent.sync_id); + } finally { + setReorderingAgent(null); + } + } + // Allow / Deny 走真实后端:提交后重查 pendingWaiters,已消失的 waiter 不再渲染。 async function submitToolDecision(card: ActionCard, allow: boolean) { const client = clients[card.fingerprint]; @@ -758,7 +896,19 @@ export default function Overview() { ) : (
{agents?.map((agent) => ( - + + void moveExecTarget(agent, index, direction), + }} + t={t} + /> ))}
)} diff --git a/internal/api/router.go b/internal/api/router.go index 141b0718..33110f9a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -82,6 +82,10 @@ func (r *RouterDeps) Router(ctx context.Context, root *mux.Router) error { workspaceCtr.DeviceDetail, // R15:从 web 给「某 Agent + 某项目」取派发计划(哪台 agentred、逐档原因)。 workspaceCtr.DispatchTarget, + // 每端自己的派发顺序:保存调用方这台设备对某个 Agent 的执行目标排列。 + // 本组鉴权的是用户不是设备,设备指纹只能由参数传入,因此 service 必须先按 + // (user_id, fingerprint) 解析出设备行,解析不到即拒绝(决策 9)。 + workspaceCtr.SetExecTargetOrder, // 关注名单(R12 后端 + R14):账号级,任一端(会话或设备 JWT)都可操作。 followCtr.Follow, followCtr.Unfollow, diff --git a/internal/api/workspace/guard_test.go b/internal/api/workspace/guard_test.go new file mode 100644 index 00000000..6dcfc307 --- /dev/null +++ b/internal/api/workspace/guard_test.go @@ -0,0 +1,117 @@ +package workspace_test + +import ( + "reflect" + "strings" + "testing" + + api "agentre-server/internal/api/workspace" +) + +// 硬不变量守卫(R19):workspace 的响应载荷不带项目路径 / CLIPath / EnvJSON。 +// 包注释把它写成了「这些字段在视图对象里根本不存在」——这里让那句话有牙齿: +// 一旦有人往任何一个响应结构体(含嵌套的档 / 项目 / Agent 条目)里加一个这类 +// 字段,下面的白名单比较会立刻红,而不是靠调用方自觉不填。 +// +// 唯一的例外是 DispatchChoiceItem.Cwd:主动派活时选中的那台机器上的工作目录必须 +// 带出去,否则 runtime.run 无处落脚(见 workspace.go 上它自己的注释)。它写在白 +// 名单里,因此是一处**显式**的例外,而不是一道悄悄敞开的口子。 +// +// 结构与隔壁 internal/api/follow/guard_test.go 同形,只是这里的可达面更大: +// 白名单按类型登记,再从三个响应根做一次反射深走,任何新出现的嵌套结构体没有 +// 登记就会红——白名单因此不会随着载荷长大而悄悄过期。 +func TestWorkspaceResponses_NeverCarryPathsOrSecrets_Guard(t *testing.T) { + // 字段名里出现这些词 = 载荷在往「机器上的东西」而不是「指向」的方向长。 + forbidden := []string{"path", "cli", "env", "token", "secret", "credential", "prompt"} + + allowed := map[string][]string{ + "ListAgentsResponse": {"Agents"}, + "AgentItem": { + "SyncID", "Name", "AvatarColor", "DepartmentName", + "ExecTargets", "HasAvailableTarget", + }, + "ExecTargetItem": { + "Rank", "BackendSyncID", "IsLocalReference", + "DeviceID", "DeviceName", "BackendType", "Availability", "Current", + }, + "DispatchTargetResponse": {"AgentSyncID", "Tiers", "Chosen", "Projects"}, + "DispatchTierItem": { + "Rank", "BackendSyncID", "DeviceID", "DeviceName", + "BackendType", "Kind", "Availability", "Current", + }, + // Cwd 是 R19 红线在主动派活场景下**唯一**的显式例外。 + "DispatchChoiceItem": { + "DeviceFingerprint", "DeviceID", "DeviceName", "BackendType", "Kind", "Cwd", + }, + "DeviceDetailResponse": {"DeviceID", "Kind", "RunnableAgents", "Projects"}, + "RunnableAgentItem": {"SyncID", "Name", "Rank"}, + "ProjectItem": {"SyncID", "Name", "Configured"}, + "SetExecTargetOrderResponse": {}, + } + + seen := map[string]bool{} + for _, root := range []any{ + api.ListAgentsResponse{}, + api.DispatchTargetResponse{}, + api.DeviceDetailResponse{}, + api.SetExecTargetOrderResponse{}, + } { + walkResponseStruct(t, reflect.TypeOf(root), allowed, forbidden, seen) + } + + // 反向核对:白名单里登记了却从任何响应根都走不到的类型 = 白名单已经过期, + // 它守着的其实是一个没人再返回的结构体。 + for name := range allowed { + if !seen[name] { + t.Errorf("%s 在白名单里但从任何响应根都不可达:白名单已过期", name) + } + } +} + +// walkResponseStruct 深走一个响应结构体:逐字段核对白名单与禁词,再对嵌套的 +// 结构体 / 指针 / 切片元素递归,直到所有可达类型都被核对过一遍。 +func walkResponseStruct( + t *testing.T, typ reflect.Type, + allowed map[string][]string, forbidden []string, seen map[string]bool, +) { + t.Helper() + for typ.Kind() == reflect.Pointer || typ.Kind() == reflect.Slice { + typ = typ.Elem() + } + if typ.Kind() != reflect.Struct || typ.PkgPath() != reflect.TypeOf(api.ListAgentsResponse{}).PkgPath() { + return + } + name := typ.Name() + if seen[name] { + return + } + seen[name] = true + + names, ok := allowed[name] + if !ok { + t.Errorf("%s 是响应载荷里的一个结构体,但没有登记在白名单里:"+ + "新载荷必须先说清楚自己允许带哪些字段", name) + return + } + allowedSet := make(map[string]bool, len(names)) + for _, n := range names { + allowedSet[n] = true + } + if typ.NumField() != len(allowedSet) { + t.Errorf("%s 字段数 %d != 白名单 %d(多出的字段 = 载荷里多了不该有的东西)", + name, typ.NumField(), len(allowedSet)) + } + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !allowedSet[field.Name] { + t.Errorf("%s.%s 不在白名单里:响应出现了 workspace 不该带的字段", name, field.Name) + } + lower := strings.ToLower(field.Name) + for _, f := range forbidden { + if strings.Contains(lower, f) { + t.Errorf("%s.%s 命中禁词 %q:R19 硬不变量被破坏", name, field.Name, f) + } + } + walkResponseStruct(t, field.Type, allowed, forbidden, seen) + } +} diff --git a/internal/api/workspace/workspace.go b/internal/api/workspace/workspace.go index b56b14ce..d47c4aff 100644 --- a/internal/api/workspace/workspace.go +++ b/internal/api/workspace/workspace.go @@ -8,12 +8,19 @@ import "github.com/cago-frame/cago/server/mux" // ---------- 总览页:账号级 Agent 清单 ---------- +// ListAgentsRequest 取总览页的账号级 Agent 清单。DeviceFingerprint 是**调用方自己** +// 的设备指纹(可空):这组端点鉴权的是用户不是设备,所以调用方要按哪台设备的顺序 +// 看这条链只能由参数说明;缺失或解析不到设备时按账号顺序返回,不报错(决策 9 读侧)。 type ListAgentsRequest struct { - mux.Meta `path:"/v1/workspace/agents" method:"GET"` + mux.Meta `path:"/v1/workspace/agents" method:"GET"` + DeviceFingerprint string `form:"device_fingerprint"` } type ExecTargetItem struct { - Rank int `json:"rank"` + Rank int `json:"rank"` + // BackendSyncID 是这一档跨机稳定且逐档唯一的标识,浏览器靠它表达排列:rank 是 + // 位置性的(重排后就变了),device_id 也不唯一(一台机器可挂多个 backend)。 + BackendSyncID string `json:"backend_sync_id,omitempty"` IsLocalReference bool `json:"is_local_reference"` DeviceID int64 `json:"device_id,omitempty"` DeviceName string `json:"device_name,omitempty"` @@ -45,13 +52,18 @@ type DispatchTargetRequest struct { mux.Meta `path:"/v1/workspace/dispatch-target" method:"GET"` AgentSyncID string `form:"agent_sync_id" binding:"required"` ProjectSyncID string `form:"project_sync_id"` + // DeviceFingerprint 是调用方自己的设备指纹(可空):带上时执行目标链先按这台 + // 设备自己的排列重排,再走「取第一个可用」的挑选,Chosen 与逐档原因随之改变。 + DeviceFingerprint string `form:"device_fingerprint"` } type DispatchTierItem struct { - Rank int `json:"rank"` - DeviceID int64 `json:"device_id,omitempty"` - DeviceName string `json:"device_name,omitempty"` - BackendType string `json:"backend_type,omitempty"` + Rank int `json:"rank"` + // BackendSyncID 见 ExecTargetItem.BackendSyncID:浏览器表达排列的唯一锚点。 + BackendSyncID string `json:"backend_sync_id,omitempty"` + 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"` @@ -109,3 +121,22 @@ type DeviceDetailResponse struct { RunnableAgents []RunnableAgentItem `json:"runnable_agents,omitempty"` Projects []ProjectItem `json:"projects"` } + +// ---------- 每端自己的派发顺序 ---------- + +// SetExecTargetOrderRequest 保存「调用方这台设备把某个 Agent 的执行目标排成这个 +// 次序」。账号取自鉴权上下文;设备指纹只能由参数传入(这组端点鉴权的是用户不是 +// 设备),服务端按 (user_id, fingerprint) 解析出设备行,解析不到即拒绝(决策 9)。 +// +// 排列用 backend sync_id 数组表达,不用 rank:rank 是位置性的,device_id 也不唯一。 +// 它是收敛的偏好而非权威——指向已删 backend 的项在解析时忽略,因此这里不校验它与 +// 当前执行目标集合是否一致。 +type SetExecTargetOrderRequest struct { + mux.Meta `path:"/v1/workspace/exec-target-order" method:"POST"` + DeviceFingerprint string `json:"device_fingerprint" binding:"required,min=8,max=128"` + AgentSyncID string `json:"agent_sync_id" binding:"required,max=255"` + // BackendSyncIDs 允许为空数组(等价于「这台设备不再有自己的顺序偏好」)。 + BackendSyncIDs []string `json:"backend_sync_ids" binding:"max=64,dive,required,max=255"` +} + +type SetExecTargetOrderResponse struct{} diff --git a/internal/bootstrap/cago.go b/internal/bootstrap/cago.go index e2885e56..db5d0e6a 100644 --- a/internal/bootstrap/cago.go +++ b/internal/bootstrap/cago.go @@ -238,6 +238,9 @@ func RegisterDefaults(cfg *ServerConfig, signer *jwt.Signer) { // 工作区多端同步 R18:撤销一台设备时,device_svc 用这个窄接口清掉它上报的 // 本机路径清单;sync_svc.Default() 结构性满足 device_svc.LocalPathPurger。 device_svc.SetLocalPathPurger(sync_svc.Default()) + // 每端自己排的执行目标顺序同理:撤销一台设备时它排的顺序一并消失; + // workspace_svc.Default() 结构性满足 device_svc.ExecTargetOrderPurger。 + device_svc.SetExecTargetOrderPurger(workspace_svc.Default()) hostname, err := os.Hostname() if err != nil { diff --git a/internal/controller/workspace_ctr/workspace.go b/internal/controller/workspace_ctr/workspace.go index 30a04a30..87cced7a 100644 --- a/internal/controller/workspace_ctr/workspace.go +++ b/internal/controller/workspace_ctr/workspace.go @@ -19,8 +19,9 @@ func callerUserID(c *gin.Context) int64 { return userID } -func (w *Workspace) ListAgents(c *gin.Context, _ *api.ListAgentsRequest) (*api.ListAgentsResponse, error) { - agents, err := workspace_svc.Default().ListAccountAgents(c.Request.Context(), callerUserID(c)) +func (w *Workspace) ListAgents(c *gin.Context, req *api.ListAgentsRequest) (*api.ListAgentsResponse, error) { + agents, err := workspace_svc.Default().ListAccountAgents( + c.Request.Context(), callerUserID(c), req.DeviceFingerprint) if err != nil { return nil, err } @@ -33,7 +34,7 @@ func (w *Workspace) ListAgents(c *gin.Context, _ *api.ListAgentsRequest) (*api.L } for _, t := range a.ExecTargets { item.ExecTargets = append(item.ExecTargets, api.ExecTargetItem{ - Rank: t.Rank, IsLocalReference: t.IsLocalReference, + Rank: t.Rank, BackendSyncID: t.BackendSyncID, IsLocalReference: t.IsLocalReference, DeviceID: t.DeviceID, DeviceName: t.DeviceName, BackendType: t.BackendType, Availability: t.Availability, Current: t.Current, }) @@ -45,7 +46,7 @@ func (w *Workspace) ListAgents(c *gin.Context, _ *api.ListAgentsRequest) (*api.L func (w *Workspace) DispatchTarget(c *gin.Context, req *api.DispatchTargetRequest) (*api.DispatchTargetResponse, error) { plan, err := workspace_svc.Default().WebDispatchPlan( - c.Request.Context(), callerUserID(c), req.AgentSyncID, req.ProjectSyncID) + c.Request.Context(), callerUserID(c), req.AgentSyncID, req.ProjectSyncID, req.DeviceFingerprint) if err != nil { return nil, err } @@ -56,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, + Rank: t.Rank, BackendSyncID: t.BackendSyncID, + DeviceID: t.DeviceID, DeviceName: t.DeviceName, BackendType: t.BackendType, Kind: t.Kind, Availability: t.Availability, Current: t.Current, }) @@ -96,3 +98,21 @@ func (w *Workspace) DeviceDetail(c *gin.Context, req *api.DeviceDetailRequest) ( } return resp, nil } + +// SetExecTargetOrder 保存调用方这台设备对某个 Agent 的执行目标排列。设备指纹的账号 +// 归属由 service 按 (user_id, fingerprint) 解析裁决,解析不到时它返回 NotFound, +// 这里原样透传——不吞成 500、也不当作成功。 +func (w *Workspace) SetExecTargetOrder( + c *gin.Context, req *api.SetExecTargetOrderRequest, +) (*api.SetExecTargetOrderResponse, error) { + if err := workspace_svc.Default().SetExecTargetOrder(c.Request.Context(), + workspace_svc.SetExecTargetOrderInput{ + UserID: callerUserID(c), + DeviceFingerprint: req.DeviceFingerprint, + AgentSyncID: req.AgentSyncID, + BackendSyncIDs: req.BackendSyncIDs, + }); err != nil { + return nil, err + } + return &api.SetExecTargetOrderResponse{}, nil +} diff --git a/internal/controller/workspace_ctr/workspace_test.go b/internal/controller/workspace_ctr/workspace_test.go index b3f7c6cc..7cf830c4 100644 --- a/internal/controller/workspace_ctr/workspace_test.go +++ b/internal/controller/workspace_ctr/workspace_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/cago-frame/cago/database/redis" @@ -35,20 +36,33 @@ type stubWorkspaceSvc struct { detailInputs []int64 detailErr error listCalled bool + listFingerprnt string dispatchPlan *workspace_svc.WebDispatchPlan dispatchInputs []string + orderInputs []workspace_svc.SetExecTargetOrderInput + orderErr error } -func (s *stubWorkspaceSvc) ListAccountAgents(context.Context, int64) ([]workspace_svc.AgentView, error) { +func (s *stubWorkspaceSvc) ListAccountAgents(_ context.Context, _ int64, deviceFingerprint string) ([]workspace_svc.AgentView, error) { s.listCalled = true + s.listFingerprnt = deviceFingerprint return s.agents, nil } -func (s *stubWorkspaceSvc) WebDispatchPlan(_ context.Context, _ int64, agentSyncID, projectSyncID string) (*workspace_svc.WebDispatchPlan, error) { - s.dispatchInputs = append(s.dispatchInputs, agentSyncID, projectSyncID) +func (s *stubWorkspaceSvc) WebDispatchPlan(_ context.Context, _ int64, agentSyncID, projectSyncID, deviceFingerprint string) (*workspace_svc.WebDispatchPlan, error) { + s.dispatchInputs = append(s.dispatchInputs, agentSyncID, projectSyncID, deviceFingerprint) return s.dispatchPlan, nil } +func (s *stubWorkspaceSvc) SetExecTargetOrder(_ context.Context, in workspace_svc.SetExecTargetOrderInput) error { + s.orderInputs = append(s.orderInputs, in) + return s.orderErr +} + +// PurgeDeviceExecTargetOrders 没有对应的端点(撤销设备走 device_svc.Revoke),这里 +// 只为满足接口——控制器不该有路可以调到它。 +func (s *stubWorkspaceSvc) PurgeDeviceExecTargetOrders(context.Context, int64) error { return nil } + func (s *stubWorkspaceSvc) DeviceDetail(_ context.Context, _ int64, deviceID int64) (*workspace_svc.DeviceDetailView, error) { s.detailInputs = append(s.detailInputs, deviceID) if s.detailErr != nil { @@ -81,9 +95,35 @@ func newWorkspaceTestServer(t *testing.T, stub *stubWorkspaceSvc) (*httptest.Ser func newSessionCookie(t *testing.T, userID int64) *http.Cookie { t.Helper() - sid, _, err := auth_svc.Default().StartSession(context.Background(), userID) + cookie, _ := newSessionCookieWithCSRF(t, userID) + return cookie +} + +// newSessionCookieWithCSRF 另外交出这次会话的 CSRF 令牌:凭 cookie 鉴权的写操作 +// 必须出示它(见 middleware.SessionOrDeviceAuth)。 +func newSessionCookieWithCSRF(t *testing.T, userID int64) (*http.Cookie, string) { + t.Helper() + sid, sess, err := auth_svc.Default().StartSession(context.Background(), userID) require.NoError(t, err) - return &http.Cookie{Name: testCookieName, Value: sid} + return &http.Cookie{Name: testCookieName, Value: sid}, sess.CSRFToken +} + +// postJSON 发一次带 JSON 正文的写请求;cookie 为空即模拟未登录。 +func postJSON(t *testing.T, url, cookie, csrf, body string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + require.NoError(t, err) + if cookie != "" { + req.AddCookie(&http.Cookie{Name: testCookieName, Value: cookie}) + } + if csrf != "" { + req.Header.Set("X-CSRF-Token", csrf) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp } func get(t *testing.T, url, cookie string) *http.Response { @@ -221,7 +261,7 @@ func TestDispatchTarget_WorksForBrowserSession_WithAgentAndProject(t *testing.T) resp := get(t, server.URL+"/v1/workspace/dispatch-target?agent_sync_id=agent-1&project_sync_id=proj-1", cookie.Value) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, []string{"agent-1", "proj-1"}, stub.dispatchInputs) + assert.Equal(t, []string{"agent-1", "proj-1", ""}, stub.dispatchInputs) var got struct { AgentSyncID string `json:"agent_sync_id"` @@ -285,3 +325,143 @@ func TestDeviceDetail_PropagatesNotFoundFromService(t *testing.T) { assert.Equal(t, code.DeviceNotFound, envelope.Code) assert.Empty(t, envelope.Data) } + +// ── 每端自己的派发顺序 ──────────────────────────────────────────────── + +// 这组端点鉴权的是**用户**不是设备,所以调用方自己的设备指纹只能由参数传入: +// query 里的 device_fingerprint 要原样转给 service,否则浏览器永远拿到账号顺序。 +// 逐档的 backend_sync_id 也要透传——浏览器只能靠它表达排列。 +func TestDispatchTarget_PassesCallerDeviceFingerprintAndCarriesBackendSyncID(t *testing.T) { + stub := &stubWorkspaceSvc{dispatchPlan: &workspace_svc.WebDispatchPlan{ + AgentSyncID: "agent-1", + Tiers: []workspace_svc.WebDispatchTier{ + {Rank: 1, BackendSyncID: "b-c", DeviceID: 22, DeviceName: "机器 C", + Availability: workspace_svc.AvailabilityAvailable, Current: true}, + {Rank: 2, BackendSyncID: "b-a", DeviceID: 20, DeviceName: "机器 A", + Availability: workspace_svc.AvailabilityOffline}, + }, + }} + server, _ := newWorkspaceTestServer(t, stub) + cookie := newSessionCookie(t, 7) + + resp := get(t, server.URL+"/v1/workspace/dispatch-target?agent_sync_id=agent-1&device_fingerprint=fp-web", cookie.Value) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, []string{"agent-1", "", "fp-web"}, stub.dispatchInputs) + + var got struct { + Tiers []struct { + BackendSyncID string `json:"backend_sync_id"` + Current bool `json:"current"` + } `json:"tiers"` + } + decodeEnvelope(t, resp, &got) + require.Len(t, got.Tiers, 2) + assert.Equal(t, "b-c", got.Tiers[0].BackendSyncID) + assert.True(t, got.Tiers[0].Current) + assert.Equal(t, "b-a", got.Tiers[1].BackendSyncID) +} + +// 总览页的卡片链同理:指纹转给 service,逐档带 backend_sync_id。 +func TestListAgents_PassesCallerDeviceFingerprintAndCarriesBackendSyncID(t *testing.T) { + stub := &stubWorkspaceSvc{agents: []workspace_svc.AgentView{{ + SyncID: "agent-1", Name: "后端 Agent", HasAvailableTarget: true, + ExecTargets: []workspace_svc.ExecTargetView{ + {Rank: 1, BackendSyncID: "b-c", DeviceName: "机器 C", + Availability: workspace_svc.AvailabilityAvailable, Current: true}, + }, + }}} + server, _ := newWorkspaceTestServer(t, stub) + cookie := newSessionCookie(t, 7) + + resp := get(t, server.URL+"/v1/workspace/agents?device_fingerprint=fp-web", cookie.Value) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "fp-web", stub.listFingerprnt) + + var got struct { + Agents []struct { + ExecTargets []struct { + BackendSyncID string `json:"backend_sync_id"` + } `json:"exec_targets"` + } `json:"agents"` + } + decodeEnvelope(t, resp, &got) + require.Len(t, got.Agents, 1) + require.Len(t, got.Agents[0].ExecTargets, 1) + assert.Equal(t, "b-c", got.Agents[0].ExecTargets[0].BackendSyncID) +} + +// 写端点:账号取自鉴权上下文,设备指纹 / Agent / 排列取自请求体,原样转给 service。 +func TestSetExecTargetOrder_PassesFingerprintAgentAndPermutation(t *testing.T) { + stub := &stubWorkspaceSvc{} + server, _ := newWorkspaceTestServer(t, stub) + cookie, csrf := newSessionCookieWithCSRF(t, 7) + + resp := postJSON(t, server.URL+"/v1/workspace/exec-target-order", cookie.Value, csrf, + `{"device_fingerprint":"fp-web-0001","agent_sync_id":"agent-1","backend_sync_ids":["b-c","b-a"]}`) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + require.Len(t, stub.orderInputs, 1) + assert.Equal(t, int64(7), stub.orderInputs[0].UserID) + assert.Equal(t, "fp-web-0001", stub.orderInputs[0].DeviceFingerprint) + assert.Equal(t, "agent-1", stub.orderInputs[0].AgentSyncID) + assert.Equal(t, []string{"b-c", "b-a"}, stub.orderInputs[0].BackendSyncIDs) +} + +// 决策 9:传入的设备指纹解析不到调用方账号下的设备时,service 拒绝,controller 原样 +// 透传——不吞成 500、更不当作成功。断言必须落到**业务码**上:只断言「不是 200」时, +// 把 NotFound 吞成 500 一样能过。 +func TestSetExecTargetOrder_PropagatesRejectionForForeignDevice(t *testing.T) { + stub := &stubWorkspaceSvc{ + orderErr: i18n.NewNotFoundError(context.Background(), code.DeviceNotFound), + } + server, _ := newWorkspaceTestServer(t, stub) + cookie, csrf := newSessionCookieWithCSRF(t, 7) + + resp := postJSON(t, server.URL+"/v1/workspace/exec-target-order", cookie.Value, csrf, + `{"device_fingerprint":"fp-someone-else","agent_sync_id":"agent-1","backend_sync_ids":["b-c"]}`) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var envelope struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + assert.Equal(t, code.DeviceNotFound, envelope.Code) +} + +// 未登录(无 cookie、无 device JWT)不得写任何人的顺序。 +func TestSetExecTargetOrder_RejectsUnauthenticated(t *testing.T) { + stub := &stubWorkspaceSvc{} + server, _ := newWorkspaceTestServer(t, stub) + + resp := postJSON(t, server.URL+"/v1/workspace/exec-target-order", "", "", + `{"device_fingerprint":"fp-web-0001","agent_sync_id":"agent-1","backend_sync_ids":["b-c"]}`) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Empty(t, stub.orderInputs) +} + +// 排列的长度上限挡在绑定层:order_json 是 text(64 KB),65 档 × 255 字符的排列 +// 已经越界。超限的请求必须被拒,且一步都不许走到 service——只验 happy path 的话, +// 把 max=64 写在 dive 之后(于是它变成「每个元素最长 64」)也照样绿。 +func TestSetExecTargetOrder_RejectsOversizedPermutation(t *testing.T) { + stub := &stubWorkspaceSvc{} + server, _ := newWorkspaceTestServer(t, stub) + cookie, csrf := newSessionCookieWithCSRF(t, 7) + + ids := make([]string, 65) + for i := range ids { + ids[i] = strings.Repeat("b", 200) + } + body, err := json.Marshal(map[string]any{ + "device_fingerprint": "fp-web-0001", "agent_sync_id": "agent-1", "backend_sync_ids": ids, + }) + require.NoError(t, err) + + resp := postJSON(t, server.URL+"/v1/workspace/exec-target-order", cookie.Value, csrf, string(body)) + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, stub.orderInputs, "越界的排列不得走到 service") +} diff --git a/internal/model/entity/exec_order_entity/exec_order.go b/internal/model/entity/exec_order_entity/exec_order.go new file mode 100644 index 00000000..dc8c2626 --- /dev/null +++ b/internal/model/entity/exec_order_entity/exec_order.go @@ -0,0 +1,56 @@ +// Package exec_order_entity 维护「每台设备自己的执行目标排列」实体。 +// +// 这里有两个「设备」轴,不可混同:**持有者**是排这份顺序的客户端(本轮只有 +// kind=web 的浏览器会写),**被排序的目标**是 OrderJSON 里的 backend sync_id +// (它们各自指向某台机器)。一行的含义是「某个浏览器,对某个 Agent,把执行目标 +// 排成这个次序」。 +// +// 排列是**偏好**不是权威:账号级的执行目标集合仍在同步组里,解析时以集合为准 +// ——排列里指向已不存在的 backend 的项忽略,集合里没被排列覆盖到的档按账号 +// sort_order 补到尾部。因此这里不做任何「排列必须与集合一致」的校验。 +package exec_order_entity + +import "encoding/json" + +// DeviceExecTargetOrder 是 device_exec_target_orders 的一行。主键是 +// (UserID, DeviceID, AgentSyncID) 复合键,没有自增 id,也没有 createtime—— +// 与同类的 sync_entity.DeviceLocalPath 一致。 +type DeviceExecTargetOrder struct { + UserID int64 `gorm:"column:user_id;type:bigint;not null;primaryKey"` + DeviceID int64 `gorm:"column:device_id;type:bigint;not null;primaryKey"` + // AgentSyncID 是被排序的 Agent 的同步标识,与 sync_objects.sync_id 逐字节可比。 + AgentSyncID string `gorm:"column:agent_sync_id;type:varchar(255);not null;primaryKey"` + // OrderJSON 是 backend sync_id 的有序 JSON 数组,整存整取(决策 13)。 + // 别直接读它,走 BackendSyncIDs / SetBackendSyncIDs。 + OrderJSON string `gorm:"column:order_json;type:text;not null"` + Updatetime int64 `gorm:"column:updatetime;type:bigint;not null;default:0"` +} + +func (*DeviceExecTargetOrder) TableName() string { return "device_exec_target_orders" } + +// BackendSyncIDs 解出排列。正文损坏、行为空或接收者为 nil 时返回空——排列是偏好, +// 读不出来就按「没有排列」处理(回落账号 sort_order),不该让派发计划失败。 +func (o *DeviceExecTargetOrder) BackendSyncIDs() []string { + if o == nil || o.OrderJSON == "" { + return nil + } + var ids []string + if err := json.Unmarshal([]byte(o.OrderJSON), &ids); err != nil { + return nil + } + return ids +} + +// SetBackendSyncIDs 把排列写进 OrderJSON。nil 存成 `[]` 而不是 `null`:读侧因此 +// 永远只面对「一个可能为空的数组」这一种形态。 +func (o *DeviceExecTargetOrder) SetBackendSyncIDs(ids []string) error { + if ids == nil { + ids = []string{} + } + raw, err := json.Marshal(ids) + if err != nil { + return err + } + o.OrderJSON = string(raw) + return nil +} diff --git a/internal/model/entity/exec_order_entity/exec_order_test.go b/internal/model/entity/exec_order_entity/exec_order_test.go new file mode 100644 index 00000000..d1f40cd5 --- /dev/null +++ b/internal/model/entity/exec_order_entity/exec_order_test.go @@ -0,0 +1,33 @@ +package exec_order_entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 排列整存整取:写进去什么次序,读回来就是什么次序。 +func TestBackendSyncIDs_RoundTrip(t *testing.T) { + o := &DeviceExecTargetOrder{} + require.NoError(t, o.SetBackendSyncIDs([]string{"backend-b", "backend-a"})) + assert.Equal(t, `["backend-b","backend-a"]`, o.OrderJSON) + assert.Equal(t, []string{"backend-b", "backend-a"}, o.BackendSyncIDs()) +} + +// 空排列存成 [],不存 null:读侧因此永远拿到「一个没有覆盖任何档的排列」这一种 +// 形态,而不是多一处 nil 与 "null" 的分支。 +func TestSetBackendSyncIDs_GivenNil_ThenStoresEmptyArray(t *testing.T) { + o := &DeviceExecTargetOrder{} + require.NoError(t, o.SetBackendSyncIDs(nil)) + assert.Equal(t, `[]`, o.OrderJSON) + assert.Empty(t, o.BackendSyncIDs()) +} + +// 正文损坏(或行是空的)按「没有排列」处理:排列是偏好不是权威,读不出来就回落 +// 账号 sort_order,不该让整个派发计划失败。 +func TestBackendSyncIDs_GivenBrokenJSON_ThenEmpty(t *testing.T) { + assert.Empty(t, (&DeviceExecTargetOrder{OrderJSON: "not json"}).BackendSyncIDs()) + assert.Empty(t, (&DeviceExecTargetOrder{}).BackendSyncIDs()) + assert.Empty(t, (*DeviceExecTargetOrder)(nil).BackendSyncIDs()) +} diff --git a/internal/pkg/code/code_test.go b/internal/pkg/code/code_test.go new file mode 100644 index 00000000..243782ce --- /dev/null +++ b/internal/pkg/code/code_test.go @@ -0,0 +1,37 @@ +package code + +import ( + "context" + "testing" + + "github.com/cago-frame/cago/pkg/i18n" + "github.com/stretchr/testify/assert" +) + +// 错误码必须解析得出文案。 +// +// 这里守的是**注册标签与解析标签必须对得上**这件事,而不是「文案写全了没」: +// cago 的 i18n.T 在 ctx 里没有语言时回落到 i18n.DefaultLang,而 DefaultLang 是 +// 全小写的 "zh-cn"。本仓库没有任何设置语言的中间件(grep WithLanguage 无命中), +// 所以每一次调用都走那条回落分支。一旦语言包注册在别的标签下,langs[DefaultLang] +// 就是 nil map,langMap[code] 取回零值,客户端拿到的是 {"code":30300,"msg":""} —— +// 有码无文案,而且是**所有**错误码、**所有**客户端。 +func TestT_GivenNoLanguageInContext_ThenEveryRegisteredCodeResolves(t *testing.T) { + ctx := context.Background() + + // 全量遍历:漏一个标签就会让整批文案一起消失,抽查看不出来。 + for c, want := range zhCN { + got := i18n.T(ctx, c) + assert.NotEmptyf(t, got, + "code %d 解析不出文案(应为 %q)——语言包注册的标签与 i18n.DefaultLang(%q) 对不上", + c, want, i18n.DefaultLang) + } +} + +// 显式带上语言时按该语言解析,中英各自成立。 +func TestT_GivenExplicitLanguage_ThenResolvesInThatLanguage(t *testing.T) { + assert.Equal(t, zhCN[DeviceNotFound], + i18n.T(i18n.WithLanguage(context.Background(), "zh-CN"), DeviceNotFound)) + assert.Equal(t, en[DeviceNotFound], + i18n.T(i18n.WithLanguage(context.Background(), "en"), DeviceNotFound)) +} diff --git a/internal/pkg/code/zh_cn.go b/internal/pkg/code/zh_cn.go index f400d24f..8937a1f7 100644 --- a/internal/pkg/code/zh_cn.go +++ b/internal/pkg/code/zh_cn.go @@ -2,8 +2,15 @@ package code import "github.com/cago-frame/cago/pkg/i18n" +// 中文包同时注册在 "zh-CN" 与 i18n.DefaultLang 两个标签下。 +// +// 后者不是冗余:cago 的 i18n.T 在 ctx 没有语言时回落到 i18n.DefaultLang,而那个 +// 常量是全小写的 "zh-cn";本服务没有设置语言的中间件,于是**每一次**错误构造都走 +// 那条回落分支。只注册 "zh-CN" 时 langs["zh-cn"] 是 nil map,取回的是零值空串, +// 客户端拿到 {"code":…,"msg":""}——有码无文案。两个标签指向同一份 map,没有副本。 func init() { i18n.Register("zh-CN", zhCN) + i18n.Register(i18n.DefaultLang, zhCN) } var zhCN = map[int]string{ diff --git a/internal/repository/exec_order_repo/exec_order.go b/internal/repository/exec_order_repo/exec_order.go new file mode 100644 index 00000000..cbc2cc5b --- /dev/null +++ b/internal/repository/exec_order_repo/exec_order.go @@ -0,0 +1,80 @@ +// Package exec_order_repo 是「每台设备自己的执行目标排列」的数据访问层。 +package exec_order_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "gorm.io/gorm/clause" + + "agentre-server/internal/model/entity/exec_order_entity" +) + +//go:generate mockgen -source exec_order.go -destination mock_exec_order_repo/mock_exec_order.go + +type ExecOrderRepo interface { + // Find 取某台设备对某个 Agent 的排列;没有这一行时返回 (nil, nil)——「这台设备 + // 还没排过序」是常态,不是错误。 + Find(ctx context.Context, userID, deviceID int64, agentSyncID string) (*exec_order_entity.DeviceExecTargetOrder, error) + // ListByDevice 一次取某台设备对全部 Agent 的排列,供总览页一屏渲染多个 Agent + // 卡片时不必按 Agent 逐条查库。 + ListByDevice(ctx context.Context, userID, deviceID int64) ([]*exec_order_entity.DeviceExecTargetOrder, error) + // Save 整体替换一条排列(决策 13:排列永远被整体读写)。 + Save(ctx context.Context, o *exec_order_entity.DeviceExecTargetOrder) error + // DeleteByDevice 删除某台设备名下全部排列:设备被解除授权 / 删除时,它的顺序 + // 一并消失。device_id 是全局自增主键、天然只属于一个账号,不需要再传 user_id + // 校验归属(与 sync_repo.SyncLocalPath().DeleteByDevice 同一约定)。 + DeleteByDevice(ctx context.Context, deviceID int64) error +} + +var defaultRepo ExecOrderRepo + +func ExecOrder() ExecOrderRepo { return defaultRepo } +func RegisterExecOrder(i ExecOrderRepo) { defaultRepo = i } +func NewExecOrder() ExecOrderRepo { return &repo{} } + +type repo struct{} + +func (r *repo) Find( + ctx context.Context, userID, deviceID int64, agentSyncID string, +) (*exec_order_entity.DeviceExecTargetOrder, error) { + ret := &exec_order_entity.DeviceExecTargetOrder{} + err := db.Ctx(ctx).Where("user_id=? AND device_id=? AND agent_sync_id=?", + userID, deviceID, agentSyncID).First(ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return ret, nil +} + +func (r *repo) ListByDevice( + ctx context.Context, userID, deviceID int64, +) ([]*exec_order_entity.DeviceExecTargetOrder, error) { + var out []*exec_order_entity.DeviceExecTargetOrder + if err := db.Ctx(ctx).Where("user_id=? AND device_id=?", userID, deviceID). + Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// Save 是一条语句的 upsert:同一 (user_id, device_id, agent_sync_id) 再排一次要覆盖 +// 旧排列,由数据库原子裁决。先查再写会在两个标签页同时提交时双双走到 INSERT, +// 竞败方撞主键拿到一个约束错误。表上只有主键这一个唯一键,所以 ON DUPLICATE KEY +// 命中的必然是它。 +func (r *repo) Save(ctx context.Context, o *exec_order_entity.DeviceExecTargetOrder) error { + return db.Ctx(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, {Name: "device_id"}, {Name: "agent_sync_id"}, + }, + DoUpdates: clause.AssignmentColumns([]string{"order_json", "updatetime"}), + }).Create(o).Error +} + +func (r *repo) DeleteByDevice(ctx context.Context, deviceID int64) error { + return db.Ctx(ctx).Where("device_id=?", deviceID). + Delete(&exec_order_entity.DeviceExecTargetOrder{}).Error +} diff --git a/internal/repository/exec_order_repo/exec_order_test.go b/internal/repository/exec_order_repo/exec_order_test.go new file mode 100644 index 00000000..9081ffa9 --- /dev/null +++ b/internal/repository/exec_order_repo/exec_order_test.go @@ -0,0 +1,107 @@ +package exec_order_repo + +import ( + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "agentre-server/internal/model/entity/exec_order_entity" + hubtest "agentre-server/internal/testutils" +) + +// 排列按 (user_id, device_id, agent_sync_id) 取:三段缺一不可。少绑 user_id 会让 +// 一台设备读到别人账号的排列,少绑 agent_sync_id 会把另一个 Agent 的排列拿来用—— +// 只验 SQL 文本看不出来,所以绑定值一并钉住。 +func TestFind_GivenCompositeKey_ThenBindsAllThreeColumns(t *testing.T) { + ctx, _, mock := hubtest.Database(t) + r := NewExecOrder() + + mock.ExpectQuery(regexp.QuoteMeta( + "FROM `device_exec_target_orders` WHERE user_id=? AND device_id=? AND agent_sync_id=?", + )).WithArgs(int64(7), int64(31), "agent-1", 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "agent_sync_id", "order_json", "updatetime"}). + AddRow(7, 31, "agent-1", `["backend-b","backend-a"]`, 1000)) + + got, err := r.Find(ctx, 7, 31, "agent-1") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, []string{"backend-b", "backend-a"}, got.BackendSyncIDs()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// 没有行 = 这台设备对这个 Agent 没有自己的顺序,不是错误:调用方据此回落账号 +// sort_order。返回 (nil, nil) 而不是 gorm.ErrRecordNotFound,让「没有排列」这个 +// 常态不必在每个调用点被当成异常拆一次。 +func TestFind_GivenNoRow_ThenNilWithoutError(t *testing.T) { + ctx, _, mock := hubtest.Database(t) + r := NewExecOrder() + + mock.ExpectQuery(regexp.QuoteMeta("FROM `device_exec_target_orders`")). + WithArgs(int64(7), int64(31), "agent-1", 1). + WillReturnRows(sqlmock.NewRows([]string{"user_id"})) + + got, err := r.Find(ctx, 7, 31, "agent-1") + require.NoError(t, err) + assert.Nil(t, got) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// Save 是一条语句的 upsert:排列永远被整体替换(决策 13),同一 (user_id, device_id, +// agent_sync_id) 再排一次要覆盖旧排列而不是撞主键报错。先查再插会在两个标签页同时 +// 提交时双双走到 INSERT,竞败方拿到一个主键冲突错误。 +func TestSave_GivenExistingKey_ThenSingleStatementUpsert(t *testing.T) { + ctx, _, mock := hubtest.Database(t) + r := NewExecOrder() + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("ON DUPLICATE KEY UPDATE")). + WithArgs(int64(7), int64(31), "agent-1", `["backend-b","backend-a"]`, int64(2000)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + o := &exec_order_entity.DeviceExecTargetOrder{ + UserID: 7, DeviceID: 31, AgentSyncID: "agent-1", Updatetime: 2000, + } + require.NoError(t, o.SetBackendSyncIDs([]string{"backend-b", "backend-a"})) + require.NoError(t, r.Save(ctx, o)) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// ListByDevice 一次取这台设备对全部 Agent 的排列,供总览页一屏渲染多个 Agent 卡片时 +// 不必按 Agent 逐条查库。同样按 (user_id, device_id) 双段过滤。 +func TestListByDevice_GivenUserAndDevice_ThenScopedToBoth(t *testing.T) { + ctx, _, mock := hubtest.Database(t) + r := NewExecOrder() + + mock.ExpectQuery(regexp.QuoteMeta( + "FROM `device_exec_target_orders` WHERE user_id=? AND device_id=?", + )).WithArgs(int64(7), int64(31)). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "device_id", "agent_sync_id", "order_json", "updatetime"}). + AddRow(7, 31, "agent-1", `["backend-b"]`, 1000). + AddRow(7, 31, "agent-2", `["backend-c"]`, 1000)) + + got, err := r.ListByDevice(ctx, 7, 31) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "agent-1", got[0].AgentSyncID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// 设备被解除授权 / 删除时,它的排列一并清除:顺序属于那台设备,设备没了它就没有 +// 指代对象。device_id 是全局自增主键、天然只属于一个账号,不需要再传 user_id 校验 +// 归属(与 sync_repo.SyncLocalPath().DeleteByDevice 同一约定)。 +func TestDeleteByDevice_GivenDeviceID_ThenDeletesAllItsOrders(t *testing.T) { + ctx, _, mock := hubtest.Database(t) + r := NewExecOrder() + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta("DELETE FROM `device_exec_target_orders` WHERE device_id=?")). + WithArgs(int64(31)).WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + + require.NoError(t, r.DeleteByDevice(ctx, 31)) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/internal/repository/exec_order_repo/mock_exec_order_repo/mock_exec_order.go b/internal/repository/exec_order_repo/mock_exec_order_repo/mock_exec_order.go new file mode 100644 index 00000000..9c18ab15 --- /dev/null +++ b/internal/repository/exec_order_repo/mock_exec_order_repo/mock_exec_order.go @@ -0,0 +1,100 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: exec_order.go +// +// Generated by this command: +// +// mockgen -source exec_order.go -destination mock_exec_order_repo/mock_exec_order.go +// + +// Package mock_exec_order_repo is a generated GoMock package. +package mock_exec_order_repo + +import ( + exec_order_entity "agentre-server/internal/model/entity/exec_order_entity" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockExecOrderRepo is a mock of ExecOrderRepo interface. +type MockExecOrderRepo struct { + ctrl *gomock.Controller + recorder *MockExecOrderRepoMockRecorder + isgomock struct{} +} + +// MockExecOrderRepoMockRecorder is the mock recorder for MockExecOrderRepo. +type MockExecOrderRepoMockRecorder struct { + mock *MockExecOrderRepo +} + +// NewMockExecOrderRepo creates a new mock instance. +func NewMockExecOrderRepo(ctrl *gomock.Controller) *MockExecOrderRepo { + mock := &MockExecOrderRepo{ctrl: ctrl} + mock.recorder = &MockExecOrderRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExecOrderRepo) EXPECT() *MockExecOrderRepoMockRecorder { + return m.recorder +} + +// DeleteByDevice mocks base method. +func (m *MockExecOrderRepo) DeleteByDevice(ctx context.Context, deviceID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByDevice", ctx, deviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByDevice indicates an expected call of DeleteByDevice. +func (mr *MockExecOrderRepoMockRecorder) DeleteByDevice(ctx, deviceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByDevice", reflect.TypeOf((*MockExecOrderRepo)(nil).DeleteByDevice), ctx, deviceID) +} + +// Find mocks base method. +func (m *MockExecOrderRepo) Find(ctx context.Context, userID, deviceID int64, agentSyncID string) (*exec_order_entity.DeviceExecTargetOrder, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Find", ctx, userID, deviceID, agentSyncID) + ret0, _ := ret[0].(*exec_order_entity.DeviceExecTargetOrder) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Find indicates an expected call of Find. +func (mr *MockExecOrderRepoMockRecorder) Find(ctx, userID, deviceID, agentSyncID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockExecOrderRepo)(nil).Find), ctx, userID, deviceID, agentSyncID) +} + +// ListByDevice mocks base method. +func (m *MockExecOrderRepo) ListByDevice(ctx context.Context, userID, deviceID int64) ([]*exec_order_entity.DeviceExecTargetOrder, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByDevice", ctx, userID, deviceID) + ret0, _ := ret[0].([]*exec_order_entity.DeviceExecTargetOrder) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListByDevice indicates an expected call of ListByDevice. +func (mr *MockExecOrderRepoMockRecorder) ListByDevice(ctx, userID, deviceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByDevice", reflect.TypeOf((*MockExecOrderRepo)(nil).ListByDevice), ctx, userID, deviceID) +} + +// Save mocks base method. +func (m *MockExecOrderRepo) Save(ctx context.Context, o *exec_order_entity.DeviceExecTargetOrder) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", ctx, o) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save. +func (mr *MockExecOrderRepoMockRecorder) Save(ctx, o any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockExecOrderRepo)(nil).Save), ctx, o) +} diff --git a/internal/service/device_svc/device.go b/internal/service/device_svc/device.go index 8f802eee..e84c6ba6 100644 --- a/internal/service/device_svc/device.go +++ b/internal/service/device_svc/device.go @@ -69,6 +69,32 @@ type noopLocalPathPurger struct{} func (noopLocalPathPurger) PurgeDeviceLocalPaths(context.Context, int64) error { return nil } +// ExecTargetOrderPurger 是 Revoke 撤销一台设备时用到的第二个窄接口(ISP):只清掉该 +// 设备自己排的执行目标顺序——「每端自己排」的顺序属于那台设备,设备被解除授权后它就 +// 没有持有者了,不该残留在账号里。与 LocalPathPurger 分开而不并成一个胖接口:两者是 +// 不同的域、实现方也不同。device_svc 不 import workspace_svc——由 bootstrap 用 +// workspace_svc.Default() 满足这个接口。 +type ExecTargetOrderPurger interface { + PurgeDeviceExecTargetOrders(ctx context.Context, deviceID int64) error +} + +// execTargetOrderPurger 默认是空操作,理由同 localPathPurger。 +var execTargetOrderPurger ExecTargetOrderPurger = noopExecTargetOrderPurger{} + +// SetExecTargetOrderPurger 由 bootstrap 注入真实实现;传 nil 时恢复成空操作。 +func SetExecTargetOrderPurger(p ExecTargetOrderPurger) { + if p == nil { + p = noopExecTargetOrderPurger{} + } + execTargetOrderPurger = p +} + +type noopExecTargetOrderPurger struct{} + +func (noopExecTargetOrderPurger) PurgeDeviceExecTargetOrders(context.Context, int64) error { + return nil +} + type deviceSvc struct { cfg Config signer Signer @@ -576,6 +602,13 @@ func (s *deviceSvc) Revoke(ctx context.Context, deviceID int64) error { logger.Ctx(ctx).Warn("device_svc.Revoke: purge reported local paths failed", zap.Int64("deviceId", deviceID), zap.Error(err)) } + // 「每端自己排」的执行目标顺序同理:它属于这台设备,设备被解除授权后没有持有者, + // 一并清掉,不残留在账号里。账号级的执行目标**集合**在同步组里,不受影响。 + // 与上面一样是撤销的从属后果,失败只记日志、不回滚已经生效的撤销。 + if err := execTargetOrderPurger.PurgeDeviceExecTargetOrders(ctx, deviceID); err != nil { + logger.Ctx(ctx).Warn("device_svc.Revoke: purge device exec target orders failed", + zap.Int64("deviceId", deviceID), zap.Error(err)) + } return nil } diff --git a/internal/service/device_svc/device_test.go b/internal/service/device_svc/device_test.go index 3c9ea3f8..4582a4b4 100644 --- a/internal/service/device_svc/device_test.go +++ b/internal/service/device_svc/device_test.go @@ -358,8 +358,55 @@ func TestRevoke_PurgesReportedLocalPaths(t *testing.T) { }) } +// stubExecTargetOrderPurger 记下每一次被清的 deviceID;err 非 nil 时模拟落库失败。 +type stubExecTargetOrderPurger struct { + purgedDeviceIDs []int64 + err error +} + +func (s *stubExecTargetOrderPurger) PurgeDeviceExecTargetOrders(_ context.Context, deviceID int64) error { + s.purgedDeviceIDs = append(s.purgedDeviceIDs, deviceID) + return s.err +} + +// TestRevoke_PurgesDeviceExecTargetOrders 每端自己排的执行目标顺序属于那台设备:用户 +// 解除一个浏览器的授权时它排的顺序一并消失,不残留在账号里。账号级的执行目标**集合** +// 不受影响——它在同步组里,不属于任何一台设备。 +func TestRevoke_PurgesDeviceExecTargetOrders(t *testing.T) { + convey.Convey("撤销设备时清掉它自己排的执行目标顺序", t, func() { + testutils.Redis() + ctx, mD, mT, _, svc, _ := setupDeviceTest(t) + mT.EXPECT().ListAccessJTIByDevice(gomock.Any(), int64(42)).Return(nil, nil) + mT.EXPECT().RevokeChain(gomock.Any(), int64(42), gomock.Any()).Return(nil) + mD.EXPECT().Revoke(gomock.Any(), int64(42), gomock.Any()).Return(nil) + + purger := &stubExecTargetOrderPurger{} + SetExecTargetOrderPurger(purger) + t.Cleanup(func() { SetExecTargetOrderPurger(nil) }) + + convey.So(svc.Revoke(ctx, 42), convey.ShouldBeNil) + convey.So(purger.purgedDeviceIDs, convey.ShouldResemble, []int64{42}) + }) + + convey.Convey("清顺序失败不回滚已经生效的撤销(fail-open,只记日志)", t, func() { + testutils.Redis() + ctx, mD, mT, _, svc, _ := setupDeviceTest(t) + mT.EXPECT().ListAccessJTIByDevice(gomock.Any(), int64(42)).Return(nil, nil) + mT.EXPECT().RevokeChain(gomock.Any(), int64(42), gomock.Any()).Return(nil) + mD.EXPECT().Revoke(gomock.Any(), int64(42), gomock.Any()).Return(nil) + + purger := &stubExecTargetOrderPurger{err: errors.New("boom")} + SetExecTargetOrderPurger(purger) + t.Cleanup(func() { SetExecTargetOrderPurger(nil) }) + + convey.So(svc.Revoke(ctx, 42), convey.ShouldBeNil) + convey.So(purger.purgedDeviceIDs, convey.ShouldResemble, []int64{42}) + }) +} + // TestRevoke_GivenNoPurgerConfigured_DoesNotPanic 复现「只装配了 device flow、 -// 没有整套 bootstrap」的调用方:从未 SetLocalPathPurger 过,Revoke 仍要正常成功, +// 没有整套 bootstrap」的调用方:从未 SetLocalPathPurger / SetExecTargetOrderPurger +// 过,Revoke 仍要正常成功, // 而不是对 nil 接口调用方法 panic(与 relay_svc.Default() 的既有安全占位同一模式)。 func TestRevoke_GivenNoPurgerConfigured_DoesNotPanic(t *testing.T) { convey.Convey("未装配 purger 时 Revoke 不 panic(默认空操作)", t, func() { diff --git a/internal/service/workspace_svc/workspace.go b/internal/service/workspace_svc/workspace.go index d0c1e8d5..fb97e8ae 100644 --- a/internal/service/workspace_svc/workspace.go +++ b/internal/service/workspace_svc/workspace.go @@ -12,13 +12,18 @@ import ( "context" "encoding/json" "sort" + "time" "github.com/cago-frame/cago/pkg/i18n" + "github.com/cago-frame/cago/pkg/logger" + "go.uber.org/zap" "agentre-server/internal/model/entity/device_entity" + "agentre-server/internal/model/entity/exec_order_entity" "agentre-server/internal/model/entity/sync_entity" "agentre-server/internal/pkg/code" "agentre-server/internal/repository/device_repo" + "agentre-server/internal/repository/exec_order_repo" "agentre-server/internal/repository/sync_repo" ) @@ -42,7 +47,10 @@ const ( // ExecTargetView 是总览页 Agent 卡片里一条执行目标链上的一档。 type ExecTargetView struct { - Rank int + Rank int + // BackendSyncID 是这一档跨机稳定且逐档唯一的标识,浏览器靠它表达排列: + // Rank 是位置性的(重排后就变了),DeviceID 也不唯一(一台机器可挂多个 backend)。 + BackendSyncID string IsLocalReference bool DeviceID int64 DeviceName string @@ -92,12 +100,15 @@ type DeviceDetailView struct { // Kind 是这一档指向的设备种类(device_entity.KindDesktop / KindAgentred)——R17 // 发起前要按它如实说明 org/subagent/hook 在目标上是否可用。 type WebDispatchTier struct { - Rank int - DeviceID int64 - DeviceName string - BackendType string - Kind string - Availability string + Rank int + // BackendSyncID 是这一档跨机稳定且逐档唯一的标识,浏览器靠它表达排列 + // (见 ExecTargetView.BackendSyncID)。 + BackendSyncID string + DeviceID int64 + DeviceName string + BackendType string + Kind string + Availability string // Current 标记按顺序取第一个可用的会落到这一档。至多一档为 true。 Current bool } @@ -133,17 +144,38 @@ type WebDispatchPlan struct { type WorkspaceSvc interface { // ListAccountAgents 是总览页「我有哪些 Agent」的唯一数据源:账号下每个 Agent - // 一行,逐档给出有序执行目标链与当前生效的那一档。 - ListAccountAgents(ctx context.Context, userID int64) ([]AgentView, error) + // 一行,逐档给出有序执行目标链与当前生效的那一档。deviceFingerprint 是调用方 + // 自己的设备指纹(可空),带上时链按**这台设备自己的排列**渲染。 + ListAccountAgents(ctx context.Context, userID int64, deviceFingerprint string) ([]AgentView, error) // WebDispatchPlan 是 R15 的派发计划:给定 Agent 与项目(可空),按序解析执行 // 目标链,跳过 device_id 为空的档(R15d),返回每档原因与选中的第一档可用 // agentred。全部不可用时 Chosen 为空,逐档原因由调用方渲染。 - WebDispatchPlan(ctx context.Context, userID int64, agentSyncID, projectSyncID string) (*WebDispatchPlan, error) + // deviceFingerprint 是调用方自己的设备指纹(可空),带上时先按它的排列重排 + // 执行目标链,再走既有的「取第一个可用」挑选。 + WebDispatchPlan(ctx context.Context, userID int64, agentSyncID, projectSyncID, deviceFingerprint string) (*WebDispatchPlan, error) + // SetExecTargetOrder 保存调用方设备对某个 Agent 的执行目标排列。指纹解析不到 + // 账号下的活跃设备时**拒绝**,不猜一个 device_id 去写(决策 9)。 + SetExecTargetOrder(ctx context.Context, in SetExecTargetOrderInput) error + // PurgeDeviceExecTargetOrders 删掉某台设备名下全部排列:设备被解除授权 / 删除时, + // 它排的顺序一并消失,不残留在账号里。 + PurgeDeviceExecTargetOrders(ctx context.Context, deviceID int64) error // DeviceDetail 是设备行展开时取的详情,deviceID 必须属于 userID 且未被撤销, // 否则返回 NotFound——不区分「不存在」与「不属于你」,避免枚举探测。 DeviceDetail(ctx context.Context, userID, deviceID int64) (*DeviceDetailView, error) } +// SetExecTargetOrderInput 是一次「这台设备把某个 Agent 的执行目标排成这个次序」。 +// UserID 来自调用方鉴权上下文,不由调用方填;DeviceFingerprint 只能由参数传入 +// (这组端点鉴权的是用户不是设备),因此它必须先被解析成 devices 行才作数。 +type SetExecTargetOrderInput struct { + UserID int64 + DeviceFingerprint string + AgentSyncID string + // BackendSyncIDs 是排列本身。不校验它与当前执行目标集合是否一致:排列是收敛的 + // 偏好,解析时以集合为准(指向已删 backend 的项忽略、未覆盖的档补到尾部)。 + BackendSyncIDs []string +} + // DaemonOnlineChecker 是这个包需要的窄接口(ISP):只问「这个指纹的 daemon 现在 // 在线吗」,不需要 relay_svc.RelaySvc 那一整套连接/转发方法。bootstrap 用 // relay_svc.Default() 结构性满足它。 @@ -219,8 +251,11 @@ type projectLocationPayload struct { // 结果,ListAccountAgents 与 DeviceDetail 共用同一份解析,避免各写一遍 // JSON 解析与分组逻辑(DRY)。 type resolvedTarget struct { - Rank int - BackendType string + Rank int + // BackendSyncID 是这一档指向的 backend 同步标识:跨机稳定、逐档唯一,是排列 + // 唯一能用的锚点,一路带到对外的档结构上。 + BackendSyncID string + BackendType string // Fingerprint 为空表示这一档是 device_id 为空的「本机」相对引用。 Fingerprint string IsLocalReference bool @@ -293,7 +328,9 @@ func buildAgentChains(rows []*sync_entity.SyncObject) []agentChain { } for i, te := range targetsByAgent[a.SyncID] { fp, known := backendFingerprint[te.backendID] - rt := resolvedTarget{Rank: i + 1, BackendType: backendType[te.backendID]} + rt := resolvedTarget{ + Rank: i + 1, BackendSyncID: te.backendID, BackendType: backendType[te.backendID], + } if !known { // backend 行不存在(已删除/尚未同步到):既非本机引用也非任何已知 // 指纹,调用方把它当「未配对」处理。 @@ -321,7 +358,99 @@ func deviceFingerprintMap(devices []*device_entity.Device) map[string]*device_en return out } -func (s *workspaceSvc) ListAccountAgents(ctx context.Context, userID int64) ([]AgentView, error) { +// ---------- 每端自己的派发顺序 ---------- + +// callerDevice 解析「发来这次请求的那台设备」。指纹只能由参数传入(这组端点鉴权的 +// 是用户不是设备),所以归属校验必须在这里发生:devices 取自 deviceByFP,而那张表 +// 由 device_repo.ListByUser(userID) 建出——只含调用方账号名下的活跃设备,别人账号的 +// 指纹在这里天然查不到,也就拿不到 device_id 去读它的排列。 +// +// 返回 nil 表示「没有可用的排列持有者」:指纹缺失、这个账号下没有这台设备、或它已 +// 被解除授权。读路径据此回落账号 sort_order,不报错(决策 9 的读侧)。 +func callerDevice(deviceByFP map[string]*device_entity.Device, fingerprint string) *device_entity.Device { + if fingerprint == "" { + return nil + } + dev := deviceByFP[fingerprint] + if !dev.IsActive() { + return nil + } + return dev +} + +// applyDeviceOrder 按一份排列重排执行目标链。 +// +// 排列是**收敛的**,不是权威的:以当前账号执行目标集合为准——排列里指向已不存在 +// backend 的项忽略,集合里没被排列覆盖到的档按账号 sort_order 补到尾部。因此增删 +// 执行目标之后旧排列不会失效,也不会让某一档凭空消失(与桌面端 ResolveExecTargetOrder +// 同一规则)。 +// +// 重排之后 Rank 必须重编号:它是位置性的,沿用账号次序的旧号会让调用方看到的序号 +// 与真实派发顺序对不上。这里只换顺序,不换任何一档的语义——本机相对引用被排到第一位 +// 也仍然是本机相对引用(R15d 照旧跳过)。 +func applyDeviceOrder(targets []resolvedTarget, permutation []string) []resolvedTarget { + if len(permutation) == 0 || len(targets) == 0 { + return targets + } + indexByBackend := make(map[string]int, len(targets)) + for i, t := range targets { + if t.BackendSyncID == "" { + continue + } + if _, dup := indexByBackend[t.BackendSyncID]; !dup { + indexByBackend[t.BackendSyncID] = i + } + } + + // 没有 sync_id 的档钉在原位:排列以 sync_id 表达,它无从指代自己,也就不该被 + // 排序动到。浏览器侧同样把它钉住(execOrder.ts 的 reorderTargets 只在可移动的 + // 档之间换位、并把空 sync_id 滤出提交载荷),两端因此对同一次操作给出同一结果。 + // 注意这和「未覆盖补到队尾」不冲突:那条针对的是**能被指代却没被排到**的档。 + pinned := make([]bool, len(targets)) + slots := make([]int, 0, len(targets)) + for i, t := range targets { + if t.BackendSyncID == "" { + pinned[i] = true + continue + } + slots = append(slots, i) + } + + taken := make([]bool, len(targets)) + ordered := make([]resolvedTarget, 0, len(slots)) + for _, backendSyncID := range permutation { + i, ok := indexByBackend[backendSyncID] + if !ok || taken[i] || pinned[i] { + continue + } + taken[i] = true + ordered = append(ordered, targets[i]) + } + for i, t := range targets { + if !taken[i] && !pinned[i] { + ordered = append(ordered, t) + } + } + + // 把重排后的档按原有的非钉住位置回填,钉住的档留在自己的下标上。 + out := make([]resolvedTarget, len(targets)) + for i, t := range targets { + if pinned[i] { + out[i] = t + } + } + for n, i := range slots { + out[i] = ordered[n] + } + for i := range out { + out[i].Rank = i + 1 + } + return out +} + +func (s *workspaceSvc) ListAccountAgents( + ctx context.Context, userID int64, deviceFingerprint string, +) ([]AgentView, error) { rows, err := sync_repo.SyncObject().ListByKinds(ctx, userID, []string{ sync_entity.KindAgent, sync_entity.KindAgentBackend, sync_entity.KindAgentExecTarget, sync_entity.KindDepartment, @@ -359,6 +488,21 @@ func (s *workspaceSvc) ListAccountAgents(ctx context.Context, userID int64) ([]A return v } + // 卡片渲染的是同一条链,也按调用方设备自己的排列:否则卡片上排第一、标着 + // 「当前」的那一档,会和真派发时选中的不是同一档。一次取这台设备对全部 Agent + // 的排列,一屏多张卡片不按 Agent 逐条查库。 + orderByAgent := map[string][]string{} + if dev := callerDevice(deviceByFP, deviceFingerprint); dev != nil { + orders, oerr := exec_order_repo.ExecOrder().ListByDevice(ctx, userID, dev.ID) + if oerr != nil { + logger.Ctx(ctx).Warn("workspace_svc.ListAccountAgents: read device exec target orders failed, falling back to account order", + zap.Int64("userID", userID), zap.Int64("deviceID", dev.ID), zap.Error(oerr)) + } + for _, o := range orders { + orderByAgent[o.AgentSyncID] = o.BackendSyncIDs() + } + } + chains := buildAgentChains(rows) out := make([]AgentView, 0, len(chains)) for _, chain := range chains { @@ -367,8 +511,11 @@ func (s *workspaceSvc) ListAccountAgents(ctx context.Context, userID int64) ([]A AvatarColor: chain.AvatarColor, DepartmentName: deptName[chain.DepartmentSyncID], } currentAssigned := false - for _, t := range chain.Targets { - et := ExecTargetView{Rank: t.Rank, BackendType: t.BackendType, IsLocalReference: t.IsLocalReference} + for _, t := range applyDeviceOrder(chain.Targets, orderByAgent[chain.SyncID]) { + et := ExecTargetView{ + Rank: t.Rank, BackendSyncID: t.BackendSyncID, + BackendType: t.BackendType, IsLocalReference: t.IsLocalReference, + } switch { case t.IsLocalReference: et.Availability = AvailabilitySkippedForWeb @@ -401,7 +548,7 @@ func (s *workspaceSvc) ListAccountAgents(ctx context.Context, userID int64) ([]A } func (s *workspaceSvc) WebDispatchPlan( - ctx context.Context, userID int64, agentSyncID, projectSyncID string, + ctx context.Context, userID int64, agentSyncID, projectSyncID, deviceFingerprint string, ) (*WebDispatchPlan, error) { rows, err := sync_repo.SyncObject().ListByKinds(ctx, userID, []string{ sync_entity.KindAgent, sync_entity.KindAgentBackend, sync_entity.KindAgentExecTarget, @@ -491,13 +638,18 @@ func (s *workspaceSvc) WebDispatchPlan( return nil, i18n.NewNotFoundError(ctx, code.NotFound) } + // 先按调用方设备自己的排列重排,再走下面那个「取第一个可用」的循环——挑选逻辑 + // 一行不改,Chosen / Current 与逐档原因自动跟着新顺序走。 + targets := applyDeviceOrder(chain.Targets, + deviceOrder(ctx, userID, deviceByFP, deviceFingerprint, agentSyncID)) + 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} + for _, t := range targets { + tier := WebDispatchTier{Rank: t.Rank, BackendSyncID: t.BackendSyncID, BackendType: t.BackendType} switch { case t.IsLocalReference: // R15d:device_id 为空的「本机」档在浏览器语境下没有指代对象,跳过。 @@ -568,6 +720,70 @@ func (s *workspaceSvc) WebDispatchPlan( return plan, nil } +// deviceOrder 取调用方设备对某个 Agent 的排列,取不到就返回空(回落账号 sort_order)。 +// +// 读路径**刻意**不因它失败:指纹缺失、设备解析不到、这台设备还没排过序、甚至读库 +// 出错,都按「没有排列」处理——派发不该因为一个偏好读不到就失败,也不该静默改派到 +// 别处(决策 9 的读侧)。读库出错是「降级但已处理」,落一条 Warn 让它可查。 +func deviceOrder( + ctx context.Context, userID int64, + deviceByFP map[string]*device_entity.Device, deviceFingerprint, agentSyncID string, +) []string { + dev := callerDevice(deviceByFP, deviceFingerprint) + if dev == nil { + return nil + } + order, err := exec_order_repo.ExecOrder().Find(ctx, userID, dev.ID, agentSyncID) + if err != nil { + logger.Ctx(ctx).Warn("workspace_svc.deviceOrder: read device exec target order failed, falling back to account order", + zap.Int64("userID", userID), zap.Int64("deviceID", dev.ID), + zap.String("agentSyncID", agentSyncID), zap.Error(err)) + return nil + } + return order.BackendSyncIDs() +} + +// SetExecTargetOrder 保存「这台设备把某个 Agent 的执行目标排成这个次序」。 +// +// 与读路径相反:这里解析不到设备就**拒绝**,绝不猜一个 device_id 去写(决策 9)。 +// 指纹是参数传进来的(这组端点鉴权的是用户不是设备),账号归属只能靠这次 +// (user_id, fingerprint) 解析来保证;device_id 又是主键的一部分,拿不到它就写不进去, +// 校验因此不可绕过。已被解除授权的设备同样写不进去——它的排列马上就要被清掉。 +func (s *workspaceSvc) SetExecTargetOrder(ctx context.Context, in SetExecTargetOrderInput) error { + dev, err := device_repo.Device().FindByFingerprint(ctx, in.UserID, in.DeviceFingerprint) + if err != nil { + return err + } + if !dev.IsActive() { + // 常见成因是浏览器攥着一枚已失效的指纹(换了账号、设备被解除授权):顺序 + // 因此存不下,界面会当场说明——落一条 Warn,让「用户说排序不生效」有据可查。 + logger.Ctx(ctx).Warn("workspace_svc.SetExecTargetOrder: device fingerprint resolves to no active device, refusing to store order", + zap.Int64("userID", in.UserID), zap.String("agentSyncID", in.AgentSyncID)) + return i18n.NewNotFoundError(ctx, code.DeviceNotFound) + } + + order := &exec_order_entity.DeviceExecTargetOrder{ + UserID: in.UserID, + DeviceID: dev.ID, + AgentSyncID: in.AgentSyncID, + Updatetime: time.Now().UnixMilli(), + } + if err := order.SetBackendSyncIDs(in.BackendSyncIDs); err != nil { + return err + } + return exec_order_repo.ExecOrder().Save(ctx, order) +} + +// PurgeDeviceExecTargetOrders 删掉某台设备名下全部排列:用户解除一个浏览器的授权 +// (或删掉一台设备的记录)时,它排的顺序一并消失,不残留在账号里。 +// +// 只按 device_id 删,不带 user_id:device_id 是全局自增主键、天然只属于一个账号, +// 一个账号的清理碰不到另一个账号的行(与 sync_svc.PurgeDeviceLocalPaths 同一约定)。 +// 账号级的执行目标**集合**不受影响——它在同步组里,不属于任何一台设备。 +func (s *workspaceSvc) PurgeDeviceExecTargetOrders(ctx context.Context, deviceID int64) error { + return exec_order_repo.ExecOrder().DeleteByDevice(ctx, deviceID) +} + // configuredProjects 回答「这台机器上哪些项目配了路径」。两类设备的路径存在不同的 // 地方,因为它们的流动性不同: // diff --git a/internal/service/workspace_svc/workspace_test.go b/internal/service/workspace_svc/workspace_test.go index 11a6252c..6218b1f1 100644 --- a/internal/service/workspace_svc/workspace_test.go +++ b/internal/service/workspace_svc/workspace_test.go @@ -5,14 +5,18 @@ import ( "encoding/json" "testing" + "github.com/cago-frame/cago/pkg/consts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "agentre-server/internal/model/entity/device_entity" + "agentre-server/internal/model/entity/exec_order_entity" "agentre-server/internal/model/entity/sync_entity" "agentre-server/internal/repository/device_repo" "agentre-server/internal/repository/device_repo/mock_device_repo" + "agentre-server/internal/repository/exec_order_repo" + "agentre-server/internal/repository/exec_order_repo/mock_exec_order_repo" "agentre-server/internal/repository/sync_repo" "agentre-server/internal/repository/sync_repo/mock_sync_repo" ) @@ -77,7 +81,7 @@ func TestListAccountAgents_GivenOrderedTargets_ThenFirstAvailableNonLocalIsCurre {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-online", Status: 1}, }, nil) - got, err := svc.ListAccountAgents(ctx, 7) + got, err := svc.ListAccountAgents(ctx, 7, "") require.NoError(t, err) require.Len(t, got, 1) agent := got[0] @@ -113,7 +117,7 @@ func TestListAccountAgents_GivenAllTargetsUnavailable_ThenHasAvailableTargetFals }, nil) mDev.EXPECT().ListByUser(ctx, int64(7)).Return(nil, nil) - got, err := svc.ListAccountAgents(ctx, 7) + got, err := svc.ListAccountAgents(ctx, 7, "") require.NoError(t, err) require.Len(t, got, 1) assert.False(t, got[0].HasAvailableTarget) @@ -137,7 +141,7 @@ func TestListAccountAgents_NeverCarriesCLIPathOrEnvJSON(t *testing.T) { }, nil) mDev.EXPECT().ListByUser(ctx, int64(7)).Return(nil, nil) - got, err := svc.ListAccountAgents(ctx, 7) + got, err := svc.ListAccountAgents(ctx, 7, "") require.NoError(t, err) out := mustJSON(t, got) assert.NotContains(t, out, "/Users/alice") @@ -275,7 +279,7 @@ func TestWebDispatchPlan_GivenLocalOfflineAvailable_ThenSkipsLocalAndPicksFirstA {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-online", Status: 1}, }, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1", "") require.NoError(t, err) require.Len(t, plan.Tiers, 3) @@ -335,7 +339,7 @@ func TestWebDispatchPlan_GivenAllUnavailable_ThenPerTierReasons(t *testing.T) { {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-online", Status: 1}, }, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1", "") require.NoError(t, err) require.Len(t, plan.Tiers, 4) @@ -376,7 +380,7 @@ func TestWebDispatchPlan_GivenProjectMissingOnFirstAvailable_ThenPicksNextWithPa {ID: 11, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-b", Status: 1}, }, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1", "") require.NoError(t, err) require.Len(t, plan.Tiers, 2) @@ -406,7 +410,7 @@ func TestWebDispatchPlan_GivenNoProject_ThenPicksFirstAvailableWithoutPath(t *te {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-online", Status: 1}, }, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "") require.NoError(t, err) require.Len(t, plan.Tiers, 1) assert.Equal(t, AvailabilityAvailable, plan.Tiers[0].Availability) @@ -423,7 +427,7 @@ func TestWebDispatchPlan_GivenUnknownAgent_ThenNotFound(t *testing.T) { }, nil) mDev.EXPECT().ListByUser(ctx, int64(7)).Return(nil, nil) - _, err := svc.WebDispatchPlan(ctx, 7, "agent-missing", "") + _, err := svc.WebDispatchPlan(ctx, 7, "agent-missing", "", "") assert.Error(t, err) } @@ -461,7 +465,7 @@ func TestWebDispatchPlan_GivenDesktopFirstAvailable_ThenChoosesDesktopWithLocalP {UserID: 7, DeviceID: 30, ProjectSyncID: "proj-1", Path: "/Users/wyz/agentre-server"}, }, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1", "") require.NoError(t, err) require.Len(t, plan.Tiers, 3) @@ -516,7 +520,7 @@ func TestWebDispatchPlan_GivenDesktopMissingProjectPath_ThenSkipsToNextTargetWit }, nil) mPath.EXPECT().ListByDevice(ctx, int64(7), int64(30)).Return(nil, nil) - plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1") + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1", "") require.NoError(t, err) require.Len(t, plan.Tiers, 2) @@ -534,3 +538,340 @@ func TestWebDispatchPlan_GivenDesktopMissingProjectPath_ThenSkipsToNextTargetWit assert.Equal(t, device_entity.KindAgentred, plan.Chosen.Kind) assert.Equal(t, "/srv/agentre-server", plan.Chosen.Cwd) } + +// ── 每端自己的派发顺序:按调用方设备的排列重排执行目标链 ────────────────── + +// registerExecOrderMock 只给「带设备指纹」的用例装排列仓储。不带指纹的用例根本不该 +// 走到它:那时仓储没被注册,一旦有人偷偷去读会当场炸开,而不是悄悄拿到一个空排列 +// 让「没解析设备就去读排列」这种错误蒙混过关。 +func registerExecOrderMock(t *testing.T) *mock_exec_order_repo.MockExecOrderRepo { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + m := mock_exec_order_repo.NewMockExecOrderRepo(ctrl) + exec_order_repo.RegisterExecOrder(m) + t.Cleanup(func() { exec_order_repo.RegisterExecOrder(nil) }) + return m +} + +// orderedChainRows 是「一个 Agent + 三档执行目标」的固定装置:账号 sort_order 是 +// b-a → b-b → b-c,分别落在三台 agentred 上。 +func orderedChainRows(t *testing.T) []*sync_entity.SyncObject { + t.Helper() + return []*sync_entity.SyncObject{ + {Kind: sync_entity.KindAgent, SyncID: "agent-1", Payload: mustJSON(t, map[string]any{"name": "后端 Agent"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-a", AgentredFingerprint: "fp-a", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-b", AgentredFingerprint: "fp-b", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + {Kind: sync_entity.KindAgentBackend, SyncID: "b-c", AgentredFingerprint: "fp-c", + 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-a", "sort_order": 0})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t2", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-b", "sort_order": 1})}, + {Kind: sync_entity.KindAgentExecTarget, SyncID: "t3", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-c", "sort_order": 2})}, + } +} + +// orderedChainDevices 是 orderedChainRows 对应的设备,外加发起请求的那台浏览器 +// (kind=web,ID 90)——排列的**持有者**,它本身从不作为派发目标出现在链上。 +func orderedChainDevices() []*device_entity.Device { + return []*device_entity.Device{ + {ID: 20, UserID: 7, Name: "机器 A", Kind: device_entity.KindAgentred, Fingerprint: "fp-a", Status: 1}, + {ID: 21, UserID: 7, Name: "机器 B", Kind: device_entity.KindAgentred, Fingerprint: "fp-b", Status: 1}, + {ID: 22, UserID: 7, Name: "机器 C", Kind: device_entity.KindAgentred, Fingerprint: "fp-c", Status: 1}, + {ID: 90, UserID: 7, Name: "Chrome", Kind: device_entity.KindWeb, Fingerprint: "fp-web", Status: 1}, + } +} + +func allOnline() fakeOnlineChecker { + return fakeOnlineChecker{online: map[string]bool{"fp-a": true, "fp-b": true, "fp-c": true}} +} + +func tierBackendSyncIDs(tiers []WebDispatchTier) []string { + out := make([]string, 0, len(tiers)) + for _, t := range tiers { + out = append(out, t.BackendSyncID) + } + return out +} + +func tierRanks(tiers []WebDispatchTier) []int { + out := make([]int, 0, len(tiers)) + for _, t := range tiers { + out = append(out, t.Rank) + } + return out +} + +// 带上自己指纹的浏览器拿到的是**它自己那份顺序**下的派发计划:链按它的排列重排, +// 「第一个可用」因此落到另一档。挑选逻辑一行不改,只是它看到的顺序变了。 +// +// 每一档还要带上 backend sync_id:rank 是位置性的、device_id 也不唯一(一台机器可挂 +// 多个 backend),浏览器只能靠它表达排列。 +func TestWebDispatchPlan_GivenDeviceOrder_ThenTiersFollowItAndChosenMoves(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + SetOnlineChecker(allOnline()) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(orderedChainRows(t), nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + mOrder.EXPECT().Find(ctx, int64(7), int64(90), "agent-1").Return( + &exec_order_entity.DeviceExecTargetOrder{ + UserID: 7, DeviceID: 90, AgentSyncID: "agent-1", OrderJSON: `["b-c","b-a"]`, + }, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "fp-web") + require.NoError(t, err) + + // 排列覆盖 b-c、b-a;没被覆盖的 b-b 按账号 sort_order 补到尾部。 + assert.Equal(t, []string{"b-c", "b-a", "b-b"}, tierBackendSyncIDs(plan.Tiers)) + // rank 是位置性的:重排后必须重编号,否则前端看到的序号与实际派发顺序对不上。 + assert.Equal(t, []int{1, 2, 3}, tierRanks(plan.Tiers)) + assert.True(t, plan.Tiers[0].Current) + require.NotNil(t, plan.Chosen) + assert.Equal(t, "fp-c", plan.Chosen.DeviceFingerprint) + assert.Equal(t, "机器 C", plan.Chosen.DeviceName) +} + +// 这台设备没有自己的顺序:回落到同步下来的账号 sort_order,与不带指纹时一致。 +func TestWebDispatchPlan_GivenNoOrderRow_ThenFallsBackToAccountSortOrder(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + SetOnlineChecker(allOnline()) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(orderedChainRows(t), nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + mOrder.EXPECT().Find(ctx, int64(7), int64(90), "agent-1").Return(nil, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "fp-web") + require.NoError(t, err) + assert.Equal(t, []string{"b-a", "b-b", "b-c"}, tierBackendSyncIDs(plan.Tiers)) + assert.Equal(t, "fp-a", plan.Chosen.DeviceFingerprint) +} + +// 排列是**收敛的**,不是权威的:排完序之后账号侧删掉了一档、又加了一档,旧排列 +// 不失效也不让谁凭空消失——指向已不存在 backend 的项忽略,没被覆盖到的档按账号 +// sort_order 补到尾部(与桌面端 ResolveExecTargetOrder 同一规则)。 +func TestWebDispatchPlan_GivenOrderReferencingRemovedBackend_ThenIgnoredAndUncoveredAppended(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + SetOnlineChecker(allOnline()) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(orderedChainRows(t), nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + mOrder.EXPECT().Find(ctx, int64(7), int64(90), "agent-1").Return( + &exec_order_entity.DeviceExecTargetOrder{ + UserID: 7, DeviceID: 90, AgentSyncID: "agent-1", + // b-gone 已被删除;b-b 是排完序之后新加的一档。 + OrderJSON: `["b-gone","b-c"]`, + }, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "fp-web") + require.NoError(t, err) + assert.Equal(t, []string{"b-c", "b-a", "b-b"}, tierBackendSyncIDs(plan.Tiers)) + assert.Len(t, plan.Tiers, 3, "排列里的幽灵档不得凭空多出一档") +} + +// 指纹解析不到设备(换了浏览器、清了 localStorage、指纹是别人账号的):读路径按 +// 「没有排列」处理,回落账号顺序,不报错也不静默改派到别处(决策 9 的读侧)。 +// 设备解析不到就拿不到 device_id,也就根本没有可读的排列——排列仓储一次都不该被碰。 +func TestWebDispatchPlan_GivenUnresolvableFingerprint_ThenSilentlyFallsBack(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + registerExecOrderMock(t) // 不设任何 EXPECT:被调用即失败 + SetOnlineChecker(allOnline()) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(orderedChainRows(t), nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "fp-someone-else") + require.NoError(t, err) + assert.Equal(t, []string{"b-a", "b-b", "b-c"}, tierBackendSyncIDs(plan.Tiers)) + assert.Equal(t, "fp-a", plan.Chosen.DeviceFingerprint) +} + +// 排列把「本机」相对引用排到第一位也改变不了 R15d:浏览器语境下它没有指代对象, +// 仍然 skipped_for_web、仍然不参与「第一个可用」的挑选。重排只换顺序,不换语义。 +func TestWebDispatchPlan_GivenOrderPromotingLocalReference_ThenStillSkippedForWeb(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + SetOnlineChecker(allOnline()) + + rows := append(orderedChainRows(t), + &sync_entity.SyncObject{Kind: sync_entity.KindAgentBackend, SyncID: "b-local", AgentredFingerprint: "", + Payload: mustJSON(t, map[string]any{"type": "claude_code"})}, + &sync_entity.SyncObject{Kind: sync_entity.KindAgentExecTarget, SyncID: "t4", + Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-local", "sort_order": 3})}, + ) + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(rows, nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + mOrder.EXPECT().Find(ctx, int64(7), int64(90), "agent-1").Return( + &exec_order_entity.DeviceExecTargetOrder{ + UserID: 7, DeviceID: 90, AgentSyncID: "agent-1", OrderJSON: `["b-local","b-b"]`, + }, nil) + + plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "", "fp-web") + require.NoError(t, err) + assert.Equal(t, []string{"b-local", "b-b", "b-a", "b-c"}, tierBackendSyncIDs(plan.Tiers)) + assert.Equal(t, AvailabilitySkippedForWeb, plan.Tiers[0].Availability) + assert.False(t, plan.Tiers[0].Current) + assert.True(t, plan.Tiers[1].Current) + assert.Equal(t, "fp-b", plan.Chosen.DeviceFingerprint) +} + +// 总览页的 Agent 卡片渲染的是同一条链,也必须按这个浏览器的顺序:否则卡片上排第一、 +// 标着「当前」的那一档,和真派发时选中的不是同一档。 +func TestListAccountAgents_GivenDeviceOrder_ThenCardChainFollowsItAndCurrentMoves(t *testing.T) { + ctx, mObj, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + SetOnlineChecker(allOnline()) + + mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return(orderedChainRows(t), nil) + mDev.EXPECT().ListByUser(ctx, int64(7)).Return(orderedChainDevices(), nil) + // 一次取这台设备对全部 Agent 的排列:一屏多张卡片不该按 Agent 逐条查库。 + mOrder.EXPECT().ListByDevice(ctx, int64(7), int64(90)).Return( + []*exec_order_entity.DeviceExecTargetOrder{ + {UserID: 7, DeviceID: 90, AgentSyncID: "agent-1", OrderJSON: `["b-c"]`}, + {UserID: 7, DeviceID: 90, AgentSyncID: "agent-other", OrderJSON: `["x"]`}, + }, nil) + + got, err := svc.ListAccountAgents(ctx, 7, "fp-web") + require.NoError(t, err) + require.Len(t, got, 1) + require.Len(t, got[0].ExecTargets, 3) + assert.Equal(t, []string{"b-c", "b-a", "b-b"}, []string{ + got[0].ExecTargets[0].BackendSyncID, + got[0].ExecTargets[1].BackendSyncID, + got[0].ExecTargets[2].BackendSyncID, + }) + assert.Equal(t, []int{1, 2, 3}, []int{ + got[0].ExecTargets[0].Rank, got[0].ExecTargets[1].Rank, got[0].ExecTargets[2].Rank, + }) + assert.True(t, got[0].ExecTargets[0].Current) + assert.Equal(t, "机器 C", got[0].ExecTargets[0].DeviceName) +} + +// 写路径与读路径**刻意不同**:保存顺序时解析不到设备就拒绝,绝不猜一个 device_id +// 去写(决策 9)。指纹是参数传进来的,账号归属只能靠 (user_id, fingerprint) 这次 +// 解析来保证——别人账号的指纹在这里查不到,因此写不进去。 +func TestSetExecTargetOrder_GivenForeignFingerprint_ThenRejectedWithoutWriting(t *testing.T) { + ctx, _, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + mDev.EXPECT().FindByFingerprint(ctx, int64(7), "fp-someone-else").Return(nil, nil) + mOrder.EXPECT().Save(gomock.Any(), gomock.Any()).Times(0) + + err := svc.SetExecTargetOrder(ctx, SetExecTargetOrderInput{ + UserID: 7, DeviceFingerprint: "fp-someone-else", + AgentSyncID: "agent-1", BackendSyncIDs: []string{"b-c", "b-a"}, + }) + assert.Error(t, err) +} + +// 已被解除授权的设备同样写不进去:它的顺序马上就要被清掉,再收一份新的没有意义。 +func TestSetExecTargetOrder_GivenRevokedDevice_ThenRejectedWithoutWriting(t *testing.T) { + ctx, _, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + mDev.EXPECT().FindByFingerprint(ctx, int64(7), "fp-web").Return( + &device_entity.Device{ID: 90, UserID: 7, Fingerprint: "fp-web", Kind: device_entity.KindWeb, Status: consts.DELETE}, nil) + mOrder.EXPECT().Save(gomock.Any(), gomock.Any()).Times(0) + + err := svc.SetExecTargetOrder(ctx, SetExecTargetOrderInput{ + UserID: 7, DeviceFingerprint: "fp-web", + AgentSyncID: "agent-1", BackendSyncIDs: []string{"b-c"}, + }) + assert.Error(t, err) +} + +// 自己账号下的设备:排列按 (user_id, device_id, agent_sync_id) 整体落库。写路径 +// 不校验排列与当前执行目标集合是否一致——排列是收敛的偏好,解析时以集合为准。 +func TestSetExecTargetOrder_GivenOwnDevice_ThenSavesPermutationUnderResolvedDeviceID(t *testing.T) { + ctx, _, _, mDev, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + mDev.EXPECT().FindByFingerprint(ctx, int64(7), "fp-web").Return( + &device_entity.Device{ID: 90, UserID: 7, Fingerprint: "fp-web", Kind: device_entity.KindWeb, Status: 1}, nil) + + var saved *exec_order_entity.DeviceExecTargetOrder + mOrder.EXPECT().Save(ctx, gomock.Any()).DoAndReturn( + func(_ context.Context, o *exec_order_entity.DeviceExecTargetOrder) error { + saved = o + return nil + }) + + require.NoError(t, svc.SetExecTargetOrder(ctx, SetExecTargetOrderInput{ + UserID: 7, DeviceFingerprint: "fp-web", + AgentSyncID: "agent-1", BackendSyncIDs: []string{"b-c", "b-a", "b-b"}, + })) + require.NotNil(t, saved) + assert.Equal(t, int64(7), saved.UserID) + assert.Equal(t, int64(90), saved.DeviceID, "device_id 只能由指纹解析 devices 行得到") + assert.Equal(t, "agent-1", saved.AgentSyncID) + assert.Equal(t, []string{"b-c", "b-a", "b-b"}, saved.BackendSyncIDs()) + assert.NotZero(t, saved.Updatetime) +} + +// 解除授权 / 删除设备时它排的顺序一并消失:排列的持有者是那台设备,设备没了它就 +// 没有持有者,不该残留在账号里。只按 device_id 删——device_id 是全局自增主键、天然 +// 只属于一个账号,账号级的执行目标**集合**(在同步组里)不受影响。 +func TestPurgeDeviceExecTargetOrders_GivenDeviceID_ThenDeletesAllItsOrders(t *testing.T) { + ctx, _, _, _, svc := setupWorkspaceTest(t) + mOrder := registerExecOrderMock(t) + + mOrder.EXPECT().DeleteByDevice(ctx, int64(90)).Return(nil) + + require.NoError(t, svc.PurgeDeviceExecTargetOrders(ctx, 90)) +} + +// 没有 backend sync_id 的档钉在原位,不被冲到队尾。 +// +// 排列以 backend sync_id 表达,所以一档没有 sync_id 就无从在排列里指代自己 +// (frontend/src/lib/execOrder.ts 的 reorderTargets 会把它从提交的排列里滤掉, +// 同时在本地把它钉在原位)。服务端若把「不在排列里」一律当成「未覆盖、补到队尾」, +// 这一档就会在提交后的重新拉取里凭空跳到最后——两端对同一次操作给出不同结果。 +// +// 「未覆盖补到队尾」只适用于**能被指代却没被排到**的档(新加的一档);无从指代的 +// 档不属于那一类,它压根没有参与排序的资格,位置也就不该被排序动到。 +func TestApplyDeviceOrder_GivenTierWithoutSyncID_ThenItStaysAtItsOriginalIndex(t *testing.T) { + targets := []resolvedTarget{ + {Rank: 1, BackendSyncID: "b-a"}, + {Rank: 2, BackendSyncID: ""}, // 无从指代:畸形同步载荷里 backend_sync_id 为空 + {Rank: 3, BackendSyncID: "b-c"}, + } + + got := applyDeviceOrder(targets, []string{"b-c", "b-a"}) + + assert.Equal(t, []string{"b-c", "", "b-a"}, backendSyncIDsOf(got), + "无 sync_id 的档应留在第 2 位,可排的两档在它前后换位") + assert.Equal(t, []int{1, 2, 3}, ranksOf(got), "Rank 必须按最终位置重编号") +} + +// 能被指代却没被排列覆盖到的档(排完序之后新增的一档)仍然补到队尾——这是规格 +// 「集合里没被排列覆盖到的档按账号 sort_order 补到尾部」,与上面那条互不冲突。 +func TestApplyDeviceOrder_GivenUncoveredTierWithSyncID_ThenAppendedAtTail(t *testing.T) { + targets := []resolvedTarget{ + {Rank: 1, BackendSyncID: "b-a"}, + {Rank: 2, BackendSyncID: "b-new"}, + {Rank: 3, BackendSyncID: "b-c"}, + } + + got := applyDeviceOrder(targets, []string{"b-c", "b-a"}) + + assert.Equal(t, []string{"b-c", "b-a", "b-new"}, backendSyncIDsOf(got)) +} + +func backendSyncIDsOf(ts []resolvedTarget) []string { + out := make([]string, 0, len(ts)) + for _, t := range ts { + out = append(out, t.BackendSyncID) + } + return out +} + +func ranksOf(ts []resolvedTarget) []int { + out := make([]int, 0, len(ts)) + for _, t := range ts { + out = append(out, t.Rank) + } + return out +} diff --git a/migrations/202608140001_device_exec_target_orders.go b/migrations/202608140001_device_exec_target_orders.go new file mode 100644 index 00000000..35279b71 --- /dev/null +++ b/migrations/202608140001_device_exec_target_orders.go @@ -0,0 +1,49 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// migration202608140001 建「每台设备自己的执行目标排列」表。 +// +// 一行的含义是「某台设备,对某个 Agent,把执行目标排成这个次序」:持有者是排这份 +// 顺序的客户端(本轮只有 kind=web 的浏览器会写),被排序的是 order_json 里的 +// backend sync_id。账号级的执行目标**集合**仍只在同步组里(sync_objects 的 +// kind=agent_exec_target),这张表只覆盖它的次序,不改变集合本身。 +// +// 形状照抄 device_local_paths(202608090001)——同样是「某台设备对某个同步对象的 +// 本地偏好」:(user_id, device_id, ) 复合主键、无自增 id、无 createtime。 +// +// 键用 device_id 而不是设备指纹:一是隔壁 followed_sessions.device_fingerprint 指的是 +// **目标**设备,同名反义必被读错;二是 device_id 只能由指纹解析 devices 行得到, +// 于是「传入的指纹必须属于调用方账号」这条校验在结构上绕不过去——拿不到 device_id +// 就无法读写这张表。 +// +// agent_sync_id 必须是 utf8mb4_0900_bin:它要和 sync_objects.sync_id 比较,而那一列 +// 是该排序规则(202608090001)。表默认的 utf8mb4_0900_ai_ci 大小写不敏感,会把两个 +// 不同的 Agent 认成同一个;老的 utf8mb4_bin 则是 PAD SPACE,会忽略尾随空格。 +// +// order_json 存 backend sync_id 的有序 JSON 数组,整存整取(排列永远被整体读写, +// 没有按档查询的需求),与桌面端 agent_exec_target_overrides.order_json 同形。 +// 用 text 而不是 json 列:这里不做任何数据库侧的 JSON 取值,text 与桌面端那侧 +// 的存法也一致。 +func migration202608140001() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202608140001", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(` + CREATE TABLE device_exec_target_orders ( + user_id bigint NOT NULL, + device_id bigint NOT NULL, + agent_sync_id varchar(255) COLLATE utf8mb4_0900_bin NOT NULL, + order_json text NOT NULL, + updatetime bigint NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, device_id, agent_sync_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS device_exec_target_orders").Error + }, + } +} diff --git a/migrations/collation_test.go b/migrations/collation_test.go index 2fa7270f..867ba265 100644 --- a/migrations/collation_test.go +++ b/migrations/collation_test.go @@ -96,7 +96,7 @@ func TestMigrationDDL_OpaqueIdentifiersAreByteExact(t *testing.T) { ddl := captureMigrationDDL(t) for _, col := range []string{ - "sync_id", "project_sync_id", "agentred_fingerprint", + "sync_id", "project_sync_id", "agentred_fingerprint", "agent_sync_id", "fingerprint", "client_fingerprint", "device_fingerprint", "session_id", "device_code", "refresh_token_hash", "access_jti", "content_hash", "provider_uid", diff --git a/migrations/migrations.go b/migrations/migrations.go index 06a1c612..4f0f220e 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -120,5 +120,6 @@ func migrationList() []*gormigrate.Migration { migration202605200005(), migration202608090001(), migration202608100001(), + migration202608140001(), } }