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

@@ -70,6 +70,11 @@ async function resolveHeartbeatProjects(config, runtime) {
}
async function postHeartbeat(config, runtime, heartbeatProjects) {
const now = new Date().toISOString();
const preferredExecutionMode =
config.preferredExecutionMode === "gui" || config.preferredExecutionMode === "cli"
? config.preferredExecutionMode
: undefined;
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -83,6 +88,19 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
status: config.status,
quota5h: config.quota5h,
quota7d: config.quota7d,
capabilities: {
gui: {
connected: Boolean(config.guiConnected),
lastSeenAt: now,
lastActiveProjectId: "",
},
cli: {
connected: true,
lastSeenAt: now,
lastActiveProjectId: "",
},
},
preferredExecutionMode,
projects: heartbeatProjects.projects,
projectCandidates: heartbeatProjects.projectCandidates,
endpoint: config.endpoint,

View File

@@ -131,6 +131,9 @@ export type AuditDecision = "pass" | "fail" | "warning" | "inconclusive";
export type CapabilityLeaseMode = "exclusive" | "shared_read" | "shared_stream";
export type AuthRole = "member" | "admin" | "highest_admin";
export type LoginMethod = "password" | "code";
export type DeviceExecutionMode = "gui" | "cli";
export type ProjectConflictAllowPolicy = "forbid" | "allow_once" | "allow_always";
export type ProjectConflictState = "none" | "warning" | "blocked";
export type OtaUpdateStatus = "available" | "scheduled" | "applied" | "skipped";
export type OtaLogStatus = "checked" | "applied" | "skipped";
export type AppLogLevel = "info" | "warn" | "error";
@@ -196,6 +199,8 @@ export interface Device {
endpoint?: string;
token?: string;
note?: string;
capabilities?: DeviceCapabilities;
preferredExecutionMode?: DeviceExecutionMode;
}
export interface Message {
@@ -409,6 +414,37 @@ export interface ProjectAgentControls {
updatedAt: string;
}
export interface DeviceCapabilityState {
connected: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
}
export interface DeviceCapabilities {
gui: DeviceCapabilityState;
cli: DeviceCapabilityState;
}
export interface DeviceCapabilitiesInput {
gui?: Partial<DeviceCapabilityState>;
cli?: Partial<DeviceCapabilityState>;
}
export interface DeviceUpdatePayload extends Partial<Omit<Device, "capabilities">> {
capabilities?: DeviceCapabilitiesInput;
}
export interface ProjectExecutionPolicy {
deviceId: string;
folderKey?: string;
projectId: string;
activeCliExecution?: boolean;
recentExternalActivityAt?: string;
conflictState: ProjectConflictState;
allowPolicy: ProjectConflictAllowPolicy;
updatedAt: string;
}
export interface ProjectOrchestrationBackendChoice {
backendId: OrchestrationBackendId;
label: string;
@@ -1002,6 +1038,7 @@ export interface BossState {
auditRequests: AuditTaskRequest[];
auditResults: AuditTaskResult[];
capabilities: Capability[];
projectExecutionPolicies: ProjectExecutionPolicy[];
}
function detectRuntimeRoot(startDir: string) {
@@ -1097,6 +1134,19 @@ const initialState: BossState = {
endpoint: "mac://kris.local",
token: "boss-mac-studio-token",
note: "本机 Codex 主节点 · 17600003315 已绑定",
capabilities: {
gui: {
connected: true,
lastSeenAt: "2026-03-25T11:52:00+08:00",
lastActiveProjectId: "master-agent",
},
cli: {
connected: true,
lastSeenAt: "2026-03-25T11:52:00+08:00",
lastActiveProjectId: "master-agent",
},
},
preferredExecutionMode: "cli",
},
{
id: "win-gpu-01",
@@ -1112,6 +1162,19 @@ const initialState: BossState = {
endpoint: "win://gpu.local",
token: "boss-win-gpu-token",
note: "摄像头证据通道偶发抖动",
capabilities: {
gui: {
connected: true,
lastSeenAt: "2026-04-06T08:50:00+08:00",
lastActiveProjectId: "audit-collab",
},
cli: {
connected: false,
lastSeenAt: "2026-04-06T08:40:00+08:00",
lastActiveProjectId: "",
},
},
preferredExecutionMode: "gui",
},
{
id: "cloud-backup",
@@ -1127,6 +1190,19 @@ const initialState: BossState = {
endpoint: "cloud://standby",
token: "boss-cloud-backup-token",
note: "standby 节点",
capabilities: {
gui: {
connected: false,
lastSeenAt: "2026-04-06T08:15:00+08:00",
lastActiveProjectId: "",
},
cli: {
connected: false,
lastSeenAt: "2026-04-06T08:15:00+08:00",
lastActiveProjectId: "",
},
},
preferredExecutionMode: "cli",
},
],
projects: [
@@ -1584,6 +1660,7 @@ const initialState: BossState = {
evidenceModes: ["serial_log"],
},
],
projectExecutionPolicies: [],
threadStatusDocuments: [],
threadProgressEvents: [],
};
@@ -1644,6 +1721,110 @@ function trimToDefined(value?: string) {
return trimmed ? trimmed : undefined;
}
function normalizeDeviceCapabilityState(
raw: Partial<DeviceCapabilityState> | undefined,
fallbackLastSeenAt: string,
): DeviceCapabilityState {
return {
connected: Boolean(raw?.connected),
lastSeenAt: trimToDefined(raw?.lastSeenAt) ?? fallbackLastSeenAt,
lastActiveProjectId: trimToDefined(raw?.lastActiveProjectId) ?? "",
};
}
function normalizeDeviceCapabilities(
raw: DeviceCapabilitiesInput | undefined,
fallbackLastSeenAt: string,
): DeviceCapabilities {
return {
gui: normalizeDeviceCapabilityState(raw?.gui, fallbackLastSeenAt),
cli: normalizeDeviceCapabilityState(raw?.cli, fallbackLastSeenAt),
};
}
function normalizePreferredExecutionMode(value: unknown): DeviceExecutionMode {
return value === "gui" ? "gui" : "cli";
}
function normalizeProjectExecutionPolicy(
raw: Partial<ProjectExecutionPolicy>,
): ProjectExecutionPolicy {
return {
deviceId: trimToDefined(raw.deviceId) ?? "",
folderKey: trimToDefined(raw.folderKey),
projectId: trimToDefined(raw.projectId) ?? "",
activeCliExecution: Boolean(raw.activeCliExecution),
recentExternalActivityAt: trimToDefined(raw.recentExternalActivityAt),
conflictState:
raw.conflictState === "warning" || raw.conflictState === "blocked"
? raw.conflictState
: "none",
allowPolicy:
raw.allowPolicy === "allow_once" || raw.allowPolicy === "allow_always"
? raw.allowPolicy
: "forbid",
updatedAt: raw.updatedAt ?? nowIso(),
};
}
function normalizeProjectExecutionPolicyScope(input: {
deviceId: string;
folderKey?: string;
projectId: string;
}) {
return {
deviceId: trimToDefined(input.deviceId) ?? "",
folderKey: trimToDefined(input.folderKey),
projectId: trimToDefined(input.projectId) ?? "",
};
}
function findProjectExecutionPolicyInState(
state: BossState,
input: {
deviceId: string;
folderKey?: string;
projectId: string;
},
) {
const scope = normalizeProjectExecutionPolicyScope(input);
if (scope.folderKey) {
const folderMatch = state.projectExecutionPolicies.find(
(policy) => policy.deviceId === scope.deviceId && policy.folderKey === scope.folderKey,
);
if (folderMatch) {
return folderMatch;
}
}
return state.projectExecutionPolicies.find(
(policy) => policy.deviceId === scope.deviceId && policy.projectId === scope.projectId,
);
}
function upsertProjectExecutionPolicyInState(
state: BossState,
input: Partial<ProjectExecutionPolicy> & {
deviceId: string;
projectId: string;
},
) {
const scope = normalizeProjectExecutionPolicyScope(input);
const existing = findProjectExecutionPolicyInState(state, scope);
const next = normalizeProjectExecutionPolicy({
...existing,
...input,
...scope,
});
if (existing) {
Object.assign(existing, next);
return existing;
}
state.projectExecutionPolicies.unshift(next);
return next;
}
function parseControlTextOverride(value: unknown) {
if (value === undefined || value === null) {
return { kind: "clear" as const };
@@ -2921,13 +3102,24 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
...(raw.user?.settings ?? {}),
},
},
devices: ensureArray(raw.devices, base.devices).map((device, index) => ({
...base.devices[index % base.devices.length],
devices: ensureArray(raw.devices, base.devices).map((device, index) => {
const fallback =
(device.id ? base.devices.find((item) => item.id === device.id) : undefined) ??
base.devices[index % base.devices.length];
const lastSeenAt = trimToDefined(device.lastSeenAt) ?? fallback.lastSeenAt;
return {
...fallback,
...device,
source:
device.source ??
(device.id === PRIMARY_CODEX_NODE_ID ? "production" : "demo"),
})),
lastSeenAt,
capabilities: normalizeDeviceCapabilities(device.capabilities ?? fallback.capabilities, lastSeenAt),
preferredExecutionMode: normalizePreferredExecutionMode(
device.preferredExecutionMode ?? fallback.preferredExecutionMode,
),
};
}),
projects: ensureArray(raw.projects, base.projects).map((project, index) =>
normalizeProject(project, base.projects[index % base.projects.length]),
),
@@ -3202,6 +3394,10 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
supportedActions: ensureArray(item.supportedActions, []),
evidenceModes: ensureArray(item.evidenceModes, []),
})),
projectExecutionPolicies: ensureArray(
raw.projectExecutionPolicies,
base.projectExecutionPolicies,
).map((policy) => normalizeProjectExecutionPolicy(policy)),
};
if (!state.projects.some((project) => project.id === "master-agent")) {
@@ -3698,6 +3894,20 @@ function syncDerivedState(input: BossState) {
(item) => visibleDeviceIds.has(item.deviceId) && visibleImportDraftIds.has(item.draftId),
);
const visibleProjectIds = new Set(state.projects.map((project) => project.id));
const seenProjectExecutionPolicies = new Set<string>();
state.projectExecutionPolicies = state.projectExecutionPolicies
.map((policy) => normalizeProjectExecutionPolicy(policy))
.filter((policy) => visibleDeviceIds.has(policy.deviceId))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.filter((policy) => {
const key = `${policy.deviceId}:${policy.folderKey ?? policy.projectId}`;
if (seenProjectExecutionPolicies.has(key)) {
return false;
}
seenProjectExecutionPolicies.add(key);
return true;
})
.slice(0, 200);
const threadStatusDocumentByThread = new Map<string, ThreadStatusDocument>();
const normalizedThreadStatusDocuments = state.threadStatusDocuments.map((document) =>
normalizeThreadStatusDocument(document),
@@ -6253,7 +6463,89 @@ export async function reassignMasterAgentTaskExecution(payload: {
return task;
}
function isCliWriteTask(task: MasterAgentTask) {
if (task.taskType === "dispatch_execution") {
return true;
}
if (task.taskType !== "conversation_reply") {
return false;
}
if (task.projectId === "master-agent") {
return false;
}
return true;
}
function resolveProjectConflictScopeForTask(
state: BossState,
task: MasterAgentTask,
): { project?: Project; projectId: string; folderKey?: string; deviceId: string } | null {
const projectId = task.targetProjectId ?? task.projectId;
const project = state.projects.find((item) => item.id === projectId);
if (project?.isGroup) {
return null;
}
const folderRef =
trimToDefined(task.targetCodexFolderRef) ??
trimToDefined(project?.threadMeta.codexFolderRef ?? project?.threadMeta.folderName);
const folderKey = project
? buildProjectFolderKey(project)
: task.deviceId && folderRef
? `${task.deviceId}:${folderRef.toLowerCase()}`
: undefined;
if (!project && !folderKey) {
return null;
}
return {
project,
projectId: project?.id ?? projectId,
folderKey,
deviceId: task.deviceId,
};
}
export async function claimNextMasterAgentTask(deviceId: string) {
const snapshot = await readState();
const queued = snapshot.masterAgentTasks.find(
(item) => item.deviceId === deviceId && item.status === "queued",
);
if (!queued) {
return null;
}
const device = snapshot.devices.find((item) => item.id === deviceId);
if (device?.preferredExecutionMode === "gui" && isCliWriteTask(queued)) {
return null;
}
if (isCliWriteTask(queued)) {
const scope = resolveProjectConflictScopeForTask(snapshot, queued);
const externalActivityAt = scope?.project?.threadMeta.lastObservedCodexActivityAt;
if (scope) {
const existingPolicy = findProjectExecutionPolicyInState(snapshot, scope);
const fallbackPolicy =
existingPolicy ??
snapshot.projectExecutionPolicies.find(
(policy) => policy.deviceId === deviceId && policy.projectId === scope.projectId,
);
if (fallbackPolicy?.conflictState === "blocked" && fallbackPolicy.allowPolicy === "forbid") {
return null;
}
}
if (scope && externalActivityAt) {
const conflict = await detectProjectExecutionConflict({
deviceId,
folderKey: scope.folderKey,
projectId: scope.projectId,
executionMode: "cli",
activityAt: nowIso(),
externalActivityAt,
});
if (conflict.blocked) {
return null;
}
}
}
let attachmentProjectId: string | undefined;
let dispatchExecutionProjectId: string | undefined;
const task = await mutateState((state) => {
@@ -6282,6 +6574,20 @@ export async function claimNextMasterAgentTask(deviceId: string) {
dispatchExecutionProjectId = execution.groupProjectId;
}
}
if (isCliWriteTask(next)) {
const scope = resolveProjectConflictScopeForTask(state, next);
if (scope) {
const existing = findProjectExecutionPolicyInState(state, scope);
upsertProjectExecutionPolicyInState(state, {
...existing,
...scope,
allowPolicy: existing?.allowPolicy ?? "forbid",
conflictState: existing?.conflictState ?? "none",
activeCliExecution: true,
updatedAt: nowIso(),
});
}
}
return { ...next };
});
if (task) {
@@ -6564,6 +6870,21 @@ export async function completeMasterAgentTask(payload: {
});
}
if (isCliWriteTask(task)) {
const scope = resolveProjectConflictScopeForTask(state, task);
if (scope) {
const policy = findProjectExecutionPolicyInState(state, scope);
if (policy) {
if (policy.allowPolicy === "allow_once") {
policy.allowPolicy = "forbid";
policy.conflictState = "blocked";
}
policy.activeCliExecution = false;
policy.updatedAt = nowIso();
}
}
}
return {
...task,
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,
@@ -6948,7 +7269,7 @@ export async function createDeviceEnrollment(payload: {
return { device, enrollment };
}
export async function updateDevice(deviceId: string, payload: Partial<Device>) {
export async function updateDevice(deviceId: string, payload: DeviceUpdatePayload) {
const device = await mutateState((state) => {
const nextDevice = state.devices.find((item) => item.id === deviceId);
if (!nextDevice) throw new Error("DEVICE_NOT_FOUND");
@@ -6962,6 +7283,20 @@ export async function updateDevice(deviceId: string, payload: Partial<Device>) {
if (payload.projects) {
nextDevice.projects = payload.projects.filter(Boolean);
}
if (payload.capabilities) {
nextDevice.capabilities = normalizeDeviceCapabilities(
{
gui: payload.capabilities.gui ?? nextDevice.capabilities?.gui,
cli: payload.capabilities.cli ?? nextDevice.capabilities?.cli,
},
trimToDefined(payload.capabilities.gui?.lastSeenAt) ??
trimToDefined(payload.capabilities.cli?.lastSeenAt) ??
nextDevice.lastSeenAt,
);
}
if (payload.preferredExecutionMode !== undefined) {
nextDevice.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode);
}
nextDevice.lastSeenAt = nowIso();
return nextDevice;
});
@@ -6969,6 +7304,105 @@ export async function updateDevice(deviceId: string, payload: Partial<Device>) {
return device;
}
export async function applyProjectConflictDecision(input: {
deviceId: string;
folderKey?: string;
projectId: string;
decision: ProjectConflictAllowPolicy;
}) {
return mutateState((state) => {
const policy = upsertProjectExecutionPolicyInState(state, {
deviceId: input.deviceId,
folderKey: input.folderKey,
projectId: input.projectId,
allowPolicy: input.decision,
conflictState: input.decision === "forbid" ? "blocked" : "warning",
activeCliExecution: false,
updatedAt: nowIso(),
});
return normalizeProjectExecutionPolicy(policy);
});
}
export async function detectProjectExecutionConflict(input: {
deviceId: string;
folderKey?: string;
projectId: string;
executionMode: DeviceExecutionMode;
activityAt: string;
externalActivityAt?: string;
}) {
let result!: {
blocked: boolean;
policy: ProjectExecutionPolicy;
};
await mutateState((state) => {
const scope = normalizeProjectExecutionPolicyScope(input);
const existingPolicy = findProjectExecutionPolicyInState(state, scope);
const hasConflict =
input.executionMode === "cli" &&
Boolean(input.externalActivityAt) &&
input.externalActivityAt! <= input.activityAt;
if (!hasConflict) {
result = {
blocked: false,
policy: normalizeProjectExecutionPolicy({
...existingPolicy,
...scope,
allowPolicy: existingPolicy?.allowPolicy ?? "forbid",
conflictState: existingPolicy?.conflictState ?? "none",
activeCliExecution: false,
updatedAt: existingPolicy?.updatedAt ?? nowIso(),
}),
};
return;
}
if (existingPolicy?.allowPolicy === "allow_always") {
const policy = upsertProjectExecutionPolicyInState(state, {
...existingPolicy,
...scope,
allowPolicy: "allow_always",
conflictState: "warning",
activeCliExecution: true,
recentExternalActivityAt: input.externalActivityAt,
updatedAt: nowIso(),
});
result = { blocked: false, policy: normalizeProjectExecutionPolicy(policy) };
return;
}
if (existingPolicy?.allowPolicy === "allow_once") {
const policy = upsertProjectExecutionPolicyInState(state, {
...existingPolicy,
...scope,
allowPolicy: "allow_once",
conflictState: "warning",
activeCliExecution: true,
recentExternalActivityAt: input.externalActivityAt,
updatedAt: nowIso(),
});
result = { blocked: false, policy: normalizeProjectExecutionPolicy(policy) };
return;
}
const blockedPolicy = upsertProjectExecutionPolicyInState(state, {
...existingPolicy,
...scope,
allowPolicy: "forbid",
conflictState: "blocked",
activeCliExecution: true,
recentExternalActivityAt: input.externalActivityAt,
updatedAt: nowIso(),
});
result = { blocked: true, policy: normalizeProjectExecutionPolicy(blockedPolicy) };
});
return result;
}
function claimEnrollment(
state: BossState,
deviceId: string,
@@ -7133,6 +7567,8 @@ export async function upsertDeviceHeartbeat(payload: {
quota5h: number;
quota7d: number;
projects: string[];
capabilities?: DeviceCapabilitiesInput;
preferredExecutionMode?: DeviceExecutionMode;
endpoint?: string;
projectCandidates?: Array<{
folderName: string;
@@ -7191,6 +7627,8 @@ export async function upsertDeviceHeartbeat(payload: {
endpoint: payload.endpoint,
token: claimedEnrollment?.token ?? payload.token ?? randomToken("boss"),
note: claimedEnrollment?.note,
capabilities: normalizeDeviceCapabilities(payload.capabilities, nowIso()),
preferredExecutionMode: normalizePreferredExecutionMode(payload.preferredExecutionMode),
};
state.devices.push(device);
} else {
@@ -7208,6 +7646,13 @@ export async function upsertDeviceHeartbeat(payload: {
device.lastSeenAt = nowIso();
device.endpoint = payload.endpoint ?? device.endpoint;
device.token = claimedEnrollment?.token ?? payload.token ?? device.token;
device.capabilities = normalizeDeviceCapabilities(
payload.capabilities ?? device.capabilities,
device.lastSeenAt,
);
if (device.preferredExecutionMode === undefined && payload.preferredExecutionMode !== undefined) {
device.preferredExecutionMode = normalizePreferredExecutionMode(payload.preferredExecutionMode);
}
}
if (shouldAutoImportLegacyProjects) {
@@ -7286,6 +7731,48 @@ export async function upsertDeviceHeartbeat(payload: {
createdAt: candidate.lastActiveAt,
sourceTaskId: `heartbeat-${candidate.candidateId}`,
});
const folderKey = buildProjectFolderKey(matchingProject);
const activePolicy = folderKey
? findProjectExecutionPolicyInState(state, {
deviceId: payload.deviceId,
folderKey,
projectId: matchingProject.id,
})
: undefined;
const hasRunningCliTask = state.masterAgentTasks.some((task) => {
if (task.deviceId !== payload.deviceId || task.status !== "running") {
return false;
}
if (!isCliWriteTask(task)) {
return false;
}
const taskScope = resolveProjectConflictScopeForTask(state, task);
if (!taskScope) {
return false;
}
if (taskScope.projectId !== matchingProject.id) {
return false;
}
if (!folderKey) {
return true;
}
return taskScope.folderKey === folderKey;
});
if (activePolicy?.activeCliExecution || hasRunningCliTask) {
const allowPolicy = activePolicy?.allowPolicy ?? "forbid";
const conflictState = allowPolicy === "allow_always" ? "warning" : "blocked";
upsertProjectExecutionPolicyInState(state, {
...activePolicy,
deviceId: payload.deviceId,
folderKey,
projectId: matchingProject.id,
allowPolicy,
conflictState,
activeCliExecution: true,
recentExternalActivityAt: candidate.lastActiveAt,
updatedAt: nowIso(),
});
}
}
if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state, "heartbeat_activity")) {
projectUnderstandingSyncRequests.push({

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 });
}
});