305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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 data: typeof import("../src/lib/boss-data");
|
|
let authCookie = "";
|
|
let postScan: (typeof import("../src/app/api/v1/admin/risks/scan/route"))["POST"];
|
|
let getOverview: (typeof import("../src/app/api/v1/admin/overview/route"))["GET"];
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-risk-sla-notifications-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
const [dataModule, authModule, scanRoute, overviewRoute] = await Promise.all([
|
|
import("../src/lib/boss-data.ts"),
|
|
import("../src/lib/boss-auth.ts"),
|
|
import("../src/app/api/v1/admin/risks/scan/route.ts"),
|
|
import("../src/app/api/v1/admin/overview/route.ts"),
|
|
]);
|
|
data = dataModule;
|
|
authCookie = authModule.AUTH_SESSION_COOKIE;
|
|
postScan = scanRoute.POST;
|
|
getOverview = overviewRoute.GET;
|
|
}
|
|
|
|
test.after(async () => {
|
|
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
await setup();
|
|
const now = "2026-04-27T17:30:00+08:00";
|
|
const state = await data.readState();
|
|
await data.writeState({
|
|
...state,
|
|
adminNotifications: [],
|
|
authAccounts: [
|
|
{
|
|
id: "account-owner",
|
|
account: "owner@example.com",
|
|
passwordHash: "hash",
|
|
displayName: "平台管理员",
|
|
role: "highest_admin",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
{
|
|
id: "account-customer",
|
|
account: "customer@example.com",
|
|
passwordHash: "hash",
|
|
displayName: "客户负责人",
|
|
role: "admin",
|
|
companyId: "tenant-a",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
],
|
|
devices: [
|
|
{
|
|
id: "mac-a",
|
|
name: "客户 Mac",
|
|
avatar: "M",
|
|
account: "customer@example.com",
|
|
companyId: "tenant-a",
|
|
source: "production",
|
|
status: "online",
|
|
projects: ["project-a"],
|
|
quota5h: 0,
|
|
quota7d: 0,
|
|
lastSeenAt: now,
|
|
},
|
|
],
|
|
projects: [],
|
|
opsFaults: [
|
|
{
|
|
faultId: "fault-overdue",
|
|
faultKey: "LOCAL_AGENT.OFFLINE",
|
|
severity: "critical",
|
|
status: "opened",
|
|
nodeId: "mac-a",
|
|
serviceName: "local-agent",
|
|
projectId: "project-a",
|
|
traceId: "trace-overdue",
|
|
runbookId: "runbook-local-agent",
|
|
firstSeenAt: "2026-04-27T12:00:00+08:00",
|
|
lastSeenAt: "2026-04-27T13:00:00+08:00",
|
|
ownerAccount: "customer@example.com",
|
|
slaDueAt: "2026-04-27T14:00:00+08:00",
|
|
summary: "local-agent 离线超过 SLA",
|
|
suggestedNextAction: "联系客户重启本地节点",
|
|
autoRepairable: false,
|
|
},
|
|
],
|
|
threadContextAlerts: [],
|
|
});
|
|
});
|
|
|
|
async function adminRequest(url: string, init: RequestInit = {}) {
|
|
const session = await data.createAuthSession({
|
|
account: "owner@example.com",
|
|
role: "highest_admin",
|
|
displayName: "平台管理员",
|
|
loginMethod: "password",
|
|
});
|
|
return new NextRequest(url, {
|
|
...init,
|
|
headers: {
|
|
...(init.headers ?? {}),
|
|
cookie: `${authCookie}=${session.sessionToken}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
test("risk SLA scan creates idempotent overdue notifications", async () => {
|
|
const first = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
assert.equal(first.status, 200);
|
|
const firstPayload = await first.json();
|
|
assert.equal(firstPayload.created.length, 1);
|
|
assert.equal(firstPayload.notifications[0].kind, "risk_sla_overdue");
|
|
assert.equal(firstPayload.notifications[0].companyId, "tenant-a");
|
|
|
|
const second = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
assert.equal(second.status, 200);
|
|
const secondPayload = await second.json();
|
|
assert.equal(secondPayload.created.length, 0);
|
|
assert.equal(secondPayload.notifications.length, 1);
|
|
});
|
|
|
|
test("admin overview includes open risk notifications", async () => {
|
|
await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", { method: "POST" }));
|
|
|
|
const response = await getOverview(await adminRequest("http://127.0.0.1:3000/api/v1/admin/overview"));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
|
|
assert.equal(payload.summary.openNotifications, 1);
|
|
assert.equal(payload.notifications[0].riskId, "ops-fault:fault-overdue");
|
|
});
|
|
|
|
test("risk scan creates operational faults for computer use and boss-agent OTA failures", async () => {
|
|
const state = await data.readState();
|
|
await data.writeState({
|
|
...state,
|
|
adminNotifications: [],
|
|
opsFaults: [],
|
|
devices: [
|
|
{
|
|
id: "mac-a",
|
|
name: "客户 Mac",
|
|
avatar: "M",
|
|
account: "customer@example.com",
|
|
companyId: "tenant-a",
|
|
source: "production",
|
|
status: "online",
|
|
projects: ["project-a"],
|
|
quota5h: 0,
|
|
quota7d: 0,
|
|
lastSeenAt: "2026-04-27T17:20:00+08:00",
|
|
capabilities: {
|
|
gui: { connected: true },
|
|
cli: { connected: true },
|
|
browserAutomation: { connected: true },
|
|
computerUse: { connected: false },
|
|
codexAppServer: { connected: true },
|
|
},
|
|
},
|
|
],
|
|
appLogs: [
|
|
{
|
|
logId: "log-ota-failed",
|
|
deviceId: "mac-a",
|
|
level: "error",
|
|
source: "local_agent",
|
|
category: "local_agent.boss_agent_ota_failed",
|
|
message: "boss-agent OTA 更新失败",
|
|
detail: "BOSS_AGENT_OTA_CHECKSUM_MISMATCH",
|
|
mirroredToProject: false,
|
|
createdAt: "2026-04-27T17:21:00+08:00",
|
|
},
|
|
],
|
|
});
|
|
|
|
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.createdFaults.length, 2);
|
|
assert.equal(payload.createdFaults.some((fault: { faultKey: string }) => fault.faultKey === "BOSS.COMPUTER_USE.UNAVAILABLE"), true);
|
|
assert.equal(payload.createdFaults.some((fault: { faultKey: string }) => fault.faultKey === "BOSS_AGENT.OTA.FAILED"), true);
|
|
|
|
const nextState = await data.readState();
|
|
assert.equal(nextState.opsFaults.some((fault) => fault.faultKey === "BOSS.COMPUTER_USE.UNAVAILABLE"), true);
|
|
assert.equal(nextState.opsFaults.some((fault) => fault.faultKey === "BOSS_AGENT.OTA.FAILED"), true);
|
|
|
|
const second = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
const secondPayload = await second.json();
|
|
assert.equal(secondPayload.createdFaults.length, 0);
|
|
});
|
|
|
|
test("risk scan creates SLA notifications for stuck master agent tasks", async () => {
|
|
const state = await data.readState();
|
|
await data.writeState({
|
|
...state,
|
|
adminNotifications: [],
|
|
masterAgentTasks: [
|
|
{
|
|
taskId: "task-stuck",
|
|
projectId: "project-a",
|
|
taskType: "conversation_reply",
|
|
requestMessageId: "msg-stuck",
|
|
requestText: "让线程继续执行",
|
|
executionPrompt: "继续执行并回写结果",
|
|
requestedBy: "客户负责人",
|
|
requestedByAccount: "customer@example.com",
|
|
deviceId: "mac-a",
|
|
status: "running",
|
|
phase: "awaiting_reply",
|
|
requestedAt: "2026-04-27T13:00:00+08:00",
|
|
claimedAt: "2026-04-27T13:01:00+08:00",
|
|
lastProgressAt: "2026-04-27T13:01:00+08:00",
|
|
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
|
|
attemptCount: 1,
|
|
maxAttempts: 2,
|
|
},
|
|
],
|
|
});
|
|
|
|
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(
|
|
payload.created.some((notification: { riskId: string }) => notification.riskId === "master-task:task-stuck"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
payload.notifications.some((notification: { title: string }) => notification.title.includes("任务 SLA")),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("risk scan automatically requeues safely recoverable master agent tasks", async () => {
|
|
const state = await data.readState();
|
|
await data.writeState({
|
|
...state,
|
|
adminNotifications: [],
|
|
permissionAuditLogs: [],
|
|
adminRiskTimeline: [],
|
|
masterAgentTasks: [
|
|
{
|
|
taskId: "task-recoverable",
|
|
projectId: "project-a",
|
|
taskType: "conversation_reply",
|
|
requestMessageId: "msg-recoverable",
|
|
requestText: "继续处理",
|
|
executionPrompt: "继续处理并回写结果",
|
|
requestedBy: "客户负责人",
|
|
requestedByAccount: "customer@example.com",
|
|
deviceId: "mac-a",
|
|
status: "running",
|
|
phase: "recoverable_failed",
|
|
requestedAt: "2026-04-27T13:00:00+08:00",
|
|
claimedAt: "2026-04-27T13:01:00+08:00",
|
|
lastProgressAt: "2026-04-27T13:02:00+08:00",
|
|
leaseExpiresAt: "2026-04-27T13:16:00+08:00",
|
|
attemptCount: 1,
|
|
maxAttempts: 2,
|
|
recoverable: true,
|
|
nextRetryAt: "2026-04-27T13:03:00+08:00",
|
|
lastErrorCode: "RECOVERABLE_RUNTIME_FAILURE",
|
|
errorMessage: "CODEX_APP_SERVER_TIMEOUT",
|
|
},
|
|
],
|
|
});
|
|
|
|
const response = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
|
method: "POST",
|
|
}));
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.autoRecovered.length, 1);
|
|
assert.equal(payload.autoRecovered[0].taskId, "task-recoverable");
|
|
|
|
const nextState = await data.readState();
|
|
const task = nextState.masterAgentTasks.find((item) => item.taskId === "task-recoverable");
|
|
assert.equal(task?.status, "queued");
|
|
assert.equal(task?.phase, "queued");
|
|
assert.equal(task?.recoverable, false);
|
|
assert.equal(nextState.permissionAuditLogs.some((log) => log.action === "master_agent.task_retried"), true);
|
|
assert.equal(nextState.adminRiskTimeline.some((event) => event.action === "task.auto_recovery_requeued"), true);
|
|
});
|