fix: tighten dispatch retry validation

This commit is contained in:
kris
2026-03-30 01:33:06 +08:00
parent 81f4245763
commit 1cd3a15a5d

View File

@@ -1538,6 +1538,16 @@ function trimToDefined(value?: string) {
return trimmed ? trimmed : undefined;
}
function normalizeStringSet(values: string[]) {
return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b));
}
function sameStringSet(a: string[] | undefined, b: string[] | undefined) {
const left = normalizeStringSet(a ?? []);
const right = normalizeStringSet(b ?? []);
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function normalizedDispatchPlanTarget(
raw: Partial<DispatchPlanTarget>,
fallback?: DispatchPlanTarget,
@@ -1576,6 +1586,86 @@ function normalizedDispatchPlanTarget(
};
}
function activeSessionForAccount(state: BossState, account: string) {
return (
state.authSessions.find(
(session) =>
session.account === account &&
!session.revokedAt &&
new Date(session.expiresAt).getTime() > Date.now(),
) ?? null
);
}
function requireDispatchActorSession(state: BossState, account: string) {
const normalizedAccount = account.trim();
if (!normalizedAccount) {
throw new Error("DISPATCH_ACTOR_ACCOUNT_REQUIRED");
}
const authAccount = state.authAccounts.find((item) => item.account === normalizedAccount);
if (!authAccount) {
throw new Error("DISPATCH_ACTOR_ACCOUNT_NOT_FOUND");
}
const session = activeSessionForAccount(state, normalizedAccount);
if (!session) {
throw new Error("DISPATCH_ACTOR_SESSION_REQUIRED");
}
return { authAccount, session };
}
function validateDispatchTargetAgainstState(
state: BossState,
target: DispatchPlanTarget,
): DispatchPlanTarget {
const project = state.projects.find((item) => item.id === target.projectId);
if (!project) {
throw new Error("DISPATCH_TARGET_PROJECT_NOT_FOUND");
}
const device = state.devices.find((item) => item.id === target.deviceId);
if (!device || device.source !== "production") {
throw new Error("DISPATCH_TARGET_DEVICE_INVALID");
}
if (!project.deviceIds.includes(device.id)) {
throw new Error("DISPATCH_TARGET_DEVICE_PROJECT_MISMATCH");
}
const matchingGroupMember = project.groupMembers.find(
(member) => member.deviceId === device.id && member.threadId === target.threadId,
);
if (project.isGroup) {
if (!matchingGroupMember) {
throw new Error("DISPATCH_TARGET_THREAD_MISMATCH");
}
if (matchingGroupMember.threadDisplayName !== target.threadDisplayName) {
throw new Error("DISPATCH_TARGET_THREAD_DISPLAY_NAME_MISMATCH");
}
if (matchingGroupMember.folderName !== target.folderName) {
throw new Error("DISPATCH_TARGET_FOLDER_NAME_MISMATCH");
}
} else {
if (project.threadMeta.threadId !== target.threadId) {
throw new Error("DISPATCH_TARGET_THREAD_MISMATCH");
}
if (project.threadMeta.threadDisplayName !== target.threadDisplayName) {
throw new Error("DISPATCH_TARGET_THREAD_DISPLAY_NAME_MISMATCH");
}
if (project.threadMeta.folderName !== target.folderName) {
throw new Error("DISPATCH_TARGET_FOLDER_NAME_MISMATCH");
}
}
if (target.codexThreadRef && project.threadMeta.codexThreadRef && target.codexThreadRef !== project.threadMeta.codexThreadRef) {
throw new Error("DISPATCH_TARGET_CODEX_THREAD_MISMATCH");
}
if (target.codexFolderRef && project.threadMeta.codexFolderRef && target.codexFolderRef !== project.threadMeta.codexFolderRef) {
throw new Error("DISPATCH_TARGET_CODEX_FOLDER_MISMATCH");
}
return target;
}
function normalizeDispatchPlan(raw: Partial<DispatchPlan>, fallback?: DispatchPlan): DispatchPlan {
const fallbackTargets = fallback?.targets ?? [];
const targets = ensureArray(raw.targets as Partial<DispatchPlanTarget>[] | undefined, fallbackTargets)
@@ -1598,17 +1688,12 @@ function normalizeDispatchPlan(raw: Partial<DispatchPlan>, fallback?: DispatchPl
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,
confirmedTargetProjectIds: (() => {
const values = normalizeStringSet(
ensureArray(raw.confirmedTargetProjectIds, fallback?.confirmedTargetProjectIds ?? []),
);
return values.length > 0 ? values : undefined;
})(),
};
}
@@ -3707,6 +3792,7 @@ export async function createDispatchPlan(input: {
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"),
@@ -3714,7 +3800,7 @@ export async function createDispatchPlan(input: {
requestMessageId,
requestedBy,
status: "pending_user_confirmation",
targets,
targets: validatedTargets,
summary: input.summary?.trim() ?? "",
createdAt: nowIso(),
};
@@ -3742,25 +3828,19 @@ export async function confirmDispatchPlan(input: {
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()),
);
requireDispatchActorSession(state, confirmedBy);
const approvedTargetProjectIds = normalizeStringSet(input.approvedTargetProjectIds);
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))
) {
const canonicalTargetProjectIds = normalizeStringSet(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")
) {
if (plan.confirmedTargetProjectIds?.length && !sameStringSet(plan.confirmedTargetProjectIds, approvedTargetProjectIds)) {
throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH");
}
@@ -3778,7 +3858,6 @@ export async function confirmDispatchPlan(input: {
export async function createDispatchExecutionsFromPlan(input: {
planId: string;
approvedTargetProjectIds?: string[];
confirmedBy: string;
}) {
return mutateState((state) => {
@@ -3787,6 +3866,7 @@ export async function createDispatchExecutionsFromPlan(input: {
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");
requireDispatchActorSession(state, confirmedBy);
if (!plan.confirmedAt || !plan.confirmedBy || !plan.confirmedTargetProjectIds?.length) {
throw new Error("DISPATCH_PLAN_NOT_CONFIRMED");
}
@@ -3794,13 +3874,11 @@ export async function createDispatchExecutionsFromPlan(input: {
throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH");
}
const canonicalTargetProjectIds = plan.confirmedTargetProjectIds;
const canonicalTargetProjectIds = normalizeStringSet(plan.confirmedTargetProjectIds);
const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId);
if (existingExecutions.length > 0) {
const existingTargetIds = dedupeStrings(
existingExecutions.map((execution) => execution.targetProjectId),
);
if (existingTargetIds.join("\u0000") !== canonicalTargetProjectIds.join("\u0000")) {
const existingTargetIds = normalizeStringSet(existingExecutions.map((execution) => execution.targetProjectId));
if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) {
throw new Error("DISPATCH_EXECUTION_SET_MISMATCH");
}
if (plan.status !== "dispatched") {
@@ -3837,19 +3915,28 @@ export async function createDispatchExecutionsFromPlan(input: {
export async function completeDispatchExecution(payload: {
executionId: string;
deviceId: string;
completedByDeviceId: string;
completedByDeviceToken: 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();
const deviceId = payload.completedByDeviceId.trim();
if (!deviceId) throw new Error("DISPATCH_EXECUTION_DEVICE_REQUIRED");
if (execution.deviceId !== deviceId) {
throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH");
}
const device = state.devices.find((item) => item.id === deviceId);
if (!device || device.source !== "production") {
throw new Error("DISPATCH_EXECUTION_DEVICE_INVALID");
}
if (!device.token || device.token !== payload.completedByDeviceToken.trim()) {
throw new Error("DISPATCH_EXECUTION_DEVICE_TOKEN_INVALID");
}
const nextResultMessageId = payload.resultMessageId?.trim() || undefined;
if (execution.status === "completed" || execution.status === "failed") {
const sameStatus = execution.status === payload.status;