feat: wire dispatch execution and device import flows

This commit is contained in:
kris
2026-03-30 11:08:43 +08:00
parent 3b2bf59b65
commit 745b47e812
16 changed files with 1545 additions and 13 deletions

View File

@@ -134,7 +134,9 @@ export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed
export type MasterAgentTaskType =
| "conversation_reply"
| "attachment_analysis"
| "group_dispatch_plan";
| "group_dispatch_plan"
| "dispatch_execution"
| "device_import_resolution";
export type DispatchPlanStatus =
| "pending_user_confirmation"
| "approved"
@@ -312,6 +314,54 @@ export interface DispatchExecution {
completedByDeviceId?: string;
}
export interface DeviceImportCandidate {
candidateId: string;
deviceId: string;
folderName: string;
folderRef?: string;
threadId: string;
threadDisplayName: string;
codexFolderRef?: string;
codexThreadRef?: string;
lastActiveAt: string;
suggestedImport: boolean;
}
export interface DeviceImportDraft {
draftId: string;
deviceId: string;
enrollmentId?: string;
status: "pending_candidates" | "pending_selection" | "pending_resolution" | "resolved" | "applied";
candidates: DeviceImportCandidate[];
selectedCandidateIds: string[];
createdAt: string;
updatedAt: string;
reviewedAt?: string;
reviewedBy?: string;
resolutionId?: string;
}
export interface DeviceImportResolutionItem {
candidateId: string;
action: "create_thread_conversation" | "attach_existing" | "skip";
threadDisplayName: string;
folderName: string;
targetProjectId?: string;
reason: string;
}
export interface DeviceImportResolution {
resolutionId: string;
draftId: string;
deviceId: string;
status: "ready" | "applied";
summary: string;
items: DeviceImportResolutionItem[];
createdAt: string;
appliedAt?: string;
appliedBy?: string;
}
export interface VerificationCode {
id: string;
account: string;
@@ -467,6 +517,11 @@ export interface MasterAgentTask {
attachmentDownloadExpiresAt?: string;
attachmentDownloadUrl?: string;
attachmentTextExcerpt?: string;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
targetThreadDisplayName?: string;
deviceImportDraftId?: string;
status: MasterAgentTaskStatus;
requestedAt: string;
claimedAt?: string;
@@ -718,6 +773,8 @@ export interface BossState {
masterAgentTasks: MasterAgentTask[];
dispatchPlans: DispatchPlan[];
dispatchExecutions: DispatchExecution[];
deviceImportDrafts: DeviceImportDraft[];
deviceImportResolutions: DeviceImportResolution[];
otaUpdates: OtaUpdate[];
otaUpdateLogs: OtaUpdateLog[];
deviceSkills: DeviceSkill[];
@@ -1140,6 +1197,8 @@ const initialState: BossState = {
masterAgentTasks: [],
dispatchPlans: [],
dispatchExecutions: [],
deviceImportDrafts: [],
deviceImportResolutions: [],
otaUpdates: [
{
releaseId: "ota_140_to_141",
@@ -1760,6 +1819,125 @@ function normalizeDispatchExecution(
};
}
function buildDeviceImportCandidateId(input: {
deviceId: string;
folderName: string;
threadId: string;
codexFolderRef?: string;
codexThreadRef?: string;
}) {
const signature = [
input.deviceId,
input.codexFolderRef?.trim() || input.folderName.trim(),
input.codexThreadRef?.trim() || input.threadId.trim(),
]
.filter(Boolean)
.join("-");
return `import-${slugify(signature)}`;
}
function normalizeDeviceImportCandidate(
raw: Partial<DeviceImportCandidate>,
fallback?: DeviceImportCandidate,
): DeviceImportCandidate {
const deviceId = raw.deviceId ?? fallback?.deviceId ?? "";
const folderName = raw.folderName ?? fallback?.folderName ?? "";
const threadId = raw.threadId ?? fallback?.threadId ?? "";
return {
candidateId:
raw.candidateId ??
fallback?.candidateId ??
buildDeviceImportCandidateId({
deviceId,
folderName,
threadId,
codexFolderRef: raw.codexFolderRef ?? fallback?.codexFolderRef,
codexThreadRef: raw.codexThreadRef ?? fallback?.codexThreadRef,
}),
deviceId,
folderName,
folderRef: raw.folderRef ?? fallback?.folderRef,
threadId,
threadDisplayName: raw.threadDisplayName ?? fallback?.threadDisplayName ?? threadId,
codexFolderRef: raw.codexFolderRef ?? fallback?.codexFolderRef,
codexThreadRef: raw.codexThreadRef ?? fallback?.codexThreadRef,
lastActiveAt: raw.lastActiveAt ?? fallback?.lastActiveAt ?? nowIso(),
suggestedImport: raw.suggestedImport ?? fallback?.suggestedImport ?? true,
};
}
function normalizeDeviceImportDraft(
raw: Partial<DeviceImportDraft>,
fallback?: DeviceImportDraft,
): DeviceImportDraft {
const fallbackCandidates = fallback?.candidates ?? [];
return {
draftId: raw.draftId ?? fallback?.draftId ?? randomToken("import-draft"),
deviceId: raw.deviceId ?? fallback?.deviceId ?? "",
enrollmentId: raw.enrollmentId ?? fallback?.enrollmentId,
status: raw.status ?? fallback?.status ?? "pending_candidates",
candidates: ensureArray(
raw.candidates as Partial<DeviceImportCandidate>[] | undefined,
fallbackCandidates,
).map((candidate, index) =>
normalizeDeviceImportCandidate(
candidate,
fallbackCandidates[index % Math.max(1, fallbackCandidates.length)],
),
),
selectedCandidateIds: dedupeStrings(
ensureArray(raw.selectedCandidateIds, fallback?.selectedCandidateIds ?? []),
),
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
updatedAt: raw.updatedAt ?? fallback?.updatedAt ?? nowIso(),
reviewedAt: raw.reviewedAt ?? fallback?.reviewedAt,
reviewedBy: raw.reviewedBy ?? fallback?.reviewedBy,
resolutionId: raw.resolutionId ?? fallback?.resolutionId,
};
}
function normalizeDeviceImportResolution(
raw: Partial<DeviceImportResolution>,
fallback?: DeviceImportResolution,
): DeviceImportResolution {
const fallbackItems = fallback?.items ?? [];
return {
resolutionId: raw.resolutionId ?? fallback?.resolutionId ?? randomToken("import-resolution"),
draftId: raw.draftId ?? fallback?.draftId ?? "",
deviceId: raw.deviceId ?? fallback?.deviceId ?? "",
status: raw.status ?? fallback?.status ?? "ready",
summary: raw.summary ?? fallback?.summary ?? "",
items: ensureArray(
raw.items as Partial<DeviceImportResolutionItem>[] | undefined,
fallbackItems,
).map((item, index) => ({
candidateId: item.candidateId ?? fallbackItems[index % Math.max(1, fallbackItems.length)]?.candidateId ?? "",
action:
item.action ??
fallbackItems[index % Math.max(1, fallbackItems.length)]?.action ??
"skip",
threadDisplayName:
item.threadDisplayName ??
fallbackItems[index % Math.max(1, fallbackItems.length)]?.threadDisplayName ??
"",
folderName:
item.folderName ??
fallbackItems[index % Math.max(1, fallbackItems.length)]?.folderName ??
"",
targetProjectId:
item.targetProjectId ??
fallbackItems[index % Math.max(1, fallbackItems.length)]?.targetProjectId,
reason:
item.reason ??
fallbackItems[index % Math.max(1, fallbackItems.length)]?.reason ??
"",
})),
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
appliedAt: raw.appliedAt ?? fallback?.appliedAt,
appliedBy: raw.appliedBy ?? fallback?.appliedBy,
};
}
function dedupeStrings(values: string[]) {
return [...new Set(values.filter((value) => Boolean(value)))];
}
@@ -2424,6 +2602,11 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
attachmentDownloadExpiresAt: task.attachmentDownloadExpiresAt,
attachmentDownloadUrl: task.attachmentDownloadUrl,
attachmentTextExcerpt: task.attachmentTextExcerpt,
dispatchExecutionId: task.dispatchExecutionId,
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
targetThreadDisplayName: task.targetThreadDisplayName,
deviceImportDraftId: task.deviceImportDraftId,
status: task.status ?? "queued",
requestedAt: task.requestedAt ?? nowIso(),
claimedAt: task.claimedAt,
@@ -2441,6 +2624,21 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
base.dispatchExecutions[index % Math.max(1, base.dispatchExecutions.length)],
),
),
deviceImportDrafts: ensureArray(raw.deviceImportDrafts, base.deviceImportDrafts).map((draft, index) =>
normalizeDeviceImportDraft(
draft,
base.deviceImportDrafts[index % Math.max(1, base.deviceImportDrafts.length)],
),
),
deviceImportResolutions: ensureArray(
raw.deviceImportResolutions,
base.deviceImportResolutions,
).map((resolution, index) =>
normalizeDeviceImportResolution(
resolution,
base.deviceImportResolutions[index % Math.max(1, base.deviceImportResolutions.length)],
),
),
otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({
...base.otaUpdates[index % base.otaUpdates.length],
...update,
@@ -2928,6 +3126,12 @@ function syncDerivedState(input: BossState) {
state.dispatchExecutions = state.dispatchExecutions
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 160);
state.deviceImportDrafts = state.deviceImportDrafts
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, 40);
state.deviceImportResolutions = state.deviceImportResolutions
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 80);
state.devices = state.devices.filter(isProductionDevice);
const visibleDeviceIds = new Set(state.devices.map((device) => device.id));
@@ -2942,6 +3146,13 @@ function syncDerivedState(input: BossState) {
visibleThreadIds.has(item.threadId),
);
state.deviceEnrollments = state.deviceEnrollments.filter((item) => visibleDeviceIds.has(item.deviceId));
state.deviceImportDrafts = state.deviceImportDrafts.filter((item) =>
visibleDeviceIds.has(item.deviceId),
);
const visibleImportDraftIds = new Set(state.deviceImportDrafts.map((item) => item.draftId));
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => visibleDeviceIds.has(item.deviceId) && visibleImportDraftIds.has(item.draftId),
);
state.deviceSkills = state.deviceSkills
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -3773,6 +3984,10 @@ export async function queueMasterAgentTask(payload: {
attachmentDownloadExpiresAt?: string;
attachmentDownloadUrl?: string;
attachmentTextExcerpt?: string;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
targetThreadDisplayName?: string;
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -3793,6 +4008,10 @@ export async function queueMasterAgentTask(payload: {
attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt,
attachmentDownloadUrl: payload.attachmentDownloadUrl,
attachmentTextExcerpt: payload.attachmentTextExcerpt,
dispatchExecutionId: payload.dispatchExecutionId,
targetProjectId: payload.targetProjectId,
targetThreadId: payload.targetThreadId,
targetThreadDisplayName: payload.targetThreadDisplayName,
status: "queued",
requestedAt: nowIso(),
};
@@ -3953,6 +4172,7 @@ export async function createDispatchExecutionsFromPlan(input: {
if (plan.status !== "dispatched") {
plan.status = "dispatched";
}
ensureDispatchExecutionTasksInState(state, plan, existingExecutions);
return existingExecutions;
}
@@ -3977,11 +4197,116 @@ export async function createDispatchExecutionsFromPlan(input: {
state.dispatchExecutions.unshift(execution);
return execution;
});
ensureDispatchExecutionTasksInState(state, plan, executions);
plan.status = "dispatched";
return executions;
});
}
function buildDispatchExecutionPrompt(input: {
groupProject: Project;
plan: DispatchPlan;
target: DispatchPlanTarget;
}) {
const requestMessage = input.groupProject.messages.find(
(message) => message.id === input.plan.requestMessageId,
);
const requestText = requestMessage?.body ?? input.plan.summary;
return [
"你正在执行 Boss 控制台的线程分发任务。",
"你的输出必须是 JSON并且只能包含两个字符串字段rawThreadReply、replyBody。",
"rawThreadReply写成目标线程直接回到群里的原始结果不要冒充主 Agent。",
"replyBody写成主 Agent 给群里的简短汇总,必须保留“主 Agent 汇总:”前缀。",
"不要输出 Markdown 代码块,不要输出额外解释。",
`groupProjectId: ${input.groupProject.id}`,
`groupProjectName: ${input.groupProject.name}`,
`threadProjectId: ${input.target.projectId}`,
`threadId: ${input.target.threadId}`,
`threadTitle: ${input.target.threadDisplayName}`,
`folderName: ${input.target.folderName}`,
`requestText: ${requestText}`,
`dispatchSummary: ${input.plan.summary}`,
].join("\n");
}
function ensureDispatchExecutionTaskInState(
state: BossState,
plan: DispatchPlan,
execution: DispatchExecution,
) {
const groupProject = state.projects.find((item) => item.id === execution.groupProjectId);
if (!groupProject) {
throw new Error("DISPATCH_EXECUTION_GROUP_PROJECT_NOT_FOUND");
}
const target = plan.targets.find(
(item) =>
item.projectId === execution.targetProjectId &&
item.threadId === execution.targetThreadId &&
item.deviceId === execution.deviceId,
);
if (!target) {
throw new Error("DISPATCH_EXECUTION_TARGET_NOT_FOUND");
}
const existing = state.masterAgentTasks.find(
(task) =>
task.taskType === "dispatch_execution" &&
(task.dispatchExecutionId === execution.executionId ||
(task.projectId === execution.groupProjectId &&
task.requestMessageId === plan.planId &&
task.targetProjectId === execution.targetProjectId &&
task.targetThreadId === execution.targetThreadId)),
);
if (existing) {
existing.dispatchExecutionId = existing.dispatchExecutionId ?? execution.executionId;
existing.targetProjectId = existing.targetProjectId ?? execution.targetProjectId;
existing.targetThreadId = existing.targetThreadId ?? execution.targetThreadId;
existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName;
existing.executionPrompt =
existing.executionPrompt ||
buildDispatchExecutionPrompt({
groupProject,
plan,
target,
});
return existing;
}
const requestedBy = plan.confirmedBy ?? plan.requestedBy;
const requestMessage = groupProject.messages.find((message) => message.id === plan.requestMessageId);
const task: MasterAgentTask = {
taskId: randomToken("mastertask"),
projectId: execution.groupProjectId,
taskType: "dispatch_execution",
requestMessageId: plan.planId,
requestText: requestMessage?.body ?? plan.summary,
executionPrompt: buildDispatchExecutionPrompt({
groupProject,
plan,
target,
}),
requestedBy,
requestedByAccount: requestedBy,
deviceId: execution.deviceId,
dispatchExecutionId: execution.executionId,
targetProjectId: execution.targetProjectId,
targetThreadId: execution.targetThreadId,
targetThreadDisplayName: target.threadDisplayName,
status: "queued",
requestedAt: nowIso(),
};
state.masterAgentTasks.unshift(task);
return task;
}
function ensureDispatchExecutionTasksInState(
state: BossState,
plan: DispatchPlan,
executions: DispatchExecution[],
) {
return executions.map((execution) => ensureDispatchExecutionTaskInState(state, plan, execution));
}
export async function confirmDispatchPlanAndCreateExecutions(input: {
groupProjectId: string;
planId: string;
@@ -4055,6 +4380,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
});
}
ensureDispatchExecutionTasksInState(state, plan, executions);
return {
plan: { ...plan },
executions: executions.map((execution) => ({ ...execution })),
@@ -4109,6 +4436,115 @@ export async function completeDispatchExecution(payload: {
});
}
function summarizeDispatchExecutionReply(rawThreadReply: string, threadTitle: string) {
const compact = rawThreadReply.trim().replace(/\s+/g, " ");
if (!compact) {
return `主 Agent 汇总:${threadTitle} 已返回执行结果。`;
}
if (compact.length <= 72) {
return `主 Agent 汇总:${threadTitle} 已返回执行结果:${compact}`;
}
return `主 Agent 汇总:${threadTitle} 已返回执行结果:${compact.slice(0, 69)}...`;
}
function appendDispatchExecutionResultInState(
state: BossState,
payload: {
dispatchExecutionId: string;
completedByDeviceId: string;
status: "completed" | "failed";
groupProjectId: string;
targetProjectId: string;
targetThreadId: string;
targetThreadDisplayName?: string;
rawThreadReply?: string;
masterSummary?: string;
},
) {
const execution = state.dispatchExecutions.find(
(item) => item.executionId === payload.dispatchExecutionId,
);
if (!execution) throw new Error("DISPATCH_EXECUTION_NOT_FOUND");
if (execution.groupProjectId !== payload.groupProjectId) {
throw new Error("DISPATCH_EXECUTION_GROUP_PROJECT_MISMATCH");
}
if (execution.targetProjectId !== payload.targetProjectId) {
throw new Error("DISPATCH_EXECUTION_TARGET_PROJECT_MISMATCH");
}
if (execution.targetThreadId !== payload.targetThreadId) {
throw new Error("DISPATCH_EXECUTION_TARGET_THREAD_MISMATCH");
}
if (execution.deviceId !== payload.completedByDeviceId) {
throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH");
}
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;
let mirroredResult: Message | null = null;
let masterSummary: Message | null = null;
if (payload.status === "completed") {
if (!payload.rawThreadReply?.trim()) {
throw new Error("DISPATCH_EXECUTION_RAW_REPLY_REQUIRED");
}
mirroredResult = pushProjectLedgerMessage(state, payload.groupProjectId, {
sender: "device",
senderLabel: `${threadTitle} · ${device?.name ?? payload.completedByDeviceId}`,
body: payload.rawThreadReply.trim(),
kind: "text",
});
masterSummary = pushProjectLedgerMessage(state, payload.groupProjectId, {
sender: "master",
senderLabel: "主 Agent",
body:
payload.masterSummary?.trim() ||
summarizeDispatchExecutionReply(payload.rawThreadReply, threadTitle),
kind: "text",
});
} else {
masterSummary = pushProjectLedgerMessage(state, payload.groupProjectId, {
sender: "ops",
senderLabel: "主 Agent Relay",
body: `${threadTitle} 执行失败,请稍后重试。`,
kind: "text",
});
}
execution.status = payload.status;
execution.completedAt = nowIso();
execution.completedByDeviceId = payload.completedByDeviceId;
execution.resultMessageId = mirroredResult?.id ?? execution.resultMessageId;
return {
execution: { ...execution },
mirroredResult,
masterSummary,
};
}
export async function appendDispatchExecutionResult(payload: {
dispatchExecutionId: string;
completedByDeviceId: string;
status: "completed" | "failed";
groupProjectId: string;
targetProjectId: string;
targetThreadId: string;
targetThreadDisplayName?: string;
rawThreadReply?: string;
masterSummary?: string;
}) {
const result = await mutateState((state) =>
appendDispatchExecutionResultInState(state, payload),
);
publishBossEvent("project.messages.updated", { projectId: payload.groupProjectId });
publishBossEvent("conversation.updated", { projectId: payload.groupProjectId });
return result;
}
export async function getMasterAgentTask(taskId: string) {
const state = await readState();
return state.masterAgentTasks.find((item) => item.taskId === taskId) ?? null;
@@ -4116,6 +4552,7 @@ export async function getMasterAgentTask(taskId: string) {
export async function claimNextMasterAgentTask(deviceId: string) {
let attachmentProjectId: string | undefined;
let dispatchExecutionProjectId: string | undefined;
const task = await mutateState((state) => {
const next = state.masterAgentTasks.find(
(item) => item.deviceId === deviceId && item.status === "queued",
@@ -4133,6 +4570,15 @@ export async function claimNextMasterAgentTask(deviceId: string) {
attachmentProjectId = next.projectId;
}
}
if (next.taskType === "dispatch_execution" && next.dispatchExecutionId) {
const execution = state.dispatchExecutions.find(
(item) => item.executionId === next.dispatchExecutionId,
);
if (execution && execution.status === "queued") {
execution.status = "running";
dispatchExecutionProjectId = execution.groupProjectId;
}
}
return { ...next };
});
if (task) {
@@ -4145,6 +4591,9 @@ export async function claimNextMasterAgentTask(deviceId: string) {
publishBossEvent("project.messages.updated", { projectId: attachmentProjectId });
publishBossEvent("conversation.updated", { projectId: attachmentProjectId });
}
if (dispatchExecutionProjectId) {
publishBossEvent("conversation.updated", { projectId: dispatchExecutionProjectId });
}
}
return task;
}
@@ -4156,6 +4605,10 @@ export async function completeMasterAgentTask(payload: {
replyBody?: string;
errorMessage?: string;
requestId?: string;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
rawThreadReply?: string;
dispatchPlan?: {
summary?: string;
targets: DispatchPlanTarget[];
@@ -4197,6 +4650,9 @@ export async function completeMasterAgentTask(payload: {
let attachmentProjectId: string | undefined;
let createdDispatchPlan: DispatchPlan | undefined;
let dispatchExecutionResult:
| ReturnType<typeof appendDispatchExecutionResultInState>
| undefined;
if (task.taskType === "attachment_analysis" && task.attachmentId) {
const project = state.projects.find((item) => item.id === task.projectId);
const match = project ? findProjectAttachment(project, task.attachmentId) : null;
@@ -4250,6 +4706,21 @@ export async function completeMasterAgentTask(payload: {
targets: payload.dispatchPlan.targets,
});
}
} else if (task.taskType === "dispatch_execution") {
if (!task.dispatchExecutionId || !task.targetProjectId || !task.targetThreadId) {
throw new Error("MASTER_AGENT_DISPATCH_EXECUTION_CONTEXT_REQUIRED");
}
dispatchExecutionResult = appendDispatchExecutionResultInState(state, {
dispatchExecutionId: payload.dispatchExecutionId?.trim() || task.dispatchExecutionId,
completedByDeviceId: payload.deviceId,
status: payload.status,
groupProjectId: task.projectId,
targetProjectId: payload.targetProjectId?.trim() || task.targetProjectId,
targetThreadId: payload.targetThreadId?.trim() || task.targetThreadId,
targetThreadDisplayName: task.targetThreadDisplayName,
rawThreadReply: payload.rawThreadReply?.trim() || task.replyBody,
masterSummary: payload.replyBody?.trim(),
});
} else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) {
pushProjectLedgerMessage(state, task.projectId, {
sender: "master",
@@ -4269,6 +4740,7 @@ export async function completeMasterAgentTask(payload: {
return {
...task,
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,
dispatchExecution: dispatchExecutionResult?.execution,
};
});
@@ -4724,6 +5196,51 @@ export async function verifyDeviceToken(deviceId: string, token?: string) {
return hasAuthorizedDeviceToken(state, deviceId, token);
}
function upsertDeviceImportDraftFromHeartbeat(
state: BossState,
payload: {
deviceId: string;
enrollmentId?: string;
candidates: DeviceImportCandidate[];
},
) {
if (payload.candidates.length === 0) {
return null;
}
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
const selectedCandidateIds = dedupeStrings(
(existing?.selectedCandidateIds ?? []).filter((candidateId) =>
payload.candidates.some((candidate) => candidate.candidateId === candidateId),
),
);
const nextDraft = normalizeDeviceImportDraft({
draftId: existing?.draftId ?? randomToken("import-draft"),
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status:
selectedCandidateIds.length > 0
? existing?.resolutionId
? "resolved"
: "pending_resolution"
: "pending_selection",
candidates: payload.candidates,
selectedCandidateIds,
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
reviewedAt: existing?.reviewedAt,
reviewedBy: existing?.reviewedBy,
resolutionId: existing?.resolutionId,
}, existing);
state.deviceImportDrafts = [
nextDraft,
...state.deviceImportDrafts.filter((item) => item.draftId !== nextDraft.draftId),
];
return nextDraft;
}
export async function upsertDeviceHeartbeat(payload: {
deviceId: string;
token?: string;
@@ -4736,6 +5253,16 @@ export async function upsertDeviceHeartbeat(payload: {
quota7d: number;
projects: string[];
endpoint?: string;
projectCandidates?: Array<{
folderName: string;
folderRef?: string;
threadId: string;
threadDisplayName: string;
codexFolderRef?: string;
codexThreadRef?: string;
lastActiveAt?: string;
suggestedImport?: boolean;
}>;
}) {
const result = await mutateState((state) => {
const claimedEnrollment = claimEnrollment(
@@ -4817,10 +5344,30 @@ export async function upsertDeviceHeartbeat(payload: {
}
}
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,
candidates: normalizedCandidates,
});
return {
device,
token: claimedEnrollment?.token ?? device.token,
pairingStatus: claimedEnrollment?.status,
importDraft: draft,
};
});
publishBossEvent("devices.updated", { deviceId: payload.deviceId });
@@ -4828,6 +5375,269 @@ export async function upsertDeviceHeartbeat(payload: {
return result;
}
function resolveDeviceImportAction(
state: BossState,
deviceId: string,
candidate: DeviceImportCandidate,
): DeviceImportResolutionItem {
const directMatch = state.projects.find(
(project) =>
!project.isGroup &&
((candidate.codexThreadRef && project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
project.threadMeta.threadId === candidate.threadId),
);
if (directMatch) {
return {
candidateId: candidate.candidateId,
action: "attach_existing",
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
targetProjectId: directMatch.id,
reason: `已匹配到现有会话《${directMatch.name}》,直接补充设备与线程映射。`,
};
}
const similarByFolder = state.projects.find(
(project) =>
!project.isGroup &&
project.deviceIds.includes(deviceId) &&
project.threadMeta.folderName === candidate.folderName &&
project.threadMeta.threadDisplayName === candidate.threadDisplayName,
);
if (similarByFolder) {
return {
candidateId: candidate.candidateId,
action: "attach_existing",
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
targetProjectId: similarByFolder.id,
reason: `同设备下已有同名线程《${similarByFolder.name}》,避免重复导入。`,
};
}
return {
candidateId: candidate.candidateId,
action: "create_thread_conversation",
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
reason: `建议把 ${candidate.threadDisplayName} 作为独立聊天窗口导入。`,
};
}
function summarizeDeviceImportResolution(
deviceName: string,
items: DeviceImportResolutionItem[],
) {
const createCount = items.filter((item) => item.action === "create_thread_conversation").length;
const attachCount = items.filter((item) => item.action === "attach_existing").length;
const skipCount = items.filter((item) => item.action === "skip").length;
return `${deviceName} 导入建议:新建 ${createCount} 个会话,关联 ${attachCount} 个现有会话${skipCount > 0 ? `,跳过 ${skipCount}` : ""}`;
}
export async function getLatestDeviceImportDraft(deviceId: string) {
const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === deviceId) ?? null;
const resolution = draft?.resolutionId
? state.deviceImportResolutions.find((item) => item.resolutionId === draft.resolutionId) ?? null
: state.deviceImportResolutions.find((item) => item.deviceId === deviceId) ?? null;
return { draft, resolution };
}
export async function selectDeviceImportCandidates(input: {
deviceId: string;
selectedCandidateIds: string[];
selectedBy: string;
}) {
const draft = await mutateState((state) => {
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
const availableCandidateIds = new Set(draft.candidates.map((item) => item.candidateId));
const nextSelected = dedupeStrings(input.selectedCandidateIds).filter((candidateId) =>
availableCandidateIds.has(candidateId),
);
if (nextSelected.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
draft.selectedCandidateIds = nextSelected;
draft.status = "pending_resolution";
draft.updatedAt = nowIso();
draft.reviewedBy = input.selectedBy;
draft.reviewedAt = undefined;
draft.resolutionId = undefined;
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== draft.draftId,
);
return { ...draft };
});
publishBossEvent("devices.updated", { deviceId: input.deviceId });
return draft;
}
export async function resolveDeviceImportDraft(input: {
deviceId: string;
reviewedBy: string;
}) {
const result = await mutateState((state) => {
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),
);
const resolution = normalizeDeviceImportResolution({
resolutionId: randomToken("import-resolution"),
draftId: draft.draftId,
deviceId: input.deviceId,
status: "ready",
summary: summarizeDeviceImportResolution(device.name, items),
items,
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 };
});
publishBossEvent("devices.updated", { deviceId: input.deviceId });
publishBossEvent("conversation.updated", { deviceId: input.deviceId });
return result;
}
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}`);
const now = nowIso();
return normalizeProject({
id: projectId,
name: candidate.threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: [device.id],
preview: `已从 ${device.name} 导入线程 ${candidate.threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: false,
unreadCount: 0,
riskLevel: "low",
threadMeta: {
projectId,
threadId: candidate.threadId,
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
activityIconCount: 1,
updatedAt: candidate.lastActiveAt || now,
codexFolderRef: candidate.codexFolderRef ?? candidate.folderRef,
codexThreadRef: candidate.codexThreadRef,
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: "主 Agent",
body: `已从设备 ${device.name} 导入线程《${candidate.threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
});
}
export async function applyDeviceImportResolution(input: {
deviceId: string;
appliedBy: string;
}) {
const result = await mutateState((state) => {
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
if (!draft || !draft.resolutionId) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const resolution = state.deviceImportResolutions.find(
(item) => item.resolutionId === draft.resolutionId,
);
if (!resolution) throw new Error("DEVICE_IMPORT_RESOLUTION_NOT_FOUND");
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
const importedProjects: Project[] = [];
for (const item of resolution.items) {
const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId);
if (!candidate || item.action === "skip") {
continue;
}
let targetProject = item.targetProjectId
? state.projects.find((project) => project.id === item.targetProjectId)
: undefined;
if (item.action === "create_thread_conversation" && !targetProject) {
targetProject = buildImportedThreadProject(device, candidate);
state.projects.unshift(targetProject);
} else if (item.action === "attach_existing" && !targetProject) {
continue;
}
if (!targetProject) continue;
if (!targetProject.deviceIds.includes(device.id)) {
targetProject.deviceIds.push(device.id);
}
targetProject.threadMeta.threadDisplayName = candidate.threadDisplayName;
targetProject.threadMeta.folderName = candidate.folderName;
targetProject.threadMeta.threadId = candidate.threadId;
targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef;
targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef;
targetProject.threadMeta.updatedAt = candidate.lastActiveAt;
targetProject.preview = `已导入 ${candidate.threadDisplayName}`;
targetProject.updatedAt = nowIso();
targetProject.lastMessageAt = targetProject.updatedAt;
importedProjects.push({ ...targetProject });
}
device.projects = dedupeStrings(
draft.candidates
.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId))
.map((candidate) => candidate.folderName),
);
resolution.status = "applied";
resolution.appliedAt = nowIso();
resolution.appliedBy = input.appliedBy;
draft.status = "applied";
draft.updatedAt = nowIso();
return {
draft: { ...draft },
resolution: { ...resolution },
importedProjects,
};
});
publishBossEvent("devices.updated", { deviceId: input.deviceId });
publishBossEvent("conversation.updated", { deviceId: input.deviceId });
return result;
}
export async function upsertDeviceSkills(payload: {
deviceId: string;
skills: Array<{

View File

@@ -10,6 +10,8 @@ import type {
ContextBudgetLevel,
Device,
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
DeviceSkill,
MasterIdentitySummary,
OpsFault,
@@ -94,6 +96,8 @@ export interface DeviceWorkspaceView {
selectedDevice?: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
}
export interface OpsSummaryView {
@@ -453,6 +457,8 @@ export function getDeviceWorkspaceView(
selectedDevice: state.devices.find((item) => item.id === deviceId),
relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId),
activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId),
importDraft: state.deviceImportDrafts.find((item) => item.deviceId === deviceId),
importResolution: state.deviceImportResolutions.find((item) => item.deviceId === deviceId),
};
}