feat: add dispatch retry and import recovery flows

This commit is contained in:
kris
2026-03-31 22:10:03 +08:00
parent be31503d22
commit dcbff3cc7d
15 changed files with 776 additions and 23 deletions

View File

@@ -5564,42 +5564,86 @@ function upsertDeviceImportDraftFromHeartbeat(
candidates: DeviceImportCandidate[];
},
) {
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
if (payload.candidates.length === 0) {
return null;
if (existing?.status === "applied" && existing.appliedProjectNames.length > 0) {
return existing;
}
const waitingDraft = normalizeDeviceImportDraft({
draftId: existing?.draftId ?? randomToken("import-draft"),
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status: "pending_candidates",
candidates: [],
selectedCandidateIds: [],
appliedProjectNames: [],
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
}, existing);
waitingDraft.reviewedAt = undefined;
waitingDraft.reviewedBy = undefined;
waitingDraft.resolutionId = undefined;
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== waitingDraft.draftId,
);
state.deviceImportDrafts = [
waitingDraft,
...state.deviceImportDrafts.filter((item) => item.draftId !== waitingDraft.draftId),
];
return waitingDraft;
}
const existing = state.deviceImportDrafts.find((item) => item.deviceId === payload.deviceId);
const selectedCandidateIds = dedupeStrings(
(existing?.selectedCandidateIds ?? []).filter((candidateId) =>
payload.candidates.some((candidate) => candidate.candidateId === candidateId),
),
);
const previousCandidateIds = existing?.candidates.map((candidate) => candidate.candidateId) ?? [];
const nextCandidateIds = payload.candidates.map((candidate) => candidate.candidateId);
const selectionChanged =
!sameStringSet(existing?.selectedCandidateIds ?? [], selectedCandidateIds) ||
!sameStringSet(previousCandidateIds, nextCandidateIds);
const keepAppliedState =
!selectionChanged &&
existing?.status === "applied" &&
Boolean(existing.resolutionId) &&
selectedCandidateIds.length > 0;
const keepResolvedState =
!selectionChanged &&
selectedCandidateIds.length > 0 &&
Boolean(existing?.resolutionId);
const nextDraft = normalizeDeviceImportDraft({
draftId: existing?.draftId ?? randomToken("import-draft"),
deviceId: payload.deviceId,
enrollmentId: payload.enrollmentId ?? existing?.enrollmentId,
status:
existing?.status === "applied" && existing.resolutionId && selectedCandidateIds.length > 0
keepAppliedState
? "applied"
: selectedCandidateIds.length > 0
? existing?.resolutionId
? keepResolvedState
? "resolved"
: "pending_resolution"
: "pending_selection",
candidates: payload.candidates,
selectedCandidateIds,
appliedProjectNames:
existing?.status === "applied" && selectedCandidateIds.length > 0
keepAppliedState
? existing.appliedProjectNames
: [],
createdAt: existing?.createdAt ?? nowIso(),
updatedAt: nowIso(),
reviewedAt: existing?.reviewedAt,
reviewedBy: existing?.reviewedBy,
resolutionId: existing?.resolutionId,
reviewedAt: keepResolvedState || keepAppliedState ? existing?.reviewedAt : undefined,
reviewedBy: keepResolvedState || keepAppliedState ? existing?.reviewedBy : undefined,
resolutionId: keepResolvedState || keepAppliedState ? existing?.resolutionId : undefined,
}, existing);
if (!keepResolvedState && !keepAppliedState) {
state.deviceImportResolutions = state.deviceImportResolutions.filter(
(item) => item.draftId !== nextDraft.draftId,
);
}
state.deviceImportDrafts = [
nextDraft,
...state.deviceImportDrafts.filter((item) => item.draftId !== nextDraft.draftId),
@@ -5652,7 +5696,8 @@ export async function upsertDeviceHeartbeat(payload: {
suggestedImport: candidate.suggestedImport ?? true,
}),
);
const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0;
const reportedProjectCandidates = Array.isArray(payload.projectCandidates);
const shouldAutoImportLegacyProjects = !reportedProjectCandidates && normalizedCandidates.length === 0;
let device = existingDevice;
if (!device) {
@@ -5969,11 +6014,8 @@ export async function selectDeviceImportCandidates(input: {
const nextSelected = dedupeStrings(input.selectedCandidateIds).filter((candidateId) =>
availableCandidateIds.has(candidateId),
);
if (nextSelected.length === 0) {
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
}
draft.selectedCandidateIds = nextSelected;
draft.status = "pending_resolution";
draft.status = nextSelected.length > 0 ? "pending_resolution" : "pending_selection";
draft.appliedProjectNames = [];
draft.updatedAt = nowIso();
draft.reviewedBy = input.selectedBy;
@@ -6202,6 +6244,23 @@ function applyDeviceImportResolutionInState(
const device = state.devices.find((item) => item.id === input.deviceId);
if (!device) throw new Error("DEVICE_NOT_FOUND");
if (draft.status === "applied" && resolution.status === "applied") {
const importedProjects = state.projects.filter(
(project) =>
!project.isGroup &&
project.deviceIds.includes(device.id) &&
draft.appliedProjectNames.includes(project.name),
);
return {
draft: { ...draft },
resolution: { ...resolution },
importedProjects: importedProjects.map((project) => ({ ...project })),
};
}
if (draft.status !== "resolved") {
throw new Error("DEVICE_IMPORT_RESOLUTION_STALE");
}
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);