fix: harden dispatch confirmation flow

This commit is contained in:
kris
2026-03-30 01:24:45 +08:00
parent 40d93c05d1
commit 81f4245763

View File

@@ -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;
});
}