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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:]"']*)?#<redacted>@tcp(\2)/\3#g
s#((redis-)?password[=:][[:space:]]*)[^,;[:space:]]+#\1<redacted>#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:]"'"']*)?#<redacted>@tcp(\2)/\3#g' \
-e 's#((redis-)?password[=:][[:space:]]*)[^,;[:space:]]+#\1<redacted>#Ig' \
sed -E -f "$RUNNER_TEMP/redact.sed" \
"$log" > "e2e/redacted-failure-evidence/runtime/$run_id/server.log"
done

Expand Down
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}

Expand Down
41 changes: 40 additions & 1 deletion frontend/src/__tests__/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -30,11 +32,18 @@ vi.mock("@/lib/relayClient", async (importOriginal) => {
});
vi.mock("@/lib/webDevice", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/webDevice")>();
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",
Expand Down Expand Up @@ -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();
});
});
181 changes: 181 additions & 0 deletions frontend/src/__tests__/exec-order.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/api")>();
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<string> {
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");
});
});
Loading
Loading