Files
boss/tests/dispatch-plan-confirmation.test.ts

647 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
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 updateDispatchReminderRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-reminder/route"))["PATCH"];
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"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE = "";
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
async function setup() {
if (runtimeRoot) {
return;
}
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-task4-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, 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/app/api/v1/projects/[projectId]/dispatch-reminder/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
postMessageRoute = messageModule.POST;
getDispatchPlansRoute = plansModule.GET;
confirmDispatchPlanRoute = confirmModule.POST;
rejectDispatchPlanRoute = rejectModule.POST;
retryDispatchPlanRoute = retryModule.POST;
updateDispatchReminderRoute = reminderModule.PATCH;
createAuthSession = data.createAuthSession;
createProjectGroupChat = data.createProjectGroupChat;
isDispatchableThreadProject = data.isDispatchableThreadProject;
readState = data.readState;
writeState = data.writeState;
baseState = structuredClone(await readState());
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
await writeState(structuredClone(baseState));
});
function buildDispatchableThreadProject({
id,
projectName,
threadDisplayName,
body,
}: {
id: string;
projectName: string;
threadDisplayName: string;
body: string;
}) {
return {
id,
name: projectName,
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: body,
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: id,
threadId: `thread-${id}`,
threadDisplayName,
folderName: "阻塞梳理",
activityIconCount: 0,
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: `thread-${id}`,
codexFolderRef: id,
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: `msg-${id}`,
sender: "device" as const,
senderLabel: "Win GPU / Codex",
body,
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
],
goals: [],
versions: [],
};
}
async function createAuthedRequest(url: string, method: "GET" | "POST" | "PATCH", body?: unknown) {
const session = await createAuthSession({
account: "krisolo",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(url, {
method,
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: body ? JSON.stringify(body) : undefined,
});
}
async function ensureTwoSingleThreadProjects() {
const state = await readState();
const singles = state.projects.filter((project) => isDispatchableThreadProject(project));
if (singles.length >= 2) {
return singles;
}
const freshProjects = [
buildDispatchableThreadProject({
id: "dispatch-confirm-a",
projectName: "北区试产线主线程",
threadDisplayName: "北区试产线回归",
body: "这里还在等待主 Agent 汇总阻塞点。",
}),
buildDispatchableThreadProject({
id: "dispatch-confirm-b",
projectName: "南区试产线主线程",
threadDisplayName: "南区试产线回归",
body: "这里还在等待视觉链路复核。",
}),
];
await writeState({
...state,
projects: state.projects.concat(freshProjects),
});
const nextState = await readState();
return nextState.projects.filter((project) => isDispatchableThreadProject(project));
}
async function createDispatchPlanForTest() {
await setup();
const memberProjects = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: memberProjects[0].id,
memberProjectIds: [memberProjects[1].id],
createdBy: "krisolo",
});
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/messages`,
"POST",
{ body: "请主 Agent 推荐要先同步的线程" },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
dispatchPlan:
| {
planId: string;
targets: Array<{ projectId: string }>;
orchestrationBackendId?: string;
orchestrationBackendLabel?: string;
}
| null;
};
assert.ok(payload.dispatchPlan, "expected seeded dispatch plan");
assert.equal(payload.dispatchPlan?.orchestrationBackendId, "boss-native-orchestrator");
assert.equal(payload.dispatchPlan?.orchestrationBackendLabel, "Boss Native Orchestrator");
return { groupProject, dispatchPlan: payload.dispatchPlan };
}
async function createMasterAgentDispatchPlanForTest() {
await setup();
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected dispatchable single-thread projects");
const response = await postMessageRoute(
await createAuthedRequest(
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
"POST",
{ body: "请操作真实线程先让南区试产线回归只回复主Agent确认链路正常" },
),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
dispatchPlan:
| {
planId: string;
targets: Array<{ projectId: string }>;
orchestrationBackendId?: string;
orchestrationBackendLabel?: string;
}
| null;
};
assert.ok(payload.dispatchPlan, "expected master-agent dispatch plan");
return { dispatchPlan: payload.dispatchPlan };
}
test("GET /api/v1/projects/[projectId]/dispatch-plans lists the latest group dispatch plans", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const response = await getDispatchPlansRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans`,
"GET",
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plans: Array<{ planId: string; requestMessageId: string; status: string }>;
};
assert.equal(payload.ok, true);
assert.equal(payload.plans[0]?.planId, dispatchPlan.planId);
assert.equal(payload.plans[0]?.status, "pending_user_confirmation");
});
test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms targets, creates executions, and writes a master-agent notice", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plan: {
planId: string;
status: string;
confirmedTargetProjectIds: string[];
orchestrationBackendId?: string;
orchestrationBackendLabel?: string;
};
executions: Array<{
planId: string;
targetProjectId: string;
status: string;
orchestrationBackendId?: string;
orchestrationBackendLabel?: string;
}>;
notice: { kind: string; body: string } | null;
collaborationGate: {
isGroup: boolean;
collaborationMode: string;
requiresMasterAgentApproval: boolean;
approvalState: string;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.plan.planId, dispatchPlan.planId);
assert.equal(payload.plan.status, "dispatched");
assert.deepEqual(payload.plan.confirmedTargetProjectIds, [approvedTargetProjectId]);
assert.equal(payload.executions.length, 1);
assert.equal(payload.executions[0]?.planId, dispatchPlan.planId);
assert.equal(payload.executions[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.executions[0]?.status, "queued");
assert.ok(payload.notice, "expected a confirmation notice in the response");
assert.equal(payload.notice?.kind, "system_notice");
assert.equal(payload.notice?.body, "已确认下发到 1 个线程:《北区试产线回归》。");
assert.equal(payload.collaborationGate.isGroup, true);
assert.equal(payload.collaborationGate.collaborationMode, "development");
assert.equal(payload.collaborationGate.requiresMasterAgentApproval, false);
assert.equal(payload.collaborationGate.approvalState, "not_required");
const nextState = await readState();
const notice = nextState.projects
.find((project) => project.id === groupProject.id)
?.messages.find(
(message) =>
message.sender === "master" &&
message.kind === "system_notice" &&
message.body.includes("已确认下发到 1 个线程"),
);
assert.ok(notice, "expected a master-agent notice in the group chat after confirmation");
const confirmedPlan = nextState.dispatchPlans.find((plan) => plan.planId === dispatchPlan.planId);
assert.ok(confirmedPlan, "expected confirmed dispatch plan in state");
assert.equal(confirmedPlan?.orchestrationBackendId, "boss-native-orchestrator");
assert.equal(confirmedPlan?.orchestrationBackendLabel, "Boss Native Orchestrator");
const createdExecution = nextState.dispatchExecutions.find((item) => item.planId === dispatchPlan.planId);
assert.ok(createdExecution, "expected dispatch execution in state");
assert.equal(createdExecution?.orchestrationBackendId, "boss-native-orchestrator");
assert.equal(createdExecution?.orchestrationBackendLabel, "Boss Native Orchestrator");
const executionTask = nextState.masterAgentTasks.find(
(task) =>
task.taskType === "dispatch_execution" &&
task.projectId === groupProject.id &&
task.targetProjectId === approvedTargetProjectId,
);
assert.ok(executionTask, "expected queued dispatch execution task");
assert.equal(executionTask?.orchestrationBackendId, "boss-native-orchestrator");
assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator");
});
test("confirming a dispatch plan with rememberLightReminder persists the group reminder preference", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{
approvedTargetProjectIds: [approvedTargetProjectId],
rememberLightReminder: true,
},
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
collaborationGate: {
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.collaborationGate.lightDispatchReminderEnabled, true);
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.lightDispatchReminderEnabled, true);
});
test("master-agent dispatch plans can also be confirmed and create queued executions", async () => {
const { dispatchPlan } = await createMasterAgentDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/master-agent/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: "master-agent", planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plan: { status: string; confirmedTargetProjectIds: string[] };
executions: Array<{ targetProjectId: string; status: string }>;
notice: { body: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.plan.status, "dispatched");
assert.deepEqual(payload.plan.confirmedTargetProjectIds, [approvedTargetProjectId]);
assert.equal(payload.executions.length, 1);
assert.equal(payload.executions[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.executions[0]?.status, "queued");
assert.match(payload.notice?.body ?? "", /已确认下发到 1 个线程/);
});
test("confirm rejects targets whose device is offline before creating queued executions", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
const targetDeviceId = dispatchPlan.targets[0]?.deviceId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
assert.ok(targetDeviceId, "expected a target device");
const state = await readState();
await writeState({
...state,
devices: state.devices.map((device) =>
device.id === targetDeviceId
? {
...device,
status: "offline" as const,
}
: device,
),
});
const response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; code: string; message: string };
assert.equal(payload.ok, false);
assert.equal(payload.code, "DISPATCH_TARGET_DEVICE_OFFLINE");
assert.equal(payload.message, "目标线程所在设备当前不在线,请先让设备上线后再确认下发。");
const nextState = await readState();
const createdExecution = nextState.dispatchExecutions.find((item) => item.planId === dispatchPlan.planId);
assert.equal(createdExecution, undefined);
});
test("confirming a dispatch plan marks approval_required groups as approved", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
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 response = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.approvalState, "approved");
});
test("rejecting a dispatch plan marks approval_required groups as rejected and writes a system notice", 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 response = 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(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plan: { planId: string; status: string };
notice: { kind: string; body: string };
collaborationGate: {
isGroup: boolean;
collaborationMode: string;
requiresMasterAgentApproval: boolean;
approvalState: string;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.plan.planId, dispatchPlan.planId);
assert.equal(payload.plan.status, "rejected");
assert.equal(payload.notice.kind, "system_notice");
assert.equal(payload.notice.body, "已拒绝主 Agent 推荐,本次不会下发到任何线程。");
assert.equal(payload.collaborationGate.isGroup, true);
assert.equal(payload.collaborationGate.collaborationMode, "approval_required");
assert.equal(payload.collaborationGate.requiresMasterAgentApproval, true);
assert.equal(payload.collaborationGate.approvalState, "rejected");
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.approvalState, "rejected");
const notice = nextGroupProject?.messages.find(
(message) =>
message.kind === "system_notice" &&
message.body.includes("已拒绝主 Agent 推荐"),
);
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");
});
test("PATCH /api/v1/projects/[projectId]/dispatch-reminder updates the per-group light reminder preference", async () => {
const { groupProject } = await createDispatchPlanForTest();
const enableResponse = await updateDispatchReminderRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-reminder`,
"PATCH",
{ lightDispatchReminderEnabled: true },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(enableResponse.status, 200);
const enablePayload = (await enableResponse.json()) as {
ok: boolean;
project: {
id: string;
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(enablePayload.ok, true);
assert.equal(enablePayload.project.id, groupProject.id);
assert.equal(enablePayload.project.lightDispatchReminderEnabled, true);
const disableResponse = await updateDispatchReminderRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-reminder`,
"PATCH",
{ lightDispatchReminderEnabled: false },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(disableResponse.status, 200);
const disablePayload = (await disableResponse.json()) as {
ok: boolean;
project: {
id: string;
lightDispatchReminderEnabled?: boolean;
};
collaborationGate: {
lightDispatchReminderEnabled?: boolean;
};
};
assert.equal(disablePayload.ok, true);
assert.equal(disablePayload.project.id, groupProject.id);
assert.equal(disablePayload.project.lightDispatchReminderEnabled, false);
assert.equal(disablePayload.collaborationGate.lightDispatchReminderEnabled, false);
const nextState = await readState();
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
assert.ok(nextGroupProject, "expected group project to remain present");
assert.equal(nextGroupProject?.lightDispatchReminderEnabled, false);
});