From c53ff3965b0cf88e52a4e444dede68edc69b543a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 23 Mar 2026 15:03:17 +0800 Subject: [PATCH] feat: add device-targeted android control flow --- README.md | 6 +- .../site/hyzq/bossandroid/model/Models.kt | 27 +++ .../site/hyzq/bossandroid/network/BossApi.kt | 32 ++- .../java/site/hyzq/bossandroid/ui/BossApp.kt | 187 +++++++++++++++++- .../site/hyzq/bossandroid/ui/BossViewModel.kt | 137 +++++-------- src/engine.ts | 122 +++++++++++- src/planner.ts | 30 ++- src/scheduler.ts | 25 ++- src/server.ts | 161 ++++++++++++++- src/store.ts | 1 + src/types.ts | 21 +- 11 files changed, 621 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 9fd42d2..7289874 100644 --- a/README.md +++ b/README.md @@ -212,11 +212,11 @@ android-app/app/build/outputs/apk/debug/app-debug.apk - SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度 - 事件流断开时自动降级轮询,尽量保持跨端数据连续同步 -- 会话创建、切换、持续对话 +- 会话创建、切换,并按“先选设备再对话”的方式把需求定向发到对应客户端 - 任务分组查看、暂停、恢复、取消、重排 - 审批查看与批准/拒绝 -- 设备列表、设备聚焦切换、设备下线 -- 绑定新设备并生成启动命令 +- 设备列表、设备聚焦切换、设备下线,以及按设备视角过滤任务 +- 绑定新设备时自动生成 Mac / Windows 对应的超链接和终端命令,拿到目标电脑执行即可完成绑定 - 云端 Boss 地址切换与重排入口 同步模型说明: diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt index 9040e81..f73489d 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt @@ -9,6 +9,7 @@ data class AppStatePayload( val messages: List = emptyList(), val tasks: List = emptyList(), val workers: List = emptyList(), + val deviceBindings: List = emptyList(), val approvals: List = emptyList(), val events: List = emptyList(), ) @@ -27,6 +28,7 @@ data class Session( val status: String, val activeObjective: String = "", val lastPlannerSummary: String = "", + val activeWorkerId: String? = null, val createdAt: String, val updatedAt: String, ) @@ -55,6 +57,7 @@ data class TaskItem( val requiredCapabilities: List = emptyList(), val dependencyIds: List = emptyList(), val assignedWorkerId: String? = null, + val preferredWorkerId: String? = null, val approvalStatus: String = "not_required", val progressPercent: Int = 0, val summary: String = "", @@ -103,3 +106,27 @@ data class BossEvent( val timestamp: String, val payload: Map = emptyMap(), ) + +@Serializable +data class DeviceBinding( + val id: String, + val token: String, + val name: String, + val os: String, + val capabilities: List = emptyList(), + val executor: String, + val workspaceHint: String = "", + val status: String, + val claimedWorkerId: String? = null, + val claimedAt: String? = null, + val createdAt: String, + val updatedAt: String, +) + +@Serializable +data class DeviceBindingLaunchPayload( + val binding: DeviceBinding, + val launcherUrl: String, + val command: String, + val platformLabel: String, +) diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt index 9910150..a0ad555 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.putJsonArray import site.hyzq.bossandroid.model.AppStatePayload import site.hyzq.bossandroid.model.ApprovalRequest +import site.hyzq.bossandroid.model.DeviceBindingLaunchPayload import site.hyzq.bossandroid.model.HealthPayload import site.hyzq.bossandroid.model.Session import site.hyzq.bossandroid.model.TaskItem @@ -42,13 +43,21 @@ class BossApi( }, ) - suspend fun addMessage(baseUrl: String, sessionId: String, content: String): AppStatePayload { + suspend fun addMessage( + baseUrl: String, + sessionId: String, + content: String, + targetWorkerId: String?, + ): AppStatePayload { post( baseUrl = baseUrl, path = "/api/sessions/$sessionId/messages", body = buildJsonObject { put("content", JsonPrimitive(content)) put("channel", JsonPrimitive("android")) + if (!targetWorkerId.isNullOrBlank()) { + put("targetWorkerId", JsonPrimitive(targetWorkerId)) + } }, ) return getBootstrap(baseUrl) @@ -81,6 +90,27 @@ class BossApi( }, ) + suspend fun createDeviceBinding( + baseUrl: String, + name: String, + os: String, + capabilities: List, + executor: String, + workspaceHint: String, + ): DeviceBindingLaunchPayload = post( + baseUrl = baseUrl, + path = "/api/device-bindings", + body = buildJsonObject { + put("name", JsonPrimitive(name)) + put("os", JsonPrimitive(os)) + put("executor", JsonPrimitive(executor)) + put("workspaceHint", JsonPrimitive(workspaceHint)) + putJsonArray("capabilities") { + capabilities.forEach { add(JsonPrimitive(it)) } + } + }, + ) + suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload { post(baseUrl, "/api/workers/$workerId/offline") return getBootstrap(baseUrl) diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt index f4101be..ba4e8ec 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt @@ -73,6 +73,7 @@ import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import site.hyzq.bossandroid.model.ApprovalRequest import site.hyzq.bossandroid.model.BossEvent +import site.hyzq.bossandroid.model.DeviceBinding import site.hyzq.bossandroid.model.Message import site.hyzq.bossandroid.model.Session import site.hyzq.bossandroid.model.TaskItem @@ -182,7 +183,7 @@ fun BossApp( MainSection.DEVICES -> DevicesScreen( uiState = uiState, clipboard = clipboard, - onBindWorker = viewModel::registerWorker, + onBindWorker = viewModel::createDeviceBinding, onSelectWorker = viewModel::selectWorker, onMarkOffline = viewModel::markWorkerOffline, onClearGeneratedCommand = viewModel::clearGeneratedCommand, @@ -226,7 +227,9 @@ private fun ConversationsScreen( event.sessionId == selectedSession?.id || event.sessionId == null }.take(8) val filteredTasks = sessionTasks.filter { task -> - uiState.selectedWorkerId == null || task.assignedWorkerId == uiState.selectedWorkerId + uiState.selectedWorkerId == null || + task.assignedWorkerId == uiState.selectedWorkerId || + task.preferredWorkerId == uiState.selectedWorkerId } Column( @@ -244,10 +247,25 @@ private fun ConversationsScreen( onSelectSession = onSelectSession, ) + if (uiState.workers.isNotEmpty()) { + DeviceConversationSelector( + workers = uiState.workers, + selectedWorkerId = uiState.selectedWorkerId, + onSelectWorker = onSelectWorker, + ) + } + if (selectedWorker != null) { FocusBanner( title = "当前设备视角", - body = "已切换到 ${selectedWorker.name},任务列表会按这个设备过滤。", + body = "已切换到 ${selectedWorker.name},接下来发送的需求会定向给这台设备,任务列表也会按它过滤。", + actionLabel = "清除", + onAction = onClearWorkerFocus, + ) + } else if (uiState.workers.isNotEmpty()) { + FocusBanner( + title = "先选择设备再对话", + body = "这版安卓主控会按设备维度投递需求。先选一台设备,再继续当前项目。", actionLabel = "清除", onAction = onClearWorkerFocus, ) @@ -299,6 +317,8 @@ private fun ConversationsScreen( ChatTab( sessionMessages = sessionMessages, sessionEvents = sessionEvents, + selectedWorker = selectedWorker, + requiresDeviceSelection = uiState.workers.isNotEmpty(), onSendMessage = onSendMessage, ) } @@ -346,6 +366,8 @@ private fun DevicesScreen( onClearGeneratedCommand: () -> Unit, ) { val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId } + val pendingBindings = uiState.deviceBindings.filter { it.status == "pending" } + val claimedBindings = uiState.deviceBindings.filter { it.status == "claimed" } val relatedTasks = remember(uiState.tasks) { uiState.tasks.associateBy { it.id } } @@ -368,6 +390,13 @@ private fun DevicesScreen( onDismissCommand = onClearGeneratedCommand, ) + if (pendingBindings.isNotEmpty()) { + SectionHeading("待完成绑定") + pendingBindings.forEach { binding -> + DeviceBindingCard(binding = binding) + } + } + if (selectedWorker != null) { FocusBanner( title = "当前设备焦点", @@ -397,6 +426,13 @@ private fun DevicesScreen( ) } } + + if (claimedBindings.isNotEmpty()) { + SectionHeading("最近完成绑定") + claimedBindings.take(3).forEach { binding -> + DeviceBindingCard(binding = binding) + } + } } } @@ -776,6 +812,8 @@ private fun SessionSummaryCard( private fun ChatTab( sessionMessages: List, sessionEvents: List, + selectedWorker: WorkerNode?, + requiresDeviceSelection: Boolean, onSendMessage: (String) -> Unit, ) { var message by rememberSaveable { mutableStateOf("") } @@ -787,6 +825,14 @@ private fun ChatTab( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text("和 Boss 对话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = when { + selectedWorker != null -> "当前发送目标:${selectedWorker.name} · ${selectedWorker.os}" + requiresDeviceSelection -> "先选一台设备,Boss 会把你的需求投递到对应客户端。" + else -> "当前还没有绑定设备,先用手机整理需求也可以。" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) OutlinedTextField( value = message, onValueChange = { message = it }, @@ -801,8 +847,15 @@ private fun ChatTab( onSendMessage(message) message = "" }, + enabled = !requiresDeviceSelection || selectedWorker != null, ) { - Text("发送给 Boss") + Text( + if (selectedWorker != null) { + "发送到 ${selectedWorker.name}" + } else { + "发送给 Boss" + }, + ) } } } @@ -917,7 +970,7 @@ private fun BindDeviceCard( ) { Text("绑定新设备", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text( - "在这里登记设备身份,Boss 会直接生成对应启动命令。适合把 Windows、Mac 和 Linux 全部挂到同一个主控下。", + "在这里登记设备身份,Boss 会生成对应平台的超链接和终端指令。你只需要把它拿到目标电脑上执行,就能完成绑定。", color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( @@ -950,9 +1003,8 @@ private fun BindDeviceCard( ) Button(onClick = { onBindWorker(name, os, capabilities, executor, workspace) - name = "" }) { - Text("绑定并生成命令") + Text("生成绑定链接") } if (generatedCommand != null) { @@ -966,14 +1018,38 @@ private fun BindDeviceCard( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - "${generatedCommand.workerName} 已就绪", + "${generatedCommand.deviceName} 绑定指令已生成", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) Text( - "把下面这条命令贴到对应设备的 ${generatedCommand.shellLabel} 里即可启动 worker。", + "先把链接或命令发到目标设备,再在那台 ${generatedCommand.platformLabel} 电脑的终端里运行。", color = MaterialTheme.colorScheme.onSecondaryContainer, ) + Text( + "一键链接", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp), + ) + .padding(12.dp), + ) { + Text( + text = generatedCommand.launcherUrl, + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "终端命令", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) Box( modifier = Modifier .fillMaxWidth() @@ -990,6 +1066,11 @@ private fun BindDeviceCard( } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = { + clipboard.setText(AnnotatedString(generatedCommand.launcherUrl)) + }) { + Text("复制链接") + } + OutlinedButton(onClick = { clipboard.setText(AnnotatedString(generatedCommand.command)) }) { Text("复制命令") @@ -1005,6 +1086,94 @@ private fun BindDeviceCard( } } +@Composable +private fun DeviceConversationSelector( + workers: List, + selectedWorkerId: String?, + onSelectWorker: (String?) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionHeading("对话设备") + Text( + "先切换设备,再继续当前项目。Boss 会把你的消息投递给选中的客户端。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + workers.forEach { worker -> + FilterChip( + selected = worker.id == selectedWorkerId, + onClick = { onSelectWorker(worker.id) }, + label = { + Text( + "${worker.name} · ${worker.os}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + if (selectedWorkerId != null) { + TextButton(onClick = { onSelectWorker(null) }) { + Text("清除设备") + } + } + } + } +} + +@Composable +private fun DeviceBindingCard( + binding: DeviceBinding, +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(binding.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "${binding.os} · ${binding.executor}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StatusChip( + label = if (binding.status == "claimed") "已完成" else "待执行", + tone = if (binding.status == "claimed") "completed" else "pending", + ) + } + if (binding.capabilities.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + binding.capabilities.forEach { capability -> + AssistChip(onClick = {}, label = { Text(capability) }) + } + } + } + Text( + text = if (binding.status == "claimed") { + "已完成绑定,Worker ID:${binding.claimedWorkerId ?: "未知"}" + } else { + "等待目标设备运行绑定命令。" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "最近更新 ${formatRelative(binding.updatedAt)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun WorkerCard( worker: WorkerNode, diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt index 9525da8..60827f6 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withLock import site.hyzq.bossandroid.model.AppStatePayload import site.hyzq.bossandroid.model.ApprovalRequest import site.hyzq.bossandroid.model.BossEvent +import site.hyzq.bossandroid.model.DeviceBinding import site.hyzq.bossandroid.model.HealthPayload import site.hyzq.bossandroid.model.Message import site.hyzq.bossandroid.model.Session @@ -46,8 +47,9 @@ data class UiNotice( ) data class GeneratedWorkerCommand( - val workerName: String, - val shellLabel: String, + val deviceName: String, + val launcherUrl: String, + val platformLabel: String, val command: String, ) @@ -60,6 +62,7 @@ data class BossUiState( val tasks: List = emptyList(), val approvals: List = emptyList(), val workers: List = emptyList(), + val deviceBindings: List = emptyList(), val events: List = emptyList(), val selectedSessionId: String? = null, val selectedWorkerId: String? = null, @@ -106,8 +109,13 @@ class BossViewModel( fun selectSession(sessionId: String) { _uiState.update { current -> + val session = current.sessions.firstOrNull { it.id == sessionId } + val nextWorkerId = session?.activeWorkerId + ?.takeIf { candidate -> current.workers.any { it.id == candidate } } + ?: current.selectedWorkerId current.copy( selectedSessionId = sessionId, + selectedWorkerId = nextWorkerId, section = MainSection.CONVERSATIONS, ) } @@ -115,10 +123,7 @@ class BossViewModel( fun selectWorker(workerId: String?) { _uiState.update { current -> - current.copy( - selectedWorkerId = workerId, - section = if (workerId == null) current.section else MainSection.DEVICES, - ) + current.copy(selectedWorkerId = workerId) } } @@ -159,10 +164,24 @@ class BossViewModel( publishNotice("消息不能为空。") return } + if (uiState.value.workers.isNotEmpty() && uiState.value.selectedWorkerId == null) { + publishNotice("请先切换到一个设备,再发送需求。") + return + } runMutation(successMessage = "需求已发送给 Boss。") { - val snapshot = api.addMessage(currentBaseUrl(), sessionId, message) - applySnapshot(snapshot, preferredSessionId = sessionId) + val targetWorkerId = uiState.value.selectedWorkerId + val snapshot = api.addMessage( + baseUrl = currentBaseUrl(), + sessionId = sessionId, + content = message, + targetWorkerId = targetWorkerId, + ) + applySnapshot( + snapshot = snapshot, + preferredSessionId = sessionId, + preferredWorkerId = targetWorkerId, + ) } } @@ -182,7 +201,7 @@ class BossViewModel( } } - fun registerWorker( + fun createDeviceBinding( name: String, os: String, capabilitiesInput: String, @@ -201,22 +220,24 @@ class BossViewModel( .filter { it.isNotEmpty() } .ifEmpty { listOf("terminal") } - runMutation(successMessage = "设备已绑定到 Boss。") { - val worker = api.registerWorker( + runMutation(successMessage = "已生成绑定链接和启动指令。") { + val bindingLaunch = api.createDeviceBinding( baseUrl = currentBaseUrl(), name = normalizedName, os = os, capabilities = capabilities, + executor = executorType, + workspaceHint = workspaceInput.trim(), ) val snapshot = api.getBootstrap(currentBaseUrl()) - applySnapshot(snapshot, preferredWorkerId = worker.id) + applySnapshot(snapshot) _uiState.update { current -> current.copy( - generatedCommand = buildWorkerCommand( - baseUrl = current.baseUrl, - worker = worker, - executorType = executorType, - workspaceInput = workspaceInput, + generatedCommand = GeneratedWorkerCommand( + deviceName = bindingLaunch.binding.name, + launcherUrl = bindingLaunch.launcherUrl, + platformLabel = bindingLaunch.platformLabel, + command = bindingLaunch.command, ), ) } @@ -339,6 +360,7 @@ class BossViewModel( val tasks = snapshot.tasks.sortedByDescending { it.updatedAt } val approvals = snapshot.approvals.sortedByDescending { it.updatedAt } val workers = snapshot.workers.sortedBy { it.name.lowercase() } + val deviceBindings = snapshot.deviceBindings.sortedByDescending { it.updatedAt } val events = snapshot.events.sortedByDescending { it.timestamp } val selectedSessionId = preferredSessionId @@ -346,8 +368,12 @@ class BossViewModel( ?: sessions.firstOrNull { it.status == "active" }?.id ?: sessions.firstOrNull()?.id + val sessionWorkerId = sessions.firstOrNull { it.id == selectedSessionId }?.activeWorkerId + ?.takeIf { candidate -> workers.any { it.id == candidate } } val selectedWorkerId = preferredWorkerId ?.takeIf { candidate -> workers.any { it.id == candidate } } + ?: sessionWorkerId + ?: uiState.value.selectedWorkerId?.takeIf { candidate -> workers.any { it.id == candidate } } _uiState.update { current -> current.copy( @@ -356,6 +382,7 @@ class BossViewModel( tasks = tasks, approvals = approvals, workers = workers, + deviceBindings = deviceBindings, events = events, health = HealthPayload( status = "ok", @@ -456,82 +483,6 @@ class BossViewModel( } } - private fun buildWorkerCommand( - baseUrl: String, - worker: WorkerNode, - executorType: String, - workspaceInput: String, - ): GeneratedWorkerCommand { - val capabilityFlags = worker.capabilities.joinToString(" ") { capability -> - "--capability ${quoteForShell(capability, worker.os)}" - } - - val workspace = workspaceInput.trim().ifBlank { - if (worker.os == "windows") { - "C:\\path\\to\\project" - } else { - "/path/to/project" - } - } - - val normalizedBaseUrl = sanitizeBaseUrl(baseUrl) - return if (worker.os == "windows") { - val executor = when (executorType) { - "claude" -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\claude_executor.ps1" - else -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\codex_executor.ps1" - } - GeneratedWorkerCommand( - workerName = worker.name, - shellLabel = "PowerShell", - command = buildString { - append("npm run worker -- --name ") - append(quoteForShell(worker.name, worker.os)) - append(" --os ") - append(worker.os) - append(' ') - append(capabilityFlags) - append(" --mode command --workspace ") - append(quoteForShell(workspace, worker.os)) - append(" --executor ") - append(quoteForShell(executor, worker.os)) - append(" --server ") - append(quoteForShell(normalizedBaseUrl, worker.os)) - }, - ) - } else { - val executor = when (executorType) { - "claude" -> "./scripts/claude_executor.sh" - else -> "./scripts/codex_executor.sh" - } - GeneratedWorkerCommand( - workerName = worker.name, - shellLabel = "Terminal", - command = buildString { - append("npm run worker -- --name ") - append(quoteForShell(worker.name, worker.os)) - append(" --os ") - append(worker.os) - append(' ') - append(capabilityFlags) - append(" --mode command --workspace ") - append(quoteForShell(workspace, worker.os)) - append(" --executor ") - append(quoteForShell(executor, worker.os)) - append(" --server ") - append(quoteForShell(normalizedBaseUrl, worker.os)) - }, - ) - } - } - - private fun quoteForShell(value: String, os: String): String { - return if (os == "windows") { - "\"${value.replace("\"", "`\"")}\"" - } else { - "'${value.replace("'", "'\"'\"'")}'" - } - } - companion object { private const val KEY_BASE_URL = "boss_base_url" private const val MAX_EVENT_CACHE = 300 diff --git a/src/engine.ts b/src/engine.ts index dbc2fa1..e5c6fae 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -3,6 +3,8 @@ import type { ApprovalRequest, AppState, BossEvent, + DeviceBinding, + ExecutorKind, Message, Session, SessionDetails, @@ -70,6 +72,7 @@ export class BossEngine { status: "active", activeObjective: "", lastPlannerSummary: "", + activeWorkerId: null, createdAt: timestamp, updatedAt: timestamp, }; @@ -185,11 +188,17 @@ export class BossEngine { return this.getSession(sessionId); } - addMessage(sessionId: string, content: string, channel = "web"): SessionDetails { + addMessage( + sessionId: string, + content: string, + channel = "web", + targetWorkerId: string | null = null, + ): SessionDetails { const session = this.getSession(sessionId).session; if (session.status === "archived") { throw new Error(`Session ${sessionId} is archived`); } + const targetWorker = targetWorkerId ? this.getWorker(targetWorkerId) : null; const message: Message = { id: createId("msg"), sessionId, @@ -210,6 +219,7 @@ export class BossEngine { } mutableSession.activeObjective = message.content; + mutableSession.activeWorkerId = targetWorker?.id ?? mutableSession.activeWorkerId ?? null; mutableSession.updatedAt = message.createdAt; if (!mutableSession.title || mutableSession.title === "未命名项目") { mutableSession.title = message.content.slice(0, 32); @@ -223,11 +233,13 @@ export class BossEngine { payload: { channel, content: message.content, + targetWorkerId: targetWorker?.id ?? null, + targetWorkerName: targetWorker?.name ?? null, }, }); }); - this.applyPlan(session, message.content); + this.applyPlan(session, message.content, targetWorker?.id ?? null); return this.getSession(sessionId); } @@ -239,12 +251,28 @@ export class BossEngine { const timestamp = now(); const existing = this.getState().workers.find((worker) => worker.name === input.name); if (existing) { - return this.updateWorker(existing.id, { + const updated = this.updateWorker(existing.id, { os: input.os, capabilities: input.capabilities, status: "idle", load: 0, }); + this.commit((state) => { + const pendingBinding = state.deviceBindings.find( + (binding) => + binding.status === "pending" && + binding.name === updated.name && + binding.os === updated.os, + ); + if (!pendingBinding) { + return; + } + pendingBinding.status = "claimed"; + pendingBinding.claimedWorkerId = updated.id; + pendingBinding.claimedAt = timestamp; + pendingBinding.updatedAt = timestamp; + }); + return updated; } const worker: WorkerNode = { @@ -262,6 +290,18 @@ export class BossEngine { this.commit((state, addEvent) => { state.workers.push(worker); + const pendingBinding = state.deviceBindings.find( + (binding) => + binding.status === "pending" && + binding.name === worker.name && + binding.os === worker.os, + ); + if (pendingBinding) { + pendingBinding.status = "claimed"; + pendingBinding.claimedWorkerId = worker.id; + pendingBinding.claimedAt = timestamp; + pendingBinding.updatedAt = timestamp; + } addEvent({ sessionId: null, taskId: null, @@ -280,6 +320,71 @@ export class BossEngine { return worker; } + createDeviceBinding(input: { + name: string; + os: WorkerNode["os"]; + capabilities: string[]; + executor: ExecutorKind; + workspaceHint?: string; + }): DeviceBinding { + const timestamp = now(); + const binding: DeviceBinding = { + id: createId("binding"), + token: createId("bindtoken"), + name: input.name.trim(), + os: input.os, + capabilities: Array.from(new Set(input.capabilities)).filter(Boolean), + executor: input.executor, + workspaceHint: input.workspaceHint?.trim() ?? "", + status: "pending", + claimedWorkerId: null, + claimedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + }; + + if (!binding.name) { + throw new Error("Binding name is required."); + } + + if (binding.capabilities.length === 0) { + binding.capabilities = ["terminal"]; + } + + this.commit((state, addEvent) => { + state.deviceBindings = state.deviceBindings.filter( + (item) => !(item.status === "pending" && item.name === binding.name && item.os === binding.os), + ); + state.deviceBindings.unshift(binding); + addEvent({ + sessionId: null, + taskId: null, + source: "system", + type: "device.binding.created", + payload: { + bindingId: binding.id, + name: binding.name, + os: binding.os, + executor: binding.executor, + }, + }); + }); + + return binding; + } + + listDeviceBindings(): DeviceBinding[] { + return this.getState().deviceBindings.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } + + getDeviceBindingByToken(token: string): DeviceBinding { + const binding = this.getState().deviceBindings.find((item) => item.token === token); + if (!binding) { + throw new Error(`Device binding not found: ${token}`); + } + return binding; + } + updateWorker( workerId: string, input: Partial>, @@ -696,9 +801,15 @@ export class BossEngine { return this.getState(); } - private applyPlan(session: Session, content: string): void { + private applyPlan(session: Session, content: string, targetWorkerId: string | null): void { const sessionDetails = this.getSession(session.id); - const result = createPlan(sessionDetails.session, content, sessionDetails.tasks.filter(isActiveTask)); + const targetWorker = targetWorkerId ? this.getWorker(targetWorkerId) : null; + const result = createPlan( + sessionDetails.session, + content, + sessionDetails.tasks.filter(isActiveTask), + targetWorker, + ); const tasks = materializeTasks(session.id, result); const plannerMessage = buildPlannerMessage(result.summary); const timestamp = now(); @@ -711,6 +822,7 @@ export class BossEngine { } mutableSession.activeObjective = content; + mutableSession.activeWorkerId = targetWorker?.id ?? mutableSession.activeWorkerId ?? null; mutableSession.lastPlannerSummary = plannerMessage; mutableSession.updatedAt = timestamp; diff --git a/src/planner.ts b/src/planner.ts index e22ed68..6539dbc 100644 --- a/src/planner.ts +++ b/src/planner.ts @@ -1,4 +1,4 @@ -import type { Session, Task } from "./types.js"; +import type { Session, Task, WorkerNode } from "./types.js"; import { containsKeyword, createId, now } from "./utils.js"; interface DraftTask { @@ -7,6 +7,7 @@ interface DraftTask { kind: string; requiredOs: Task["requiredOs"]; requiredCapabilities: string[]; + preferredWorkerId: string | null; priority: Task["priority"]; dependencyIndexes: number[]; approvalStatus: Task["approvalStatus"]; @@ -55,20 +56,27 @@ function requiresApproval(content: string): boolean { ]); } -export function createPlan(session: Session, content: string, activeTasks: Task[]): PlannerResult { - const baseOs = inferRequiredOs(content); +export function createPlan( + session: Session, + content: string, + activeTasks: Task[], + targetWorker: WorkerNode | null, +): PlannerResult { + const baseOs = targetWorker?.os ?? inferRequiredOs(content); const baseCapabilities = inferCapabilities(content); const approvalStatus = requiresApproval(content) ? "pending" : "not_required"; + const preferredWorkerId = targetWorker?.id ?? null; const pauseExistingTasks = activeTasks.some((task) => ["planning", "queued", "assigned", "running", "paused", "blocked"].includes(task.status), ); const replan = pauseExistingTasks; + const summarySuffix = targetWorker ? `(目标设备:${targetWorker.name})` : ""; if (containsKeyword(content, ["调研", "研究", "定位", "排查", "分析"])) { return { summary: replan - ? `已根据新要求重排调研任务:${content}` - : `已生成调研型任务树:${content}`, + ? `已根据新要求重排调研任务:${content}${summarySuffix}` + : `已生成调研型任务树:${content}${summarySuffix}`, pauseExistingTasks: replan, tasks: [ { @@ -77,6 +85,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "research", requiredOs: "any", requiredCapabilities: ["terminal"], + preferredWorkerId, priority: "high", dependencyIndexes: [], approvalStatus, @@ -87,6 +96,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "investigation", requiredOs: baseOs, requiredCapabilities: baseCapabilities, + preferredWorkerId, priority: "high", dependencyIndexes: [], approvalStatus, @@ -97,6 +107,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "summary", requiredOs: "any", requiredCapabilities: ["terminal"], + preferredWorkerId, priority: "medium", dependencyIndexes: [0, 1], approvalStatus: "not_required", @@ -107,8 +118,8 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ return { summary: replan - ? `已根据最新需求重排执行计划:${content}` - : `已生成执行型任务树:${content}`, + ? `已根据最新需求重排执行计划:${content}${summarySuffix}` + : `已生成执行型任务树:${content}${summarySuffix}`, pauseExistingTasks: replan, tasks: [ { @@ -117,6 +128,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "planning", requiredOs: "any", requiredCapabilities: ["terminal"], + preferredWorkerId, priority: "high", dependencyIndexes: [], approvalStatus: "not_required", @@ -127,6 +139,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "implementation", requiredOs: baseOs, requiredCapabilities: baseCapabilities, + preferredWorkerId, priority: "high", dependencyIndexes: [0], approvalStatus, @@ -137,6 +150,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[ kind: "validation", requiredOs: "any", requiredCapabilities: Array.from(new Set([...baseCapabilities, "test"])), + preferredWorkerId, priority: "medium", dependencyIndexes: [1], approvalStatus: "not_required", @@ -165,6 +179,7 @@ export function materializeTasks(sessionId: string, result: PlannerResult): Task requiredCapabilities: draft.requiredCapabilities, dependencyIds: draft.dependencyIndexes.map((index) => placeholders[index].id), assignedWorkerId: null, + preferredWorkerId: draft.preferredWorkerId, approvalStatus: draft.approvalStatus, progressPercent: 0, summary: "", @@ -178,4 +193,3 @@ export function materializeTasks(sessionId: string, result: PlannerResult): Task export function buildPlannerMessage(summary: string): string { return `${summary}。系统会继续调度可执行子任务,并在需要审批时暂停。`; } - diff --git a/src/scheduler.ts b/src/scheduler.ts index 6141205..0a537e2 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -11,17 +11,31 @@ function workerIsIdle(worker: WorkerNode): boolean { return worker.status === "idle" && !worker.currentTaskId; } +function workerCanRun(task: Task, worker: WorkerNode): boolean { + if (task.preferredWorkerId && task.preferredWorkerId !== worker.id) { + return false; + } + + if (!(task.requiredOs === "any" || task.requiredOs === worker.os)) { + return false; + } + + return task.requiredCapabilities.every((capability) => worker.capabilities.includes(capability)); +} + function scoreWorker(task: Task, worker: WorkerNode): number { + if (!workerCanRun(task, worker)) { + return Number.NEGATIVE_INFINITY; + } + let score = 0; - if (task.requiredOs === "any" || task.requiredOs === worker.os) { - score += 10; + if (task.preferredWorkerId === worker.id) { + score += 20; } for (const capability of task.requiredCapabilities) { - if (worker.capabilities.includes(capability)) { - score += 4; - } + score += 4; } return score - worker.load; @@ -61,4 +75,3 @@ export function chooseAssignmentCandidates(state: AppState): Array<{ return assignments; } - diff --git a/src/server.ts b/src/server.ts index 560561a..5d420dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,10 +2,13 @@ import path from "node:path"; import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import { BossEngine } from "./engine.js"; +import type { DeviceBinding } from "./types.js"; const engine = new BossEngine(); const app = Fastify({ logger: process.env.BOSS_DEBUG === "1" }); const basePath = normalizeBasePath(process.env.BOSS_BASE_PATH ?? ""); +const workerRepoUrl = process.env.BOSS_WORKER_REPO_URL ?? "https://git.hyzq.site/krisolo/boss.git"; +const workerRepoBranch = process.env.BOSS_WORKER_BRANCH ?? "main"; function normalizeBasePath(input: string) { if (!input || input === "/") { @@ -22,6 +25,113 @@ function withBase(pathname: string) { return pathname === "/" ? `${basePath}/` : `${basePath}${pathname}`; } +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +function powershellQuote(value: string) { + return `'${value.replaceAll("'", "''")}'`; +} + +function publicBaseUrl(request: { + headers: Record; + protocol?: string; +}) { + const forwardedProto = request.headers["x-forwarded-proto"]; + const protocol = typeof forwardedProto === "string" && forwardedProto ? forwardedProto : request.protocol ?? "http"; + const host = request.headers.host ?? "127.0.0.1:43210"; + return `${protocol}://${host}${basePath}`; +} + +function bindingLauncherUrl(baseUrl: string, binding: DeviceBinding) { + return binding.os === "windows" + ? `${baseUrl}/api/device-bindings/${binding.token}/bootstrap.ps1` + : `${baseUrl}/api/device-bindings/${binding.token}/bootstrap.sh`; +} + +function buildBindingCommand(baseUrl: string, binding: DeviceBinding) { + const launcherUrl = bindingLauncherUrl(baseUrl, binding); + if (binding.os === "windows") { + return `powershell -ExecutionPolicy Bypass -Command "irm ${launcherUrl} | iex"`; + } + + return `curl -fsSL ${shellQuote(launcherUrl)} | bash`; +} + +function renderBootstrapScript(baseUrl: string, binding: DeviceBinding) { + const capabilityFlags = binding.capabilities.map((capability) => `--capability ${shellQuote(capability)}`).join(" "); + const executor = binding.executor === "claude" ? "./scripts/claude_executor.sh" : "./scripts/codex_executor.sh"; + const workspaceHint = binding.workspaceHint.trim(); + + return `#!/usr/bin/env bash +set -euo pipefail + +REPO_URL=${shellQuote(workerRepoUrl)} +REPO_BRANCH=${shellQuote(workerRepoBranch)} +WORKER_HOME="\${BOSS_WORKER_HOME:-$HOME/.boss-worker}" +ORIGINAL_PWD="$PWD" +WORKSPACE="\${BOSS_WORKSPACE:-${workspaceHint ? workspaceHint : '$ORIGINAL_PWD'}}" + +if ! command -v git >/dev/null 2>&1; then + echo "git is required" >&2 + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "npm is required" >&2 + exit 1 +fi + +if [[ ! -d "$WORKER_HOME/.git" ]]; then + git clone "$REPO_URL" "$WORKER_HOME" +fi + +cd "$WORKER_HOME" +git fetch origin "$REPO_BRANCH" +git checkout "$REPO_BRANCH" +git reset --hard "origin/$REPO_BRANCH" +npm install --no-fund --no-audit + +exec npm run worker -- --name ${shellQuote(binding.name)} --os ${binding.os} ${capabilityFlags} --mode command --workspace "$WORKSPACE" --executor ${shellQuote(executor)} --server ${shellQuote(baseUrl)} +`; +} + +function renderBootstrapPowerShell(baseUrl: string, binding: DeviceBinding) { + const capabilityArgs = binding.capabilities.map((capability) => `"--capability","${capability.replaceAll('"', '`"')}"`).join(","); + const executor = + binding.executor === "claude" + ? "powershell -ExecutionPolicy Bypass -File .\\scripts\\claude_executor.ps1" + : "powershell -ExecutionPolicy Bypass -File .\\scripts\\codex_executor.ps1"; + const workspaceHint = binding.workspaceHint.trim(); + + return `$ErrorActionPreference = "Stop" + +$repoUrl = ${powershellQuote(workerRepoUrl)} +$repoBranch = ${powershellQuote(workerRepoBranch)} +$workerHome = if ($env:BOSS_WORKER_HOME) { $env:BOSS_WORKER_HOME } else { Join-Path $HOME ".boss-worker" } +$originalPath = (Get-Location).Path +$workspace = if ($env:BOSS_WORKSPACE) { $env:BOSS_WORKSPACE } else { ${powershellQuote(workspaceHint)} } +if ([string]::IsNullOrWhiteSpace($workspace)) { $workspace = $originalPath } + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "git is required" } +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { throw "npm is required" } + +if (-not (Test-Path (Join-Path $workerHome ".git"))) { + git clone $repoUrl $workerHome +} + +Set-Location $workerHome +git fetch origin $repoBranch +git checkout $repoBranch +git reset --hard ("origin/" + $repoBranch) +npm install --no-fund --no-audit + +$args = @("--name",${powershellQuote(binding.name)},"--os",${powershellQuote(binding.os)},${capabilityArgs},"--mode","command","--workspace",$workspace,"--executor",${powershellQuote(executor)},"--server",${powershellQuote(baseUrl)}) +& npm run worker -- @args +exit $LASTEXITCODE +`; +} + app.setErrorHandler((error, request, reply) => { const message = typeof error === "object" && error !== null && "message" in error @@ -104,8 +214,13 @@ app.get(withBase("/api/tasks/:taskId"), async (request) => { app.post(withBase("/api/sessions/:sessionId/messages"), async (request) => { const params = request.params as { sessionId: string }; - const body = (request.body ?? {}) as { content?: string; channel?: string }; - return engine.addMessage(params.sessionId, body.content ?? "", body.channel ?? "web"); + const body = (request.body ?? {}) as { content?: string; channel?: string; targetWorkerId?: string | null }; + return engine.addMessage( + params.sessionId, + body.content ?? "", + body.channel ?? "web", + body.targetWorkerId ?? null, + ); }); app.get(withBase("/api/events/stream"), async (_request, reply) => { @@ -131,6 +246,8 @@ app.get(withBase("/api/events/stream"), async (_request, reply) => { app.get(withBase("/api/workers"), async () => engine.getState().workers); +app.get(withBase("/api/device-bindings"), async () => engine.listDeviceBindings()); + app.get(withBase("/api/workers/:workerId"), async (request) => { const params = request.params as { workerId: string }; return engine.getWorker(params.workerId); @@ -149,6 +266,46 @@ app.post(withBase("/api/workers/register"), async (request) => { }); }); +app.post(withBase("/api/device-bindings"), async (request) => { + const body = request.body as { + name?: string; + os?: "windows" | "macos" | "linux"; + capabilities?: string[]; + executor?: "codex" | "claude"; + workspaceHint?: string; + }; + const binding = engine.createDeviceBinding({ + name: body.name ?? "worker", + os: body.os ?? "macos", + capabilities: body.capabilities ?? ["terminal"], + executor: body.executor ?? "codex", + workspaceHint: body.workspaceHint ?? "", + }); + const baseUrl = publicBaseUrl(request); + return { + binding, + launcherUrl: bindingLauncherUrl(baseUrl, binding), + command: buildBindingCommand(baseUrl, binding), + platformLabel: binding.os === "windows" ? "Windows PowerShell" : "macOS / Linux Terminal", + }; +}); + +app.get(withBase("/api/device-bindings/:token/bootstrap.sh"), async (request, reply) => { + const params = request.params as { token: string }; + const binding = engine.getDeviceBindingByToken(params.token); + const baseUrl = publicBaseUrl(request); + reply.type("text/x-shellscript; charset=utf-8"); + return renderBootstrapScript(baseUrl, binding); +}); + +app.get(withBase("/api/device-bindings/:token/bootstrap.ps1"), async (request, reply) => { + const params = request.params as { token: string }; + const binding = engine.getDeviceBindingByToken(params.token); + const baseUrl = publicBaseUrl(request); + reply.type("text/plain; charset=utf-8"); + return renderBootstrapPowerShell(baseUrl, binding); +}); + app.post(withBase("/api/workers/:workerId/heartbeat"), async (request) => { const params = request.params as { workerId: string }; const body = (request.body ?? {}) as { load?: number }; diff --git a/src/store.ts b/src/store.ts index 70122c7..b50af2a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,6 +9,7 @@ function defaultState(): AppState { tasks: [], workers: [], approvals: [], + deviceBindings: [], events: [], }; } diff --git a/src/types.ts b/src/types.ts index b96587b..452341a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export type TaskStatus = export type WorkerStatus = "idle" | "busy" | "offline"; export type ApprovalStatus = "pending" | "approved" | "rejected"; export type RiskLevel = "low" | "medium" | "high"; +export type DeviceBindingStatus = "pending" | "claimed" | "expired"; +export type ExecutorKind = "codex" | "claude"; export interface Session { id: string; @@ -20,6 +22,7 @@ export interface Session { status: SessionStatus; activeObjective: string; lastPlannerSummary: string; + activeWorkerId: string | null; createdAt: string; updatedAt: string; } @@ -46,6 +49,7 @@ export interface Task { requiredCapabilities: string[]; dependencyIds: string[]; assignedWorkerId: string | null; + preferredWorkerId: string | null; approvalStatus: "not_required" | ApprovalStatus; progressPercent: number; summary: string; @@ -82,6 +86,21 @@ export interface ApprovalRequest { updatedAt: string; } +export interface DeviceBinding { + id: string; + token: string; + name: string; + os: WorkerNode["os"]; + capabilities: string[]; + executor: ExecutorKind; + workspaceHint: string; + status: DeviceBindingStatus; + claimedWorkerId: string | null; + claimedAt: string | null; + createdAt: string; + updatedAt: string; +} + export interface BossEvent { id: string; sessionId: string | null; @@ -98,6 +117,7 @@ export interface AppState { tasks: Task[]; workers: WorkerNode[]; approvals: ApprovalRequest[]; + deviceBindings: DeviceBinding[]; events: BossEvent[]; } @@ -107,4 +127,3 @@ export interface SessionDetails { tasks: Task[]; approvals: ApprovalRequest[]; } -