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; 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( function normalizedDispatchPlanTarget(
raw: Partial<DispatchPlanTarget>, raw: Partial<DispatchPlanTarget>,
fallback?: 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 { function normalizeDispatchPlan(raw: Partial<DispatchPlan>, fallback?: DispatchPlan): DispatchPlan {
const fallbackTargets = fallback?.targets ?? []; const fallbackTargets = fallback?.targets ?? [];
const targets = ensureArray(raw.targets as Partial<DispatchPlanTarget>[] | undefined, fallbackTargets) 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(), createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
confirmedAt: raw.confirmedAt ?? fallback?.confirmedAt, confirmedAt: raw.confirmedAt ?? fallback?.confirmedAt,
confirmedBy: raw.confirmedBy ?? fallback?.confirmedBy, confirmedBy: raw.confirmedBy ?? fallback?.confirmedBy,
confirmedTargetProjectIds: confirmedTargetProjectIds: (() => {
ensureArray( const values = normalizeStringSet(
raw.confirmedTargetProjectIds, ensureArray(raw.confirmedTargetProjectIds, fallback?.confirmedTargetProjectIds ?? []),
fallback?.confirmedTargetProjectIds ?? [], );
).map((projectId) => projectId.trim()).filter(Boolean).length > 0 return values.length > 0 ? values : undefined;
? dedupeStrings( })(),
ensureArray(raw.confirmedTargetProjectIds, fallback?.confirmedTargetProjectIds ?? [])
.map((projectId) => projectId.trim())
.filter(Boolean),
)
: undefined,
}; };
} }
@@ -3707,6 +3792,7 @@ export async function createDispatchPlan(input: {
normalizedDispatchPlanTarget(target, undefined, { allowInvalid: false }), normalizedDispatchPlanTarget(target, undefined, { allowInvalid: false }),
) as DispatchPlanTarget[]; ) as DispatchPlanTarget[];
if (targets.length === 0) throw new Error("DISPATCH_PLAN_TARGETS_REQUIRED"); if (targets.length === 0) throw new Error("DISPATCH_PLAN_TARGETS_REQUIRED");
const validatedTargets = targets.map((target) => validateDispatchTargetAgainstState(state, target));
const plan: DispatchPlan = { const plan: DispatchPlan = {
planId: randomToken("dispatch-plan"), planId: randomToken("dispatch-plan"),
@@ -3714,7 +3800,7 @@ export async function createDispatchPlan(input: {
requestMessageId, requestMessageId,
requestedBy, requestedBy,
status: "pending_user_confirmation", status: "pending_user_confirmation",
targets, targets: validatedTargets,
summary: input.summary?.trim() ?? "", summary: input.summary?.trim() ?? "",
createdAt: nowIso(), createdAt: nowIso(),
}; };
@@ -3742,25 +3828,19 @@ export async function confirmDispatchPlan(input: {
if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED");
const confirmedBy = input.confirmedBy.trim(); const confirmedBy = input.confirmedBy.trim();
if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED");
const approvedTargetProjectIds = dedupeStrings( requireDispatchActorSession(state, confirmedBy);
input.approvedTargetProjectIds.map((projectId) => projectId.trim()), const approvedTargetProjectIds = normalizeStringSet(input.approvedTargetProjectIds);
);
if (approvedTargetProjectIds.length === 0) { if (approvedTargetProjectIds.length === 0) {
throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_REQUIRED"); throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_REQUIRED");
} }
const canonicalTargetProjectIds = dedupeStrings(plan.targets.map((target) => target.projectId)); const canonicalTargetProjectIds = normalizeStringSet(plan.targets.map((target) => target.projectId));
if ( if (approvedTargetProjectIds.some((projectId) => !canonicalTargetProjectIds.includes(projectId))) {
approvedTargetProjectIds.some((projectId) => !canonicalTargetProjectIds.includes(projectId))
) {
throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_INVALID"); throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_INVALID");
} }
if (plan.confirmedBy && plan.confirmedBy !== confirmedBy) { if (plan.confirmedBy && plan.confirmedBy !== confirmedBy) {
throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH");
} }
if ( if (plan.confirmedTargetProjectIds?.length && !sameStringSet(plan.confirmedTargetProjectIds, approvedTargetProjectIds)) {
plan.confirmedTargetProjectIds?.length &&
plan.confirmedTargetProjectIds.join("\u0000") !== approvedTargetProjectIds.join("\u0000")
) {
throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH"); throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH");
} }
@@ -3778,7 +3858,6 @@ export async function confirmDispatchPlan(input: {
export async function createDispatchExecutionsFromPlan(input: { export async function createDispatchExecutionsFromPlan(input: {
planId: string; planId: string;
approvedTargetProjectIds?: string[];
confirmedBy: string; confirmedBy: string;
}) { }) {
return mutateState((state) => { return mutateState((state) => {
@@ -3787,6 +3866,7 @@ export async function createDispatchExecutionsFromPlan(input: {
if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED");
const confirmedBy = input.confirmedBy.trim(); const confirmedBy = input.confirmedBy.trim();
if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED");
requireDispatchActorSession(state, confirmedBy);
if (!plan.confirmedAt || !plan.confirmedBy || !plan.confirmedTargetProjectIds?.length) { if (!plan.confirmedAt || !plan.confirmedBy || !plan.confirmedTargetProjectIds?.length) {
throw new Error("DISPATCH_PLAN_NOT_CONFIRMED"); throw new Error("DISPATCH_PLAN_NOT_CONFIRMED");
} }
@@ -3794,13 +3874,11 @@ export async function createDispatchExecutionsFromPlan(input: {
throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); 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); const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId);
if (existingExecutions.length > 0) { if (existingExecutions.length > 0) {
const existingTargetIds = dedupeStrings( const existingTargetIds = normalizeStringSet(existingExecutions.map((execution) => execution.targetProjectId));
existingExecutions.map((execution) => execution.targetProjectId), if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) {
);
if (existingTargetIds.join("\u0000") !== canonicalTargetProjectIds.join("\u0000")) {
throw new Error("DISPATCH_EXECUTION_SET_MISMATCH"); throw new Error("DISPATCH_EXECUTION_SET_MISMATCH");
} }
if (plan.status !== "dispatched") { if (plan.status !== "dispatched") {
@@ -3837,19 +3915,28 @@ export async function createDispatchExecutionsFromPlan(input: {
export async function completeDispatchExecution(payload: { export async function completeDispatchExecution(payload: {
executionId: string; executionId: string;
deviceId: string; completedByDeviceId: string;
completedByDeviceToken: string;
status: "completed" | "failed"; status: "completed" | "failed";
resultMessageId?: string; resultMessageId?: string;
}) { }) {
return mutateState((state) => { return mutateState((state) => {
const execution = state.dispatchExecutions.find((item) => item.executionId === payload.executionId); const execution = state.dispatchExecutions.find((item) => item.executionId === payload.executionId);
if (!execution) throw new Error("DISPATCH_EXECUTION_NOT_FOUND"); 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 (!deviceId) throw new Error("DISPATCH_EXECUTION_DEVICE_REQUIRED");
if (execution.deviceId !== deviceId) { if (execution.deviceId !== deviceId) {
throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH"); 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; const nextResultMessageId = payload.resultMessageId?.trim() || undefined;
if (execution.status === "completed" || execution.status === "failed") { if (execution.status === "completed" || execution.status === "failed") {
const sameStatus = execution.status === payload.status; const sameStatus = execution.status === payload.status;