diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index d0778a2..1b0e4e5 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -290,6 +290,8 @@ export interface DispatchPlan { summary: string; createdAt: string; confirmedAt?: string; + confirmedBy?: string; + confirmedTargetProjectIds?: string[]; } export interface DispatchExecution { @@ -303,6 +305,7 @@ export interface DispatchExecution { createdAt: string; completedAt?: string; resultMessageId?: string; + completedByDeviceId?: string; } export interface VerificationCode { @@ -1530,37 +1533,82 @@ function normalizeGroupMember( }; } -function normalizeDispatchPlanTarget( +function trimToDefined(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizedDispatchPlanTarget( raw: Partial, fallback?: DispatchPlanTarget, -): DispatchPlanTarget { + options?: { allowInvalid?: boolean }, +): DispatchPlanTarget | null { + const deviceId = trimToDefined(raw.deviceId ?? fallback?.deviceId); + const projectId = trimToDefined(raw.projectId ?? fallback?.projectId); + const threadId = trimToDefined(raw.threadId ?? fallback?.threadId); + const threadDisplayName = trimToDefined(raw.threadDisplayName ?? fallback?.threadDisplayName); + const folderName = trimToDefined(raw.folderName ?? fallback?.folderName); + const codexFolderRef = trimToDefined(raw.codexFolderRef ?? fallback?.codexFolderRef); + const codexThreadRef = trimToDefined(raw.codexThreadRef ?? fallback?.codexThreadRef); + const reason = trimToDefined(raw.reason ?? fallback?.reason); + + if ( + !deviceId || + !projectId || + !threadId || + !threadDisplayName || + !folderName || + !reason + ) { + if (options?.allowInvalid) return null; + throw new Error("DISPATCH_PLAN_TARGET_INVALID"); + } + return { - deviceId: raw.deviceId ?? fallback?.deviceId ?? "", - projectId: raw.projectId ?? fallback?.projectId ?? "", - threadId: raw.threadId ?? fallback?.threadId ?? "", - threadDisplayName: raw.threadDisplayName ?? fallback?.threadDisplayName ?? "", - folderName: raw.folderName ?? fallback?.folderName ?? "", - codexFolderRef: raw.codexFolderRef ?? fallback?.codexFolderRef, - codexThreadRef: raw.codexThreadRef ?? fallback?.codexThreadRef, - reason: raw.reason ?? fallback?.reason ?? "", + deviceId, + projectId, + threadId, + threadDisplayName, + folderName, + codexFolderRef, + codexThreadRef, + reason, }; } function normalizeDispatchPlan(raw: Partial, fallback?: DispatchPlan): DispatchPlan { const fallbackTargets = fallback?.targets ?? []; + const targets = ensureArray(raw.targets as Partial[] | undefined, fallbackTargets) + .map((target, index) => + normalizedDispatchPlanTarget( + target, + fallbackTargets[index % Math.max(1, fallbackTargets.length)], + { allowInvalid: true }, + ), + ) + .filter((target): target is DispatchPlanTarget => Boolean(target)); return { planId: raw.planId ?? fallback?.planId ?? randomToken("dispatch-plan"), groupProjectId: raw.groupProjectId ?? fallback?.groupProjectId ?? "", requestMessageId: raw.requestMessageId ?? fallback?.requestMessageId ?? "", requestedBy: raw.requestedBy ?? fallback?.requestedBy ?? "", status: raw.status ?? fallback?.status ?? "pending_user_confirmation", - targets: ensureArray(raw.targets as Partial[] | undefined, fallbackTargets).map( - (target, index) => - normalizeDispatchPlanTarget(target, fallbackTargets[index % Math.max(1, fallbackTargets.length)]), - ), + targets, summary: raw.summary ?? fallback?.summary ?? "", createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), confirmedAt: raw.confirmedAt ?? fallback?.confirmedAt, + confirmedBy: raw.confirmedBy ?? fallback?.confirmedBy, + confirmedTargetProjectIds: + ensureArray( + raw.confirmedTargetProjectIds, + fallback?.confirmedTargetProjectIds ?? [], + ).map((projectId) => projectId.trim()).filter(Boolean).length > 0 + ? dedupeStrings( + ensureArray(raw.confirmedTargetProjectIds, fallback?.confirmedTargetProjectIds ?? []) + .map((projectId) => projectId.trim()) + .filter(Boolean), + ) + : undefined, }; } @@ -1579,6 +1627,7 @@ function normalizeDispatchExecution( createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(), completedAt: raw.completedAt ?? fallback?.completedAt, resultMessageId: raw.resultMessageId ?? fallback?.resultMessageId, + completedByDeviceId: raw.completedByDeviceId ?? fallback?.completedByDeviceId, }; } @@ -3640,11 +3689,23 @@ export async function createDispatchPlan(input: { const groupProjectId = input.groupProjectId.trim(); const requestMessageId = input.requestMessageId.trim(); const requestedBy = input.requestedBy.trim(); - const targets = input.targets.map((target) => normalizeDispatchPlanTarget(target)); if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED"); if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED"); + + const existing = state.dispatchPlans.find( + (plan) => + plan.groupProjectId === groupProjectId && + plan.requestMessageId === requestMessageId, + ); + if (existing) { + return existing; + } + if (!requestedBy) throw new Error("DISPATCH_PLAN_REQUESTED_BY_REQUIRED"); + const targets = input.targets.map((target) => + normalizedDispatchPlanTarget(target, undefined, { allowInvalid: false }), + ) as DispatchPlanTarget[]; if (targets.length === 0) throw new Error("DISPATCH_PLAN_TARGETS_REQUIRED"); const plan: DispatchPlan = { @@ -3672,12 +3733,36 @@ export async function listDispatchPlansByProject(groupProjectId: string) { export async function confirmDispatchPlan(input: { planId: string; - confirmedBy?: string; + confirmedBy: string; + approvedTargetProjectIds: string[]; }) { return mutateState((state) => { const plan = state.dispatchPlans.find((item) => item.planId === input.planId); if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND"); if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); + const confirmedBy = input.confirmedBy.trim(); + if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); + const approvedTargetProjectIds = dedupeStrings( + input.approvedTargetProjectIds.map((projectId) => projectId.trim()), + ); + if (approvedTargetProjectIds.length === 0) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_REQUIRED"); + } + const canonicalTargetProjectIds = dedupeStrings(plan.targets.map((target) => target.projectId)); + if ( + approvedTargetProjectIds.some((projectId) => !canonicalTargetProjectIds.includes(projectId)) + ) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_INVALID"); + } + if (plan.confirmedBy && plan.confirmedBy !== confirmedBy) { + throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); + } + if ( + plan.confirmedTargetProjectIds?.length && + plan.confirmedTargetProjectIds.join("\u0000") !== approvedTargetProjectIds.join("\u0000") + ) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH"); + } if (plan.status !== "dispatched") { plan.status = "approved"; @@ -3685,6 +3770,8 @@ export async function confirmDispatchPlan(input: { if (!plan.confirmedAt) { plan.confirmedAt = nowIso(); } + plan.confirmedBy = confirmedBy; + plan.confirmedTargetProjectIds = approvedTargetProjectIds; return plan; }); } @@ -3692,31 +3779,42 @@ export async function confirmDispatchPlan(input: { export async function createDispatchExecutionsFromPlan(input: { planId: string; approvedTargetProjectIds?: string[]; - confirmedBy?: string; + confirmedBy: string; }) { return mutateState((state) => { const plan = state.dispatchPlans.find((item) => item.planId === input.planId); if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND"); if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); + const confirmedBy = input.confirmedBy.trim(); + if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); + if (!plan.confirmedAt || !plan.confirmedBy || !plan.confirmedTargetProjectIds?.length) { + throw new Error("DISPATCH_PLAN_NOT_CONFIRMED"); + } + if (plan.confirmedBy !== confirmedBy) { + throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); + } + const canonicalTargetProjectIds = plan.confirmedTargetProjectIds; const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId); if (existingExecutions.length > 0) { - plan.status = "dispatched"; + const existingTargetIds = dedupeStrings( + existingExecutions.map((execution) => execution.targetProjectId), + ); + if (existingTargetIds.join("\u0000") !== canonicalTargetProjectIds.join("\u0000")) { + throw new Error("DISPATCH_EXECUTION_SET_MISMATCH"); + } + if (plan.status !== "dispatched") { + plan.status = "dispatched"; + } return existingExecutions; } - const allowedTargets = input.approvedTargetProjectIds?.length - ? new Set(input.approvedTargetProjectIds) - : null; const targets = plan.targets.filter((target) => - allowedTargets ? allowedTargets.has(target.projectId) : true, + canonicalTargetProjectIds.includes(target.projectId), ); if (targets.length === 0) { throw new Error("DISPATCH_EXECUTION_TARGETS_REQUIRED"); } - if (plan.status === "pending_user_confirmation") { - plan.status = "approved"; - } const createdAt = nowIso(); const executions = targets.map((target) => { const execution: DispatchExecution = { @@ -3739,16 +3837,33 @@ export async function createDispatchExecutionsFromPlan(input: { export async function completeDispatchExecution(payload: { executionId: string; + deviceId: string; status: "completed" | "failed"; resultMessageId?: string; }) { return mutateState((state) => { const execution = state.dispatchExecutions.find((item) => item.executionId === payload.executionId); if (!execution) throw new Error("DISPATCH_EXECUTION_NOT_FOUND"); + const deviceId = payload.deviceId.trim(); + if (!deviceId) throw new Error("DISPATCH_EXECUTION_DEVICE_REQUIRED"); + if (execution.deviceId !== deviceId) { + throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH"); + } + + const nextResultMessageId = payload.resultMessageId?.trim() || undefined; + if (execution.status === "completed" || execution.status === "failed") { + const sameStatus = execution.status === payload.status; + const sameResultMessageId = (execution.resultMessageId ?? undefined) === nextResultMessageId; + if (!sameStatus || !sameResultMessageId) { + throw new Error("DISPATCH_EXECUTION_COMPLETION_MISMATCH"); + } + return execution; + } execution.status = payload.status; execution.completedAt = nowIso(); - execution.resultMessageId = payload.resultMessageId?.trim() || undefined; + execution.resultMessageId = nextResultMessageId ?? execution.resultMessageId; + execution.completedByDeviceId = deviceId; return execution; }); }