fix: tighten dispatch retry validation
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user