Add gui/cli capability conflict guards

This commit is contained in:
kris
2026-04-06 10:22:07 +08:00
parent d04eca4703
commit 27ab594921
5 changed files with 1191 additions and 8 deletions

View File

@@ -0,0 +1,316 @@
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";
let runtimeRoot = "";
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let detectProjectExecutionConflict: (typeof import("../src/lib/boss-data"))["detectProjectExecutionConflict"];
let applyProjectConflictDecision: (typeof import("../src/lib/boss-data"))["applyProjectConflictDecision"];
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
let claimNextMasterAgentTask: (typeof import("../src/lib/boss-data"))["claimNextMasterAgentTask"];
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-conflict-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const data = await import("../src/lib/boss-data.ts");
readState = data.readState;
writeState = data.writeState;
detectProjectExecutionConflict = data.detectProjectExecutionConflict;
applyProjectConflictDecision = data.applyProjectConflictDecision;
queueMasterAgentTask = data.queueMasterAgentTask;
claimNextMasterAgentTask = data.claimNextMasterAgentTask;
completeMasterAgentTask = data.completeMasterAgentTask;
updateDevice = data.updateDevice;
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
await rm(runtimeRoot, { recursive: true, force: true });
});
function buildProjectFolderKey(project: Awaited<ReturnType<typeof readState>>["projects"][number]) {
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
return `${project.deviceIds[0]}:${folderRef}`;
}
async function getCliProject() {
const state = await readState();
let project = state.projects.find(
(item) => !item.isGroup && item.id !== "master-agent" && item.deviceIds.includes("mac-studio"),
);
if (!project) {
project = {
id: "thread-ui",
name: "Boss UI",
pinned: false,
deviceIds: ["mac-studio"],
preview: "线程执行中",
updatedAt: "2026-04-06T10:00:00.000Z",
lastMessageAt: "2026-04-06T10:00:00.000Z",
isGroup: false,
threadMeta: {
projectId: "thread-ui",
threadId: "thread-ui-main",
threadDisplayName: "Boss UI 主线程",
folderName: "boss",
activityIconCount: 1,
updatedAt: "2026-04-06T10:00:00.000Z",
codexThreadRef: "thread-ui-main",
codexFolderRef: "boss",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "medium",
contextBudgetPct: 64,
contextBudgetLabel: "64%",
messages: [],
goals: [],
versions: [],
};
state.projects.push(project);
await writeState(state);
}
return project;
}
test("detectProjectExecutionConflict blocks cli execution when the same folder has new external activity", async () => {
await setup();
const state = await readState();
state.projectExecutionPolicies = [];
await writeState(state);
const result = await detectProjectExecutionConflict({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
executionMode: "cli",
activityAt: "2026-04-06T10:05:00.000Z",
externalActivityAt: "2026-04-06T10:04:00.000Z",
});
assert.equal(result.blocked, true);
assert.equal(result.policy.allowPolicy, "forbid");
assert.equal(result.policy.conflictState, "blocked");
});
test("allow_once only clears the active folder conflict after a single execution", async () => {
await setup();
await applyProjectConflictDecision({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
decision: "allow_once",
});
let result = await detectProjectExecutionConflict({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
executionMode: "cli",
activityAt: "2026-04-06T10:10:00.000Z",
externalActivityAt: "2026-04-06T10:09:00.000Z",
});
assert.equal(result.blocked, false);
assert.equal(result.policy.allowPolicy, "allow_once");
result = await detectProjectExecutionConflict({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
executionMode: "cli",
activityAt: "2026-04-06T10:20:00.000Z",
externalActivityAt: "2026-04-06T10:19:00.000Z",
});
assert.equal(result.blocked, false);
assert.equal(result.policy.allowPolicy, "allow_once");
});
test("allow_always applies only to the active folder and does not unlock other folders on the same device", async () => {
await setup();
await applyProjectConflictDecision({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
decision: "allow_always",
});
const allowed = await detectProjectExecutionConflict({
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
executionMode: "cli",
activityAt: "2026-04-06T10:30:00.000Z",
externalActivityAt: "2026-04-06T10:29:00.000Z",
});
assert.equal(allowed.blocked, false);
assert.equal(allowed.policy.allowPolicy, "allow_always");
const blocked = await detectProjectExecutionConflict({
deviceId: "mac-studio",
folderKey: "mac-studio:talking",
projectId: "thread-talking",
executionMode: "cli",
activityAt: "2026-04-06T10:31:00.000Z",
externalActivityAt: "2026-04-06T10:30:00.000Z",
});
assert.equal(blocked.blocked, true);
assert.equal(blocked.policy.allowPolicy, "forbid");
});
test("claimNextMasterAgentTask keeps conversation replies queued when the device prefers gui mode", async () => {
await setup();
const project = await getCliProject();
await updateDevice("mac-studio", {
preferredExecutionMode: "gui",
});
const task = await queueMasterAgentTask({
projectId: project.id,
requestMessageId: "msg-preferred-gui",
requestText: "继续推进当前线程任务",
executionPrompt: "请继续推进当前线程任务",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "mac-studio",
taskType: "conversation_reply",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
});
const claimed = await claimNextMasterAgentTask("mac-studio");
assert.equal(claimed, null);
const state = await readState();
const queued = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
assert.equal(queued?.status, "queued");
});
test("heartbeat external activity on an active cli folder blocks the next claim until the user explicitly allows it", async () => {
await setup();
const project = await getCliProject();
const folderKey = buildProjectFolderKey(project);
const firstTask = await queueMasterAgentTask({
projectId: project.id,
requestMessageId: "msg-first",
requestText: "先推进一轮",
executionPrompt: "请先推进一轮",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "mac-studio",
taskType: "conversation_reply",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
});
const claimedFirst = await claimNextMasterAgentTask("mac-studio");
assert.equal(claimedFirst?.taskId, firstTask.taskId);
await upsertDeviceHeartbeat({
deviceId: "mac-studio",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
status: "online",
quota5h: 72,
quota7d: 86,
projects: [project.threadMeta.folderName],
projectCandidates: [
{
folderName: project.threadMeta.folderName,
folderRef: project.threadMeta.codexFolderRef,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
codexFolderRef: project.threadMeta.codexFolderRef,
codexThreadRef: project.threadMeta.codexThreadRef,
lastActiveAt: "2026-04-06T11:05:00.000Z",
suggestedImport: true,
},
],
});
let state = await readState();
let policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
assert.ok(policy, "expected heartbeat to persist a scoped conflict policy");
assert.equal(policy?.activeCliExecution, true);
assert.equal(policy?.conflictState, "blocked");
assert.equal(policy?.recentExternalActivityAt, "2026-04-06T11:05:00.000Z");
const secondTask = await queueMasterAgentTask({
projectId: project.id,
requestMessageId: "msg-second",
requestText: "继续推进第二轮",
executionPrompt: "请继续推进第二轮",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "mac-studio",
taskType: "conversation_reply",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
});
const blockedClaim = await claimNextMasterAgentTask("mac-studio");
assert.equal(blockedClaim, null);
await applyProjectConflictDecision({
deviceId: "mac-studio",
folderKey,
projectId: project.id,
decision: "allow_once",
});
const allowedClaim = await claimNextMasterAgentTask("mac-studio");
assert.equal(allowedClaim?.taskId, secondTask.taskId);
state = await readState();
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
assert.equal(policy?.allowPolicy, "allow_once");
await completeMasterAgentTask({
taskId: secondTask.taskId,
deviceId: "mac-studio",
status: "completed",
replyBody: "第二轮已完成",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
});
state = await readState();
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
assert.ok(policy, "expected scoped policy to remain after consuming allow_once");
assert.equal(policy?.allowPolicy, "forbid");
assert.equal(policy?.activeCliExecution, false);
assert.equal(policy?.conflictState, "blocked");
});

View File

@@ -0,0 +1,242 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
let runtimeRoot = "";
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-capabilities-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const data = await import("../src/lib/boss-data.ts");
readState = data.readState;
writeState = data.writeState;
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
updateDevice = data.updateDevice;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("device stores gui and cli capabilities without splitting the physical device", async () => {
await setup();
const state = await readState();
const device = state.devices.find((item) => item.id === "mac-studio") as
| ({
capabilities?: {
gui?: { connected?: boolean };
cli?: { connected?: boolean };
};
preferredExecutionMode?: string;
} & (typeof state.devices)[number])
| undefined;
assert.ok(device);
assert.equal(device.capabilities?.gui?.connected, true);
assert.equal(device.capabilities?.cli?.connected, true);
assert.equal(device.preferredExecutionMode, "cli");
});
test("conflict policy is scoped to the active folder instead of the whole device", async () => {
await setup();
const state = (await readState()) as typeof readState extends () => Promise<infer T>
? T & {
projectExecutionPolicies?: Array<{
deviceId: string;
folderKey?: string;
projectId: string;
allowPolicy: string;
conflictState: string;
updatedAt: string;
}>;
}
: never;
state.projectExecutionPolicies = [
{
deviceId: "mac-studio",
folderKey: "mac-studio:boss",
projectId: "thread-ui",
allowPolicy: "allow_always",
conflictState: "warning",
updatedAt: "2026-04-06T10:00:00.000Z",
},
];
await writeState(state);
const nextState = (await readState()) as typeof state;
const bossPolicy = nextState.projectExecutionPolicies?.find(
(item) => item.folderKey === "mac-studio:boss",
);
const otherPolicy = nextState.projectExecutionPolicies?.find(
(item) => item.folderKey === "mac-studio:talking",
);
assert.equal(bossPolicy?.allowPolicy, "allow_always");
assert.equal(otherPolicy, undefined);
});
test("legacy device state without capabilities is normalized with seeded defaults", async () => {
await setup();
const state = await readState();
const legacyState = {
...state,
devices: state.devices.map((device) =>
device.id === "mac-studio"
? {
...device,
capabilities: undefined,
preferredExecutionMode: undefined,
}
: device,
),
};
await writeFile(process.env.BOSS_STATE_FILE!, JSON.stringify(legacyState, null, 2), "utf8");
const normalized = await readState();
const device = normalized.devices.find((item) => item.id === "mac-studio");
assert.ok(device);
assert.equal(device.capabilities?.gui.connected, true);
assert.equal(device.capabilities?.cli.connected, true);
assert.equal(device.preferredExecutionMode, "cli");
});
test("legacy device normalization matches seeded defaults by device id instead of array position", async () => {
await setup();
const state = await readState();
const reorderedLegacyDevices = [
{
id: "win-gpu-01",
source: "production",
capabilities: undefined,
preferredExecutionMode: undefined,
},
{
id: "mac-studio",
source: "production",
capabilities: undefined,
preferredExecutionMode: undefined,
},
];
await writeFile(
process.env.BOSS_STATE_FILE!,
JSON.stringify(
{
...state,
devices: reorderedLegacyDevices,
},
null,
2,
),
"utf8",
);
const normalized = await readState();
const windowsDevice = normalized.devices.find((item) => item.id === "win-gpu-01");
const macDevice = normalized.devices.find((item) => item.id === "mac-studio");
assert.ok(windowsDevice);
assert.ok(macDevice);
assert.equal(windowsDevice.capabilities?.gui.connected, true);
assert.equal(windowsDevice.capabilities?.cli.connected, false);
assert.equal(windowsDevice.preferredExecutionMode, "gui");
assert.equal(macDevice.capabilities?.gui.connected, true);
assert.equal(macDevice.capabilities?.cli.connected, true);
assert.equal(macDevice.preferredExecutionMode, "cli");
});
test("device heartbeat persists gui cli capability state on the same physical device", async () => {
await setup();
await upsertDeviceHeartbeat({
deviceId: "mac-studio",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
status: "online",
quota5h: 72,
quota7d: 86,
preferredExecutionMode: "gui",
capabilities: {
gui: {
connected: true,
lastSeenAt: "2026-04-06T09:30:00.000Z",
lastActiveProjectId: "audit-collab",
},
cli: {
connected: true,
lastSeenAt: "2026-04-06T09:31:00.000Z",
lastActiveProjectId: "master-agent",
},
},
projects: ["硬件审计协作"],
endpoint: "mac://kris.local",
});
const state = await readState();
const device = state.devices.find((item) => item.id === "mac-studio");
assert.ok(device);
assert.equal(device.preferredExecutionMode, "cli");
assert.equal(device.capabilities?.gui.connected, true);
assert.equal(device.capabilities?.gui.lastActiveProjectId, "audit-collab");
assert.equal(device.capabilities?.cli.connected, true);
assert.equal(device.capabilities?.cli.lastActiveProjectId, "master-agent");
});
test("device heartbeat does not overwrite the preferred execution mode chosen in Boss for an existing device", async () => {
await setup();
await updateDevice("mac-studio", {
preferredExecutionMode: "gui",
});
await upsertDeviceHeartbeat({
deviceId: "mac-studio",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
status: "online",
quota5h: 72,
quota7d: 86,
preferredExecutionMode: "cli",
capabilities: {
gui: {
connected: true,
lastSeenAt: "2026-04-06T09:35:00.000Z",
lastActiveProjectId: "master-agent",
},
cli: {
connected: true,
lastSeenAt: "2026-04-06T09:36:00.000Z",
lastActiveProjectId: "audit-collab",
},
},
projects: ["硬件审计协作"],
endpoint: "mac://kris.local",
});
const state = await readState();
const device = state.devices.find((item) => item.id === "mac-studio");
assert.ok(device);
assert.equal(device.preferredExecutionMode, "gui");
});

View File

@@ -0,0 +1,120 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function startMockControlPlane() {
let resolveHeartbeat;
const heartbeatReceived = new Promise((resolve) => {
resolveHeartbeat = resolve;
});
const server = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
const bodyText = Buffer.concat(chunks).toString("utf8");
if (request.method === "POST" && request.url === "/api/device-heartbeat") {
resolveHeartbeat({
headers: request.headers,
bodyText,
});
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
});
await new Promise((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("failed to bind mock control plane");
}
return {
server,
port: address.port,
heartbeatReceived,
};
}
test("local-agent heartbeat reports gui and cli capability state", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-heartbeat-capabilities-"));
const skillsDir = path.join(runtimeRoot, "skills");
await mkdir(skillsDir, { recursive: true });
const mockControlPlane = await startMockControlPlane();
const exampleConfig = JSON.parse(
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
);
const configPath = path.join(runtimeRoot, "config.json");
await writeFile(
configPath,
JSON.stringify(
{
...exampleConfig,
bindHost: "127.0.0.1",
port: 0,
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
heartbeatIntervalMs: 60_000,
masterAgentPollIntervalMs: 60_000,
masterAgentEnabled: false,
codexSessionDiscoveryEnabled: false,
projects: [],
projectCandidates: [],
skillsDir,
},
null,
2,
),
"utf8",
);
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
try {
const heartbeatRequest = await Promise.race([
mockControlPlane.heartbeatReceived,
new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`timed out waiting for heartbeat\n${stderr}`));
}, 8000);
}),
]);
const payload = JSON.parse(heartbeatRequest.bodyText);
assert.ok(payload.capabilities, "heartbeat payload should include device capabilities");
assert.equal(payload.capabilities.gui.connected, false);
assert.equal(payload.capabilities.cli.connected, true);
assert.equal(payload.preferredExecutionMode, "cli");
} finally {
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
await new Promise((resolve) => {
mockControlPlane.server.close(resolve);
});
await rm(runtimeRoot, { recursive: true, force: true });
}
});