fix: harden dispatch and device import flows
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user