fix: harden dispatch and device import flows

This commit is contained in:
kris
2026-03-30 12:03:43 +08:00
parent 745b47e812
commit 038c2bd088
13 changed files with 872 additions and 108 deletions

View File

@@ -1,22 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth";
import { applyDeviceImportResolution } from "@/lib/boss-data";
export async function POST(
request: NextRequest,
context: { params: Promise<{ deviceId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { deviceId } = await context.params;
const auth = await authorizeDeviceSessionRequest(request, deviceId);
if (!auth.ok) {
return NextResponse.json(
{ ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" },
{ status: auth.status },
);
}
try {
const result = await applyDeviceImportResolution({
deviceId,
appliedBy: session.account,
appliedBy: auth.session.account,
});
return NextResponse.json({ ok: true, ...result });
} catch (error) {

View File

@@ -1,24 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { resolveDeviceImportDraft } from "@/lib/boss-data";
import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth";
import { queueDeviceImportResolutionTask } from "@/lib/boss-master-agent";
export async function POST(
request: NextRequest,
context: { params: Promise<{ deviceId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
const { deviceId } = await context.params;
const auth = await authorizeDeviceSessionRequest(request, deviceId);
if (!auth.ok) {
return NextResponse.json(
{ ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" },
{ status: auth.status },
);
}
const { deviceId } = await context.params;
try {
const result = await resolveDeviceImportDraft({
const result = await queueDeviceImportResolutionTask({
deviceId,
reviewedBy: session.account,
reviewedBy: auth.session.account,
});
return NextResponse.json({ ok: true, ...result });
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },

View File

@@ -1,17 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth";
import { getLatestDeviceImportDraft } from "@/lib/boss-data";
export async function GET(
request: NextRequest,
context: { params: Promise<{ deviceId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { deviceId } = await context.params;
const auth = await authorizeDeviceSessionRequest(request, deviceId);
if (!auth.ok) {
return NextResponse.json(
{ ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" },
{ status: auth.status },
);
}
const result = await getLatestDeviceImportDraft(deviceId);
return NextResponse.json({ ok: true, ...result });
}

View File

@@ -1,26 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth";
import { selectDeviceImportCandidates } from "@/lib/boss-data";
export async function POST(
request: NextRequest,
context: { params: Promise<{ deviceId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
const { deviceId } = await context.params;
const auth = await authorizeDeviceSessionRequest(request, deviceId);
if (!auth.ok) {
return NextResponse.json(
{ ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" },
{ status: auth.status },
);
}
const body = (await request.json().catch(() => ({}))) as {
selectedCandidateIds?: string[];
};
const { deviceId } = await context.params;
try {
const draft = await selectDeviceImportCandidates({
deviceId,
selectedCandidateIds: body.selectedCandidateIds ?? [],
selectedBy: session.account,
selectedBy: auth.session.account,
});
return NextResponse.json({ ok: true, draft });
} catch (error) {

View File

@@ -3984,6 +3984,7 @@ export async function queueMasterAgentTask(payload: {
attachmentDownloadExpiresAt?: string;
attachmentDownloadUrl?: string;
attachmentTextExcerpt?: string;
deviceImportDraftId?: string;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
@@ -4008,6 +4009,7 @@ export async function queueMasterAgentTask(payload: {
attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt,
attachmentDownloadUrl: payload.attachmentDownloadUrl,
attachmentTextExcerpt: payload.attachmentTextExcerpt,
deviceImportDraftId: payload.deviceImportDraftId,
dispatchExecutionId: payload.dispatchExecutionId,
targetProjectId: payload.targetProjectId,
targetThreadId: payload.targetThreadId,
@@ -4478,12 +4480,39 @@ function appendDispatchExecutionResultInState(
throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH");
}
const groupProject = state.projects.find((item) => item.id === payload.groupProjectId);
if (!groupProject) {
throw new Error("PROJECT_NOT_FOUND");
}
const device = state.devices.find((item) => item.id === payload.completedByDeviceId);
const threadTitle =
payload.targetThreadDisplayName?.trim() ||
state.projects.find((item) => item.id === payload.targetProjectId)?.threadMeta.threadDisplayName ||
payload.targetThreadId;
if (execution.status === "completed" || execution.status === "failed") {
if (execution.status !== payload.status) {
throw new Error("DISPATCH_EXECUTION_COMPLETION_MISMATCH");
}
const existingMirroredResult = execution.resultMessageId
? findProjectMessage(groupProject, execution.resultMessageId)
: null;
if (
payload.status === "completed" &&
payload.rawThreadReply?.trim() &&
existingMirroredResult &&
existingMirroredResult.body !== payload.rawThreadReply.trim()
) {
throw new Error("DISPATCH_EXECUTION_COMPLETION_MISMATCH");
}
return {
execution: { ...execution },
mirroredResult: existingMirroredResult,
masterSummary: null,
};
}
let mirroredResult: Message | null = null;
let masterSummary: Message | null = null;
@@ -4706,6 +4735,24 @@ export async function completeMasterAgentTask(payload: {
targets: payload.dispatchPlan.targets,
});
}
} else if (task.taskType === "device_import_resolution") {
if (!task.deviceImportDraftId) {
throw new Error("MASTER_AGENT_DEVICE_IMPORT_DRAFT_REQUIRED");
}
const draft = state.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId);
if (!draft) {
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
}
if (payload.status === "completed") {
const resolutionReply = parseDeviceImportResolutionReply(state, draft, task.replyBody ?? "");
upsertDeviceImportResolutionInState(state, {
deviceId: draft.deviceId,
reviewedBy: task.requestedByAccount,
summary: resolutionReply.summary,
items: resolutionReply.items,
draftId: draft.draftId,
});
}
} else if (task.taskType === "dispatch_execution") {
if (!task.dispatchExecutionId || !task.targetProjectId || !task.targetThreadId) {
throw new Error("MASTER_AGENT_DISPATCH_EXECUTION_CONTEXT_REQUIRED");
@@ -5220,11 +5267,13 @@ function upsertDeviceImportDraftFromHeartbeat(
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status:
selectedCandidateIds.length > 0
? existing?.resolutionId
? "resolved"
: "pending_resolution"
: "pending_selection",
existing?.status === "applied" && existing.resolutionId && selectedCandidateIds.length > 0
? "applied"
: selectedCandidateIds.length > 0
? existing?.resolutionId
? "resolved"
: "pending_resolution"
: "pending_selection",
candidates: payload.candidates,
selectedCandidateIds,
createdAt: existing?.createdAt ?? nowIso(),
@@ -5272,6 +5321,21 @@ export async function upsertDeviceHeartbeat(payload: {
payload.token,
);
const normalizedCandidates = ensureArray(payload.projectCandidates, []).map((candidate) =>
normalizeDeviceImportCandidate({
deviceId: payload.deviceId,
folderName: candidate.folderName,
folderRef: candidate.folderRef,
threadId: candidate.threadId,
threadDisplayName: candidate.threadDisplayName,
codexFolderRef: candidate.codexFolderRef,
codexThreadRef: candidate.codexThreadRef,
lastActiveAt: candidate.lastActiveAt ?? nowIso(),
suggestedImport: candidate.suggestedImport ?? true,
}),
);
const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0;
let device = state.devices.find((item) => item.id === payload.deviceId);
if (!device) {
device = {
@@ -5307,56 +5371,44 @@ export async function upsertDeviceHeartbeat(payload: {
device.token = claimedEnrollment?.token ?? payload.token ?? device.token;
}
for (const projectName of payload.projects) {
const existing = state.projects.find((item) => item.name === projectName);
if (!existing) {
state.projects.push(
normalizeProject({
id: slugify(projectName),
name: projectName,
pinned: false,
deviceIds: [payload.deviceId],
preview: `${payload.name} 已自动上报项目文件夹`,
updatedAt: nowIso(),
lastMessageAt: nowIso(),
isGroup: false,
unreadCount: 0,
riskLevel: "low",
contextBudgetPct: 80,
contextBudgetLabel: "80%",
messages: [
{
id: randomToken("auto"),
sender: "device",
senderLabel: payload.name,
body: `本机发现新的项目目录:${projectName}`,
sentAt: nowIso(),
kind: "text",
},
],
goals: [],
versions: [],
}),
);
} else if (!existing.deviceIds.includes(payload.deviceId)) {
existing.deviceIds.push(payload.deviceId);
existing.isGroup = existing.deviceIds.length > 1;
if (shouldAutoImportLegacyProjects) {
for (const projectName of payload.projects) {
const existing = state.projects.find((item) => item.name === projectName);
if (!existing) {
state.projects.push(
normalizeProject({
id: slugify(projectName),
name: projectName,
pinned: false,
deviceIds: [payload.deviceId],
preview: `${payload.name} 已自动上报项目文件夹`,
updatedAt: nowIso(),
lastMessageAt: nowIso(),
isGroup: false,
unreadCount: 0,
riskLevel: "low",
contextBudgetPct: 80,
contextBudgetLabel: "80%",
messages: [
{
id: randomToken("auto"),
sender: "device",
senderLabel: payload.name,
body: `本机发现新的项目目录:${projectName}`,
sentAt: nowIso(),
kind: "text",
},
],
goals: [],
versions: [],
}),
);
} else if (!existing.deviceIds.includes(payload.deviceId)) {
existing.deviceIds.push(payload.deviceId);
existing.isGroup = existing.deviceIds.length > 1;
}
}
}
const normalizedCandidates = ensureArray(payload.projectCandidates, []).map((candidate) =>
normalizeDeviceImportCandidate({
deviceId: payload.deviceId,
folderName: candidate.folderName,
folderRef: candidate.folderRef,
threadId: candidate.threadId,
threadDisplayName: candidate.threadDisplayName,
codexFolderRef: candidate.codexFolderRef,
codexThreadRef: candidate.codexThreadRef,
lastActiveAt: candidate.lastActiveAt ?? nowIso(),
suggestedImport: candidate.suggestedImport ?? true,
}),
);
const draft = upsertDeviceImportDraftFromHeartbeat(state, {
deviceId: payload.deviceId,
enrollmentId: claimedEnrollment?.enrollmentId,
@@ -5443,6 +5495,75 @@ export async function getLatestDeviceImportDraft(deviceId: string) {
return { draft, resolution };
}
export async function previewDeviceImportResolution(input: { deviceId: string }) {
const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
if (draft.selectedCandidateIds.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);
const items = selectedCandidates.map((candidate) =>
resolveDeviceImportAction(state, input.deviceId, candidate),
);
return {
draft: { ...draft },
device: { ...device },
items,
summary: summarizeDeviceImportResolution(device.name, items),
};
}
function upsertDeviceImportResolutionInState(
state: BossState,
input: {
deviceId: string;
reviewedBy: string;
summary: string;
items: DeviceImportResolutionItem[];
draftId?: string;
},
) {
const draft =
state.deviceImportDrafts.find(
(item) => item.draftId === input.draftId || item.deviceId === input.deviceId,
) ?? null;
if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
if (draft.selectedCandidateIds.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
const existingResolution = state.deviceImportResolutions.find((item) => item.draftId === draft.draftId);
const resolution = normalizeDeviceImportResolution({
resolutionId: existingResolution?.resolutionId ?? draft.resolutionId ?? randomToken("import-resolution"),
draftId: draft.draftId,
deviceId: input.deviceId,
status: "ready",
summary: input.summary,
items: input.items,
createdAt: existingResolution?.createdAt ?? nowIso(),
});
draft.status = "resolved";
draft.updatedAt = nowIso();
draft.reviewedAt = nowIso();
draft.reviewedBy = input.reviewedBy;
draft.resolutionId = resolution.resolutionId;
state.deviceImportResolutions = [
resolution,
...state.deviceImportResolutions.filter((item) => item.draftId !== draft.draftId),
];
return { draft: { ...draft }, resolution };
}
export async function selectDeviceImportCandidates(input: {
deviceId: string;
selectedCandidateIds: string[];
@@ -5492,34 +5613,96 @@ export async function resolveDeviceImportDraft(input: {
const items = selectedCandidates.map((candidate) =>
resolveDeviceImportAction(state, input.deviceId, candidate),
);
const resolution = normalizeDeviceImportResolution({
resolutionId: randomToken("import-resolution"),
draftId: draft.draftId,
return upsertDeviceImportResolutionInState(state, {
deviceId: input.deviceId,
status: "ready",
reviewedBy: input.reviewedBy,
summary: summarizeDeviceImportResolution(device.name, items),
items,
createdAt: nowIso(),
draftId: draft.draftId,
});
draft.status = "resolved";
draft.updatedAt = nowIso();
draft.reviewedAt = nowIso();
draft.reviewedBy = input.reviewedBy;
draft.resolutionId = resolution.resolutionId;
state.deviceImportResolutions = [
resolution,
...state.deviceImportResolutions.filter((item) => item.draftId !== draft.draftId),
];
return { draft: { ...draft }, resolution };
});
publishBossEvent("devices.updated", { deviceId: input.deviceId });
publishBossEvent("conversation.updated", { deviceId: input.deviceId });
return result;
}
function parseDeviceImportResolutionReply(
state: BossState,
draft: DeviceImportDraft,
replyBody: string,
) {
const trimmed = replyBody.trim();
if (!trimmed) {
throw new Error("DEVICE_IMPORT_RESOLUTION_REPLY_REQUIRED");
}
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
const jsonCandidate = fencedMatch?.[1]?.trim() ?? trimmed;
let parsed:
| {
summary?: string;
items?: Array<{
candidateId?: string;
action?: DeviceImportResolutionItem["action"];
targetProjectId?: string;
reason?: string;
}>;
}
| null = null;
try {
parsed = JSON.parse(jsonCandidate);
} catch {
throw new Error("DEVICE_IMPORT_RESOLUTION_JSON_INVALID");
}
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);
const candidateMap = new Map(selectedCandidates.map((candidate) => [candidate.candidateId, candidate]));
const seenCandidateIds = new Set<string>();
const items: DeviceImportResolutionItem[] = [];
for (const rawItem of ensureArray(parsed?.items, [])) {
const candidateId = rawItem?.candidateId?.trim();
if (!candidateId || seenCandidateIds.has(candidateId)) continue;
const candidate = candidateMap.get(candidateId);
if (!candidate) continue;
seenCandidateIds.add(candidateId);
const heuristic = resolveDeviceImportAction(state, draft.deviceId, candidate);
items.push({
candidateId,
action:
rawItem.action === "attach_existing" ||
rawItem.action === "create_thread_conversation" ||
rawItem.action === "skip"
? rawItem.action
: heuristic.action,
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
targetProjectId:
typeof rawItem.targetProjectId === "string" && rawItem.targetProjectId.trim()
? rawItem.targetProjectId.trim()
: heuristic.targetProjectId,
reason: rawItem.reason?.trim() || heuristic.reason,
});
}
for (const candidate of selectedCandidates) {
if (!seenCandidateIds.has(candidate.candidateId)) {
items.push(resolveDeviceImportAction(state, draft.deviceId, candidate));
}
}
const device = state.devices.find((item) => item.id === draft.deviceId);
return {
summary:
parsed?.summary?.trim() ||
summarizeDeviceImportResolution(device?.name ?? draft.deviceId, items),
items,
};
}
function buildImportedThreadProject(device: Device, candidate: DeviceImportCandidate) {
const projectId =
candidate.codexThreadRef?.trim() && candidate.codexFolderRef?.trim()
@@ -5592,8 +5775,21 @@ export async function applyDeviceImportResolution(input: {
? state.projects.find((project) => project.id === item.targetProjectId)
: undefined;
if (item.action === "create_thread_conversation" && !targetProject) {
targetProject = buildImportedThreadProject(device, candidate);
state.projects.unshift(targetProject);
const draftProject = buildImportedThreadProject(device, candidate);
targetProject =
state.projects.find((project) => project.id === draftProject.id) ??
state.projects.find(
(project) =>
!project.isGroup &&
project.deviceIds.includes(device.id) &&
((candidate.codexThreadRef &&
project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
project.threadMeta.threadId === candidate.threadId),
);
if (!targetProject) {
targetProject = draftProject;
state.projects.unshift(targetProject);
}
} else if (item.action === "attach_existing" && !targetProject) {
continue;
}

View File

@@ -32,3 +32,45 @@ export async function authorizeDeviceWriteRequest(
principal: null,
};
}
export async function authorizeDeviceSessionRequest(
request: NextRequest,
deviceId: string,
) {
const device = await getDevice(deviceId);
const session = await requireRequestSession(request);
if (!session) {
return {
ok: false as const,
status: 401 as const,
device,
session: null,
};
}
if (!device) {
return {
ok: false as const,
status: 404 as const,
device: null,
session,
};
}
if (session.role === "highest_admin" || device.account === session.account) {
return {
ok: true as const,
status: 200 as const,
device,
session,
};
}
return {
ok: false as const,
status: 403 as const,
device,
session,
};
}

View File

@@ -6,9 +6,11 @@ import {
completeMasterAgentTask,
getProjectAttachment,
getAttachmentStorageConfig,
getLatestDeviceImportDraft,
getRuntimeAiAccountById,
getMasterAgentRuntimeAccount,
getMasterAgentTask,
previewDeviceImportResolution,
queueMasterAgentTask,
readState,
updateAttachmentAnalysisResult,
@@ -402,6 +404,176 @@ export async function queueGroupDispatchPlan(params: {
return resolveGroupDispatchPlanTask(task.taskId);
}
function buildDeviceImportResolutionPrompt(params: {
deviceName: string;
deviceId: string;
draftId: string;
selectedCandidates: Array<{
candidateId: string;
threadDisplayName: string;
folderName: string;
lastActiveAt: string;
}>;
existingProjects: string[];
}) {
return [
"你正在处理 Boss 控制台的设备导入决议任务。",
"请根据候选线程和现有会话,给出导入建议。",
"输出必须是 JSON对象结构如下",
'{ "summary": "一句中文摘要", "items": [{ "candidateId": "...", "action": "create_thread_conversation|attach_existing|skip", "targetProjectId": "可选", "reason": "中文原因" }] }',
"要求:",
"1. 每个 candidateId 最多出现一次。",
"2. 如果 action=attach_existing尽量给出 targetProjectId。",
"3. 如果信息不足,也必须给出 reason不要输出额外解释文本。",
"",
`deviceName: ${params.deviceName}`,
`deviceId: ${params.deviceId}`,
`draftId: ${params.draftId}`,
"selectedCandidates:",
params.selectedCandidates
.map(
(candidate) =>
`${candidate.candidateId} / ${candidate.threadDisplayName} / ${candidate.folderName} / ${candidate.lastActiveAt}`,
)
.join("\n") || "无",
"",
"existingProjects:",
params.existingProjects.join("\n") || "无",
].join("\n");
}
type DeviceImportResolutionTaskResult =
| {
ok: true;
taskId: string;
status: "completed";
draft: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"]>;
resolution: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"]>;
}
| {
ok: false;
taskId: string;
status: "failed";
draft: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"];
resolution: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"];
error: string;
};
async function resolveDeviceImportResolutionTask(taskId: string): Promise<DeviceImportResolutionTaskResult> {
const task = await getMasterAgentTask(taskId);
if (!task) {
throw new Error("MASTER_AGENT_TASK_NOT_FOUND");
}
if (task.taskType !== "device_import_resolution" || !task.deviceImportDraftId) {
throw new Error("MASTER_AGENT_TASK_TYPE_INVALID");
}
const draftRecord = await readState();
const draft = draftRecord.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId);
if (!draft) {
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
}
try {
const proposal = await previewDeviceImportResolution({ deviceId: draft.deviceId });
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: task.deviceId,
status: "completed",
replyBody: JSON.stringify(
{
summary: proposal.summary,
items: proposal.items.map((item) => ({
candidateId: item.candidateId,
action: item.action,
targetProjectId: item.targetProjectId,
reason: item.reason,
})),
},
null,
2,
),
});
const latest = await getLatestDeviceImportDraft(draft.deviceId);
return {
ok: true as const,
taskId: task.taskId,
status: "completed" as const,
draft: latest.draft!,
resolution: latest.resolution!,
};
} catch (error) {
const message = error instanceof Error ? error.message : "DEVICE_IMPORT_RESOLUTION_FAILED";
await completeMasterAgentTask({
taskId: task.taskId,
deviceId: task.deviceId,
status: "failed",
errorMessage: message,
});
const latest = await getLatestDeviceImportDraft(draft.deviceId);
return {
ok: false as const,
taskId: task.taskId,
status: "failed" as const,
draft: latest.draft,
resolution: latest.resolution,
error: message,
};
}
}
export async function queueDeviceImportResolutionTask(params: {
deviceId: string;
reviewedBy: string;
}) {
const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === params.deviceId);
if (!draft) {
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
}
if (draft.selectedCandidateIds.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
const device = state.devices.find((item) => item.id === params.deviceId);
if (!device) {
throw new Error("DEVICE_NOT_FOUND");
}
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);
const task = await queueMasterAgentTask({
projectId: "master-agent",
taskType: "device_import_resolution",
requestMessageId: draft.draftId,
requestText: `请审核设备 ${device.name} 的线程导入建议`,
executionPrompt: buildDeviceImportResolutionPrompt({
deviceName: device.name,
deviceId: device.id,
draftId: draft.draftId,
selectedCandidates: selectedCandidates.map((candidate) => ({
candidateId: candidate.candidateId,
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
lastActiveAt: candidate.lastActiveAt,
})),
existingProjects: state.projects
.filter((project) => !project.isGroup)
.map(
(project) =>
`${project.id} / ${project.threadMeta.threadDisplayName} / ${project.threadMeta.folderName} / devices=${project.deviceIds.join(",")}`,
),
}),
requestedBy: params.reviewedBy,
requestedByAccount: params.reviewedBy,
deviceId: state.user.boundDeviceId || "mac-studio",
deviceImportDraftId: draft.draftId,
});
return resolveDeviceImportResolutionTask(task.taskId);
}
async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {