fix: harden dispatch confirmation flow
This commit is contained in:
@@ -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<DispatchPlanTarget>,
|
||||
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<DispatchPlan>, fallback?: DispatchPlan): DispatchPlan {
|
||||
const fallbackTargets = fallback?.targets ?? [];
|
||||
const targets = ensureArray(raw.targets as Partial<DispatchPlanTarget>[] | 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<DispatchPlanTarget>[] | 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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user