From ad7dd94d9581b70f87aa05aff9871d642862c2d7 Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 3 Apr 2026 05:02:18 +0800 Subject: [PATCH] fix: keep imported thread candidates distinct --- src/lib/boss-data.ts | 12 +- ...ice-import-candidate-id-regression.test.ts | 141 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/device-import-candidate-id-regression.test.ts diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 5f3e899..4567805 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -2021,7 +2021,7 @@ function buildDeviceImportCandidateId(input: { ] .filter(Boolean) .join("-"); - return `import-${slugify(signature)}`; + return `import-${slugifyWithHash(signature)}`; } function normalizeDeviceImportCandidate( @@ -2316,6 +2316,12 @@ function slugify(value: string) { .slice(0, 48) || `item-${randomBytes(2).toString("hex")}`; } +function slugifyWithHash(value: string) { + const base = slugify(value).slice(0, 40); + const hash = createHash("sha1").update(value).digest("hex").slice(0, 8); + return `${base || "item"}-${hash}`; +} + const aiRolePriority: Record = { primary: 0, backup: 1, @@ -7144,8 +7150,8 @@ function parseDeviceImportResolutionReply( function buildImportedThreadProject(device: Device, candidate: DeviceImportCandidate) { const projectId = candidate.codexThreadRef?.trim() && candidate.codexFolderRef?.trim() - ? slugify(`${device.id}-${candidate.codexFolderRef}-${candidate.codexThreadRef}`) - : slugify(`${device.id}-${candidate.folderName}-${candidate.threadId}`); + ? slugifyWithHash(`${device.id}-${candidate.codexFolderRef}-${candidate.codexThreadRef}`) + : slugifyWithHash(`${device.id}-${candidate.folderName}-${candidate.threadId}`); const now = nowIso(); return normalizeProject({ id: projectId, diff --git a/tests/device-import-candidate-id-regression.test.ts b/tests/device-import-candidate-id-regression.test.ts new file mode 100644 index 0000000..804c5e8 --- /dev/null +++ b/tests/device-import-candidate-id-regression.test.ts @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let deviceHeartbeatRoute: (typeof import("../src/app/api/device-heartbeat/route"))["POST"]; +let AUTH_SESSION_COOKIE = ""; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-import-candidate-id-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [heartbeatModule, data, auth] = await Promise.all([ + import("../src/app/api/device-heartbeat/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + deviceHeartbeatRoute = heartbeatModule.POST; + createAuthSession = data.createAuthSession; + readState = data.readState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedSessionCookie() { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + return `${AUTH_SESSION_COOKIE}=${session.sessionToken}`; +} + +test("auto-sync import keeps long-prefix project candidates distinct", async () => { + await setup(); + const cookie = await createAuthedSessionCookie(); + const state = await readState(); + state.devices = [ + { + id: "mac-studio", + name: "Mac Studio", + avatar: "M", + account: "17600003315", + source: "production", + status: "online", + projects: [], + quota5h: 68, + quota7d: 81, + lastSeenAt: "2026-04-03T10:00:00.000Z", + endpoint: "mac://kris.local", + token: "boss-mac-studio-token", + note: "本机 Codex 主节点", + }, + ]; + + const response = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + }, + body: JSON.stringify({ + deviceId: "mac-studio", + token: "boss-mac-studio-token", + name: "Mac Studio", + avatar: "M", + account: "17600003315", + status: "online", + quota5h: 68, + quota7d: 81, + projects: [], + endpoint: "mac://kris.local", + projectCandidates: [ + { + folderName: "AItoukui", + folderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode/AItoukui", + threadId: "019d4efb-3485-7b21-86dc-b70d3f4adf68", + threadDisplayName: "拉取代码并验证交接文档和本地启动状态", + codexFolderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode/AItoukui", + codexThreadRef: "019d4efb-3485-7b21-86dc-b70d3f4adf68", + lastActiveAt: "2026-04-02T20:50:36.000Z", + suggestedImport: true, + }, + { + folderName: "500Gcode", + folderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode", + threadId: "019d4e19-1d27-77d3-bc54-2a1c80c7431b", + threadDisplayName: "本机运行3.5B模型并搭建Web演示", + codexFolderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode", + codexThreadRef: "019d4e19-1d27-77d3-bc54-2a1c80c7431b", + lastActiveAt: "2026-04-02T20:50:36.000Z", + suggestedImport: true, + }, + { + folderName: "figma", + folderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode/figma", + threadId: "019d4e1d-03b8-7610-b427-c4ffd234aed4", + threadDisplayName: "配置Figma账号并启用UI制作Skill", + codexFolderRef: "/Volumes/Macintosh HD/Users/kris/500Gcode/figma", + codexThreadRef: "019d4e1d-03b8-7610-b427-c4ffd234aed4", + lastActiveAt: "2026-04-02T20:50:36.000Z", + suggestedImport: true, + }, + ], + }), + }), + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + importDraft?: { candidates: Array<{ candidateId: string }>; appliedProjectNames: string[] }; + }; + + const candidateIds = payload.importDraft?.candidates.map((candidate) => candidate.candidateId) ?? []; + assert.equal(new Set(candidateIds).size, 3); + + const nextState = await readState(); + const importedProjects = nextState.projects.filter((project) => + ["AItoukui", "500Gcode", "figma"].includes(project.threadMeta.folderName), + ); + assert.equal(importedProjects.length, 3); + assert.deepEqual( + importedProjects.map((project) => project.threadMeta.folderName).sort(), + ["500Gcode", "AItoukui", "figma"], + ); +});