diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 3ac3a83..76554bd 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -1548,6 +1548,46 @@ function sameStringSet(a: string[] | undefined, b: string[] | undefined) { return left.length === right.length && left.every((value, index) => value === right[index]); } +function dispatchPlanTargetSignature(target: DispatchPlanTarget) { + return [ + target.projectId, + target.deviceId, + target.threadId, + target.threadDisplayName, + target.folderName, + target.codexFolderRef ?? "", + target.codexThreadRef ?? "", + target.reason, + ].join("\u001f"); +} + +function sameDispatchPlanTargets(a: DispatchPlanTarget[], b: DispatchPlanTarget[]) { + return sameStringSet( + a.map((target) => dispatchPlanTargetSignature(target)), + b.map((target) => dispatchPlanTargetSignature(target)), + ); +} + +function normalizeDispatchPlanTargetsForCreate( + state: BossState, + targets: DispatchPlanTarget[], +) { + if (targets.length === 0) { + throw new Error("DISPATCH_PLAN_TARGETS_REQUIRED"); + } + + const validatedTargets = targets.map((target) => + validateDispatchTargetAgainstState(state, normalizedDispatchPlanTarget(target, undefined, { allowInvalid: false }) as DispatchPlanTarget), + ); + + const uniqueProjectIds = normalizeStringSet(validatedTargets.map((target) => target.projectId)); + if (uniqueProjectIds.length !== validatedTargets.length) { + throw new Error("DISPATCH_PLAN_TARGET_DUPLICATE"); + } + + return validatedTargets; +} + function normalizedDispatchPlanTarget( raw: Partial, fallback?: DispatchPlanTarget, @@ -3774,26 +3814,29 @@ export async function createDispatchPlan(input: { const groupProjectId = input.groupProjectId.trim(); const requestMessageId = input.requestMessageId.trim(); const requestedBy = input.requestedBy.trim(); + const summary = input.summary?.trim() ?? ""; if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED"); if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED"); + if (!requestedBy) throw new Error("DISPATCH_PLAN_REQUESTED_BY_REQUIRED"); + const validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets); const existing = state.dispatchPlans.find( (plan) => plan.groupProjectId === groupProjectId && plan.requestMessageId === requestMessageId, ); if (existing) { + const payloadMatches = + existing.requestedBy === requestedBy && + existing.summary === summary && + sameDispatchPlanTargets(existing.targets, validatedTargets); + if (!payloadMatches) { + throw new Error("DISPATCH_PLAN_RETRY_MISMATCH"); + } 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 validatedTargets = targets.map((target) => validateDispatchTargetAgainstState(state, target)); - const plan: DispatchPlan = { planId: randomToken("dispatch-plan"), groupProjectId, @@ -3801,7 +3844,7 @@ export async function createDispatchPlan(input: { requestedBy, status: "pending_user_confirmation", targets: validatedTargets, - summary: input.summary?.trim() ?? "", + summary, createdAt: nowIso(), }; state.dispatchPlans.unshift(plan);