feat: add dispatch retry and import recovery flows
This commit is contained in:
@@ -426,6 +426,118 @@ test("device import apply is idempotent and heartbeat preserves applied status",
|
||||
assert.equal(appliedDraft?.status, "applied", "later heartbeats should not regress applied drafts");
|
||||
});
|
||||
|
||||
test("clearing device import selection resets draft back to pending_selection and drops old resolution", async () => {
|
||||
await setup();
|
||||
|
||||
const enrollmentResponse = await createEnrollmentRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
endpoint: "mac://review.local",
|
||||
note: "selection reset",
|
||||
}),
|
||||
);
|
||||
assert.equal(enrollmentResponse.status, 200);
|
||||
const enrollmentPayload = (await enrollmentResponse.json()) as {
|
||||
enrollment: { pairingCode: string };
|
||||
device: { id: string };
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await deviceHeartbeatRoute(
|
||||
new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deviceId: enrollmentPayload.device.id,
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
status: "online",
|
||||
quota5h: 66,
|
||||
quota7d: 79,
|
||||
projects: [],
|
||||
endpoint: "mac://review.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "回归目录",
|
||||
folderRef: "review-folder",
|
||||
threadId: "thread-review-1",
|
||||
threadDisplayName: "回归线程一",
|
||||
codexFolderRef: "review-folder",
|
||||
codexThreadRef: "thread-review-1",
|
||||
lastActiveAt: "2026-03-30T11:00:00+08:00",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const draftResponse = await getImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
|
||||
"GET",
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
const draftPayload = (await draftResponse.json()) as {
|
||||
draft: { candidates: Array<{ candidateId: string }> };
|
||||
};
|
||||
const selectedCandidateIds = draftPayload.draft.candidates.map((candidate) => candidate.candidateId);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await reviewImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const clearSelectionResponse = await selectImportDraftRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`,
|
||||
"POST",
|
||||
{ selectedCandidateIds: [] },
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
|
||||
);
|
||||
assert.equal(clearSelectionResponse.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const draft = nextState.deviceImportDrafts.find((item) => item.deviceId === enrollmentPayload.device.id);
|
||||
const resolution = nextState.deviceImportResolutions.find((item) => item.deviceId === enrollmentPayload.device.id);
|
||||
assert.equal(draft?.status, "pending_selection");
|
||||
assert.deepEqual(draft?.selectedCandidateIds, []);
|
||||
assert.equal(draft?.resolutionId, undefined);
|
||||
assert.equal(resolution, undefined);
|
||||
});
|
||||
|
||||
test("device import routes reject unrelated logged-in members", async () => {
|
||||
await setup();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/mes
|
||||
let getDispatchPlansRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route"))["GET"];
|
||||
let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"];
|
||||
let rejectDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route"))["POST"];
|
||||
let retryDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"];
|
||||
let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"];
|
||||
@@ -26,11 +27,12 @@ async function setup() {
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [messageModule, plansModule, confirmModule, rejectModule, data, auth] = await Promise.all([
|
||||
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
@@ -39,6 +41,7 @@ async function setup() {
|
||||
getDispatchPlansRoute = plansModule.GET;
|
||||
confirmDispatchPlanRoute = confirmModule.POST;
|
||||
rejectDispatchPlanRoute = rejectModule.POST;
|
||||
retryDispatchPlanRoute = retryModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
createProjectGroupChat = data.createProjectGroupChat;
|
||||
isDispatchableThreadProject = data.isDispatchableThreadProject;
|
||||
@@ -317,3 +320,65 @@ test("rejecting a dispatch plan marks approval_required groups as rejected and w
|
||||
);
|
||||
assert.ok(notice, "expected rejection notice in group chat");
|
||||
});
|
||||
|
||||
test("retrying a rejected dispatch plan creates a fresh pending recommendation and resets approval gate", async () => {
|
||||
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
|
||||
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === groupProject.id
|
||||
? {
|
||||
...project,
|
||||
collaborationMode: "approval_required" as const,
|
||||
approvalState: "pending_user" as const,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
});
|
||||
|
||||
const rejectResponse = await rejectDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/reject`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
|
||||
);
|
||||
assert.equal(rejectResponse.status, 200);
|
||||
|
||||
const retryResponse = await retryDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/retry`,
|
||||
"POST",
|
||||
{},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
|
||||
);
|
||||
assert.equal(retryResponse.status, 200);
|
||||
|
||||
const retryPayload = (await retryResponse.json()) as {
|
||||
ok: boolean;
|
||||
dispatchPlan: { planId: string; status: string; requestMessageId: string } | null;
|
||||
collaborationGate: {
|
||||
approvalState: string;
|
||||
requiresMasterAgentApproval: boolean;
|
||||
collaborationMode: string;
|
||||
};
|
||||
};
|
||||
assert.equal(retryPayload.ok, true);
|
||||
assert.ok(retryPayload.dispatchPlan, "expected a fresh dispatch recommendation");
|
||||
assert.notEqual(retryPayload.dispatchPlan?.planId, dispatchPlan.planId);
|
||||
assert.equal(retryPayload.dispatchPlan?.status, "pending_user_confirmation");
|
||||
assert.match(retryPayload.dispatchPlan?.requestMessageId ?? "", /:retry:/);
|
||||
assert.equal(retryPayload.collaborationGate.collaborationMode, "approval_required");
|
||||
assert.equal(retryPayload.collaborationGate.requiresMasterAgentApproval, true);
|
||||
assert.equal(retryPayload.collaborationGate.approvalState, "pending_user");
|
||||
|
||||
const nextState = await readState();
|
||||
const refreshedPlan = nextState.dispatchPlans.find((plan) => plan.planId === retryPayload.dispatchPlan?.planId);
|
||||
assert.ok(refreshedPlan, "expected retried dispatch plan in state");
|
||||
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
|
||||
assert.equal(nextGroupProject?.approvalState, "pending_user");
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractApprovedTargetProjectIds,
|
||||
latestRejectedDispatchPlan,
|
||||
latestPendingDispatchPlan,
|
||||
summarizeDispatchPlan,
|
||||
} from "@/lib/dispatch-plan-ui";
|
||||
@@ -54,3 +55,27 @@ test("latestPendingDispatchPlan returns the latest waiting confirmation item", (
|
||||
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("latestRejectedDispatchPlan returns the latest rejected item", () => {
|
||||
const plan = latestRejectedDispatchPlan([
|
||||
{
|
||||
planId: "dispatch-plan-1",
|
||||
status: "dispatched",
|
||||
summary: "已完成的推荐",
|
||||
targets: [{ projectId: "p1", threadDisplayName: "Boss UI 主线程" }],
|
||||
},
|
||||
{
|
||||
planId: "dispatch-plan-3",
|
||||
status: "rejected",
|
||||
summary: "已拒绝的推荐",
|
||||
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
planId: "dispatch-plan-3",
|
||||
status: "rejected",
|
||||
summary: "已拒绝的推荐",
|
||||
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user