Add gui/cli capability conflict guards
This commit is contained in:
316
tests/device-execution-conflict.test.ts
Normal file
316
tests/device-execution-conflict.test.ts
Normal 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");
|
||||
});
|
||||
242
tests/device-gui-cli-capabilities.test.ts
Normal file
242
tests/device-gui-cli-capabilities.test.ts
Normal 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");
|
||||
});
|
||||
120
tests/local-agent-heartbeat-capabilities.test.mjs
Normal file
120
tests/local-agent-heartbeat-capabilities.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user