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