Add gui/cli capability conflict guards
This commit is contained in:
@@ -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],
|
||||
...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<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({
|
||||
|
||||
Reference in New Issue
Block a user