diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 6d155b6..362379c 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -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, diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 3268369..5ec9db4 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -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; + cli?: Partial; +} + +export interface DeviceUpdatePayload extends Partial> { + 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 | 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 { + 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 & { + 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 | undefined): BossState { ...(raw.user?.settings ?? {}), }, }, - devices: ensureArray(raw.devices, base.devices).map((device, index) => ({ - ...base.devices[index % base.devices.length], - ...device, - source: - device.source ?? - (device.id === PRIMARY_CODEX_NODE_ID ? "production" : "demo"), - })), + 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 | 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(); + 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(); 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) { +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) { 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) { 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({ diff --git a/tests/device-execution-conflict.test.ts b/tests/device-execution-conflict.test.ts new file mode 100644 index 0000000..5d36c0d --- /dev/null +++ b/tests/device-execution-conflict.test.ts @@ -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>["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"); +}); diff --git a/tests/device-gui-cli-capabilities.test.ts b/tests/device-gui-cli-capabilities.test.ts new file mode 100644 index 0000000..04df0e7 --- /dev/null +++ b/tests/device-gui-cli-capabilities.test.ts @@ -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 + ? 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"); +}); diff --git a/tests/local-agent-heartbeat-capabilities.test.mjs b/tests/local-agent-heartbeat-capabilities.test.mjs new file mode 100644 index 0000000..5b1ab94 --- /dev/null +++ b/tests/local-agent-heartbeat-capabilities.test.mjs @@ -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 }); + } +});