feat: add device-targeted android control flow

This commit is contained in:
Codex
2026-03-23 15:03:17 +08:00
parent 0148440ad5
commit c53ff3965b
11 changed files with 621 additions and 128 deletions

View File

@@ -212,11 +212,11 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度 - SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步 - 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
- 会话创建、切换、持续对话 - 会话创建、切换,并按“先选设备再对话”的方式把需求定向发到对应客户端
- 任务分组查看、暂停、恢复、取消、重排 - 任务分组查看、暂停、恢复、取消、重排
- 审批查看与批准/拒绝 - 审批查看与批准/拒绝
- 设备列表、设备聚焦切换、设备下线 - 设备列表、设备聚焦切换、设备下线,以及按设备视角过滤任务
- 绑定新设备并生成启动命令 - 绑定新设备时自动生成 Mac / Windows 对应的超链接和终端命令,拿到目标电脑执行即可完成绑定
- 云端 Boss 地址切换与重排入口 - 云端 Boss 地址切换与重排入口
同步模型说明: 同步模型说明:

View File

@@ -9,6 +9,7 @@ data class AppStatePayload(
val messages: List<Message> = emptyList(), val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(), val tasks: List<TaskItem> = emptyList(),
val workers: List<WorkerNode> = emptyList(), val workers: List<WorkerNode> = emptyList(),
val deviceBindings: List<DeviceBinding> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(), val approvals: List<ApprovalRequest> = emptyList(),
val events: List<BossEvent> = emptyList(), val events: List<BossEvent> = emptyList(),
) )
@@ -27,6 +28,7 @@ data class Session(
val status: String, val status: String,
val activeObjective: String = "", val activeObjective: String = "",
val lastPlannerSummary: String = "", val lastPlannerSummary: String = "",
val activeWorkerId: String? = null,
val createdAt: String, val createdAt: String,
val updatedAt: String, val updatedAt: String,
) )
@@ -55,6 +57,7 @@ data class TaskItem(
val requiredCapabilities: List<String> = emptyList(), val requiredCapabilities: List<String> = emptyList(),
val dependencyIds: List<String> = emptyList(), val dependencyIds: List<String> = emptyList(),
val assignedWorkerId: String? = null, val assignedWorkerId: String? = null,
val preferredWorkerId: String? = null,
val approvalStatus: String = "not_required", val approvalStatus: String = "not_required",
val progressPercent: Int = 0, val progressPercent: Int = 0,
val summary: String = "", val summary: String = "",
@@ -103,3 +106,27 @@ data class BossEvent(
val timestamp: String, val timestamp: String,
val payload: Map<String, JsonElement> = emptyMap(), val payload: Map<String, JsonElement> = emptyMap(),
) )
@Serializable
data class DeviceBinding(
val id: String,
val token: String,
val name: String,
val os: String,
val capabilities: List<String> = 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,
)

View File

@@ -14,6 +14,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonArray
import site.hyzq.bossandroid.model.AppStatePayload import site.hyzq.bossandroid.model.AppStatePayload
import site.hyzq.bossandroid.model.ApprovalRequest import site.hyzq.bossandroid.model.ApprovalRequest
import site.hyzq.bossandroid.model.DeviceBindingLaunchPayload
import site.hyzq.bossandroid.model.HealthPayload import site.hyzq.bossandroid.model.HealthPayload
import site.hyzq.bossandroid.model.Session import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem 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<UnitPayload>( post<UnitPayload>(
baseUrl = baseUrl, baseUrl = baseUrl,
path = "/api/sessions/$sessionId/messages", path = "/api/sessions/$sessionId/messages",
body = buildJsonObject { body = buildJsonObject {
put("content", JsonPrimitive(content)) put("content", JsonPrimitive(content))
put("channel", JsonPrimitive("android")) put("channel", JsonPrimitive("android"))
if (!targetWorkerId.isNullOrBlank()) {
put("targetWorkerId", JsonPrimitive(targetWorkerId))
}
}, },
) )
return getBootstrap(baseUrl) return getBootstrap(baseUrl)
@@ -81,6 +90,27 @@ class BossApi(
}, },
) )
suspend fun createDeviceBinding(
baseUrl: String,
name: String,
os: String,
capabilities: List<String>,
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 { suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload {
post<UnitPayload>(baseUrl, "/api/workers/$workerId/offline") post<UnitPayload>(baseUrl, "/api/workers/$workerId/offline")
return getBootstrap(baseUrl) return getBootstrap(baseUrl)

View File

@@ -73,6 +73,7 @@ import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import site.hyzq.bossandroid.model.ApprovalRequest import site.hyzq.bossandroid.model.ApprovalRequest
import site.hyzq.bossandroid.model.BossEvent import site.hyzq.bossandroid.model.BossEvent
import site.hyzq.bossandroid.model.DeviceBinding
import site.hyzq.bossandroid.model.Message import site.hyzq.bossandroid.model.Message
import site.hyzq.bossandroid.model.Session import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem import site.hyzq.bossandroid.model.TaskItem
@@ -182,7 +183,7 @@ fun BossApp(
MainSection.DEVICES -> DevicesScreen( MainSection.DEVICES -> DevicesScreen(
uiState = uiState, uiState = uiState,
clipboard = clipboard, clipboard = clipboard,
onBindWorker = viewModel::registerWorker, onBindWorker = viewModel::createDeviceBinding,
onSelectWorker = viewModel::selectWorker, onSelectWorker = viewModel::selectWorker,
onMarkOffline = viewModel::markWorkerOffline, onMarkOffline = viewModel::markWorkerOffline,
onClearGeneratedCommand = viewModel::clearGeneratedCommand, onClearGeneratedCommand = viewModel::clearGeneratedCommand,
@@ -226,7 +227,9 @@ private fun ConversationsScreen(
event.sessionId == selectedSession?.id || event.sessionId == null event.sessionId == selectedSession?.id || event.sessionId == null
}.take(8) }.take(8)
val filteredTasks = sessionTasks.filter { task -> val filteredTasks = sessionTasks.filter { task ->
uiState.selectedWorkerId == null || task.assignedWorkerId == uiState.selectedWorkerId uiState.selectedWorkerId == null ||
task.assignedWorkerId == uiState.selectedWorkerId ||
task.preferredWorkerId == uiState.selectedWorkerId
} }
Column( Column(
@@ -244,10 +247,25 @@ private fun ConversationsScreen(
onSelectSession = onSelectSession, onSelectSession = onSelectSession,
) )
if (uiState.workers.isNotEmpty()) {
DeviceConversationSelector(
workers = uiState.workers,
selectedWorkerId = uiState.selectedWorkerId,
onSelectWorker = onSelectWorker,
)
}
if (selectedWorker != null) { if (selectedWorker != null) {
FocusBanner( FocusBanner(
title = "当前设备视角", title = "当前设备视角",
body = "已切换到 ${selectedWorker.name},任务列表会按这个设备过滤。", body = "已切换到 ${selectedWorker.name}接下来发送的需求会定向给这台设备,任务列表会按过滤。",
actionLabel = "清除",
onAction = onClearWorkerFocus,
)
} else if (uiState.workers.isNotEmpty()) {
FocusBanner(
title = "先选择设备再对话",
body = "这版安卓主控会按设备维度投递需求。先选一台设备,再继续当前项目。",
actionLabel = "清除", actionLabel = "清除",
onAction = onClearWorkerFocus, onAction = onClearWorkerFocus,
) )
@@ -299,6 +317,8 @@ private fun ConversationsScreen(
ChatTab( ChatTab(
sessionMessages = sessionMessages, sessionMessages = sessionMessages,
sessionEvents = sessionEvents, sessionEvents = sessionEvents,
selectedWorker = selectedWorker,
requiresDeviceSelection = uiState.workers.isNotEmpty(),
onSendMessage = onSendMessage, onSendMessage = onSendMessage,
) )
} }
@@ -346,6 +366,8 @@ private fun DevicesScreen(
onClearGeneratedCommand: () -> Unit, onClearGeneratedCommand: () -> Unit,
) { ) {
val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId } 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) { val relatedTasks = remember(uiState.tasks) {
uiState.tasks.associateBy { it.id } uiState.tasks.associateBy { it.id }
} }
@@ -368,6 +390,13 @@ private fun DevicesScreen(
onDismissCommand = onClearGeneratedCommand, onDismissCommand = onClearGeneratedCommand,
) )
if (pendingBindings.isNotEmpty()) {
SectionHeading("待完成绑定")
pendingBindings.forEach { binding ->
DeviceBindingCard(binding = binding)
}
}
if (selectedWorker != null) { if (selectedWorker != null) {
FocusBanner( FocusBanner(
title = "当前设备焦点", 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( private fun ChatTab(
sessionMessages: List<Message>, sessionMessages: List<Message>,
sessionEvents: List<BossEvent>, sessionEvents: List<BossEvent>,
selectedWorker: WorkerNode?,
requiresDeviceSelection: Boolean,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
) { ) {
var message by rememberSaveable { mutableStateOf("") } var message by rememberSaveable { mutableStateOf("") }
@@ -787,6 +825,14 @@ private fun ChatTab(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text("和 Boss 对话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) 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( OutlinedTextField(
value = message, value = message,
onValueChange = { message = it }, onValueChange = { message = it },
@@ -801,8 +847,15 @@ private fun ChatTab(
onSendMessage(message) onSendMessage(message)
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("绑定新设备", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text( Text(
"在这里登记设备身份Boss 会直接生成对应启动命令。适合把 Windows、Mac 和 Linux 全部挂到同一个主控下", "在这里登记设备身份Boss 会生成对应平台的超链接和终端指令。你只需要把它拿到目标电脑上执行,就能完成绑定",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
OutlinedTextField( OutlinedTextField(
@@ -950,9 +1003,8 @@ private fun BindDeviceCard(
) )
Button(onClick = { Button(onClick = {
onBindWorker(name, os, capabilities, executor, workspace) onBindWorker(name, os, capabilities, executor, workspace)
name = ""
}) { }) {
Text("绑定并生成命令") Text("生成绑定链接")
} }
if (generatedCommand != null) { if (generatedCommand != null) {
@@ -966,14 +1018,38 @@ private fun BindDeviceCard(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
"${generatedCommand.workerName} 已就绪", "${generatedCommand.deviceName} 绑定指令已生成",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Text( Text(
"把下面这条命令贴到对应设备的 ${generatedCommand.shellLabel} 里即可启动 worker", "先把链接或命令发到目标设备,再在那台 ${generatedCommand.platformLabel} 电脑的终端里运行",
color = MaterialTheme.colorScheme.onSecondaryContainer, 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -990,6 +1066,11 @@ private fun BindDeviceCard(
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { Button(onClick = {
clipboard.setText(AnnotatedString(generatedCommand.launcherUrl))
}) {
Text("复制链接")
}
OutlinedButton(onClick = {
clipboard.setText(AnnotatedString(generatedCommand.command)) clipboard.setText(AnnotatedString(generatedCommand.command))
}) { }) {
Text("复制命令") Text("复制命令")
@@ -1005,6 +1086,94 @@ private fun BindDeviceCard(
} }
} }
@Composable
private fun DeviceConversationSelector(
workers: List<WorkerNode>,
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 @Composable
private fun WorkerCard( private fun WorkerCard(
worker: WorkerNode, worker: WorkerNode,

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withLock
import site.hyzq.bossandroid.model.AppStatePayload import site.hyzq.bossandroid.model.AppStatePayload
import site.hyzq.bossandroid.model.ApprovalRequest import site.hyzq.bossandroid.model.ApprovalRequest
import site.hyzq.bossandroid.model.BossEvent import site.hyzq.bossandroid.model.BossEvent
import site.hyzq.bossandroid.model.DeviceBinding
import site.hyzq.bossandroid.model.HealthPayload import site.hyzq.bossandroid.model.HealthPayload
import site.hyzq.bossandroid.model.Message import site.hyzq.bossandroid.model.Message
import site.hyzq.bossandroid.model.Session import site.hyzq.bossandroid.model.Session
@@ -46,8 +47,9 @@ data class UiNotice(
) )
data class GeneratedWorkerCommand( data class GeneratedWorkerCommand(
val workerName: String, val deviceName: String,
val shellLabel: String, val launcherUrl: String,
val platformLabel: String,
val command: String, val command: String,
) )
@@ -60,6 +62,7 @@ data class BossUiState(
val tasks: List<TaskItem> = emptyList(), val tasks: List<TaskItem> = emptyList(),
val approvals: List<ApprovalRequest> = emptyList(), val approvals: List<ApprovalRequest> = emptyList(),
val workers: List<WorkerNode> = emptyList(), val workers: List<WorkerNode> = emptyList(),
val deviceBindings: List<DeviceBinding> = emptyList(),
val events: List<BossEvent> = emptyList(), val events: List<BossEvent> = emptyList(),
val selectedSessionId: String? = null, val selectedSessionId: String? = null,
val selectedWorkerId: String? = null, val selectedWorkerId: String? = null,
@@ -106,8 +109,13 @@ class BossViewModel(
fun selectSession(sessionId: String) { fun selectSession(sessionId: String) {
_uiState.update { current -> _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( current.copy(
selectedSessionId = sessionId, selectedSessionId = sessionId,
selectedWorkerId = nextWorkerId,
section = MainSection.CONVERSATIONS, section = MainSection.CONVERSATIONS,
) )
} }
@@ -115,10 +123,7 @@ class BossViewModel(
fun selectWorker(workerId: String?) { fun selectWorker(workerId: String?) {
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(selectedWorkerId = workerId)
selectedWorkerId = workerId,
section = if (workerId == null) current.section else MainSection.DEVICES,
)
} }
} }
@@ -159,10 +164,24 @@ class BossViewModel(
publishNotice("消息不能为空。") publishNotice("消息不能为空。")
return return
} }
if (uiState.value.workers.isNotEmpty() && uiState.value.selectedWorkerId == null) {
publishNotice("请先切换到一个设备,再发送需求。")
return
}
runMutation(successMessage = "需求已发送给 Boss。") { runMutation(successMessage = "需求已发送给 Boss。") {
val snapshot = api.addMessage(currentBaseUrl(), sessionId, message) val targetWorkerId = uiState.value.selectedWorkerId
applySnapshot(snapshot, preferredSessionId = sessionId) 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, name: String,
os: String, os: String,
capabilitiesInput: String, capabilitiesInput: String,
@@ -201,22 +220,24 @@ class BossViewModel(
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.ifEmpty { listOf("terminal") } .ifEmpty { listOf("terminal") }
runMutation(successMessage = "设备已绑定到 Boss") { runMutation(successMessage = "已生成绑定链接和启动指令") {
val worker = api.registerWorker( val bindingLaunch = api.createDeviceBinding(
baseUrl = currentBaseUrl(), baseUrl = currentBaseUrl(),
name = normalizedName, name = normalizedName,
os = os, os = os,
capabilities = capabilities, capabilities = capabilities,
executor = executorType,
workspaceHint = workspaceInput.trim(),
) )
val snapshot = api.getBootstrap(currentBaseUrl()) val snapshot = api.getBootstrap(currentBaseUrl())
applySnapshot(snapshot, preferredWorkerId = worker.id) applySnapshot(snapshot)
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
generatedCommand = buildWorkerCommand( generatedCommand = GeneratedWorkerCommand(
baseUrl = current.baseUrl, deviceName = bindingLaunch.binding.name,
worker = worker, launcherUrl = bindingLaunch.launcherUrl,
executorType = executorType, platformLabel = bindingLaunch.platformLabel,
workspaceInput = workspaceInput, command = bindingLaunch.command,
), ),
) )
} }
@@ -339,6 +360,7 @@ class BossViewModel(
val tasks = snapshot.tasks.sortedByDescending { it.updatedAt } val tasks = snapshot.tasks.sortedByDescending { it.updatedAt }
val approvals = snapshot.approvals.sortedByDescending { it.updatedAt } val approvals = snapshot.approvals.sortedByDescending { it.updatedAt }
val workers = snapshot.workers.sortedBy { it.name.lowercase() } val workers = snapshot.workers.sortedBy { it.name.lowercase() }
val deviceBindings = snapshot.deviceBindings.sortedByDescending { it.updatedAt }
val events = snapshot.events.sortedByDescending { it.timestamp } val events = snapshot.events.sortedByDescending { it.timestamp }
val selectedSessionId = preferredSessionId val selectedSessionId = preferredSessionId
@@ -346,8 +368,12 @@ class BossViewModel(
?: sessions.firstOrNull { it.status == "active" }?.id ?: sessions.firstOrNull { it.status == "active" }?.id
?: sessions.firstOrNull()?.id ?: sessions.firstOrNull()?.id
val sessionWorkerId = sessions.firstOrNull { it.id == selectedSessionId }?.activeWorkerId
?.takeIf { candidate -> workers.any { it.id == candidate } }
val selectedWorkerId = preferredWorkerId val selectedWorkerId = preferredWorkerId
?.takeIf { candidate -> workers.any { it.id == candidate } } ?.takeIf { candidate -> workers.any { it.id == candidate } }
?: sessionWorkerId
?: uiState.value.selectedWorkerId?.takeIf { candidate -> workers.any { it.id == candidate } }
_uiState.update { current -> _uiState.update { current ->
current.copy( current.copy(
@@ -356,6 +382,7 @@ class BossViewModel(
tasks = tasks, tasks = tasks,
approvals = approvals, approvals = approvals,
workers = workers, workers = workers,
deviceBindings = deviceBindings,
events = events, events = events,
health = HealthPayload( health = HealthPayload(
status = "ok", 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 { companion object {
private const val KEY_BASE_URL = "boss_base_url" private const val KEY_BASE_URL = "boss_base_url"
private const val MAX_EVENT_CACHE = 300 private const val MAX_EVENT_CACHE = 300

View File

@@ -3,6 +3,8 @@ import type {
ApprovalRequest, ApprovalRequest,
AppState, AppState,
BossEvent, BossEvent,
DeviceBinding,
ExecutorKind,
Message, Message,
Session, Session,
SessionDetails, SessionDetails,
@@ -70,6 +72,7 @@ export class BossEngine {
status: "active", status: "active",
activeObjective: "", activeObjective: "",
lastPlannerSummary: "", lastPlannerSummary: "",
activeWorkerId: null,
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp, updatedAt: timestamp,
}; };
@@ -185,11 +188,17 @@ export class BossEngine {
return this.getSession(sessionId); 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; const session = this.getSession(sessionId).session;
if (session.status === "archived") { if (session.status === "archived") {
throw new Error(`Session ${sessionId} is archived`); throw new Error(`Session ${sessionId} is archived`);
} }
const targetWorker = targetWorkerId ? this.getWorker(targetWorkerId) : null;
const message: Message = { const message: Message = {
id: createId("msg"), id: createId("msg"),
sessionId, sessionId,
@@ -210,6 +219,7 @@ export class BossEngine {
} }
mutableSession.activeObjective = message.content; mutableSession.activeObjective = message.content;
mutableSession.activeWorkerId = targetWorker?.id ?? mutableSession.activeWorkerId ?? null;
mutableSession.updatedAt = message.createdAt; mutableSession.updatedAt = message.createdAt;
if (!mutableSession.title || mutableSession.title === "未命名项目") { if (!mutableSession.title || mutableSession.title === "未命名项目") {
mutableSession.title = message.content.slice(0, 32); mutableSession.title = message.content.slice(0, 32);
@@ -223,11 +233,13 @@ export class BossEngine {
payload: { payload: {
channel, channel,
content: message.content, 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); return this.getSession(sessionId);
} }
@@ -239,12 +251,28 @@ export class BossEngine {
const timestamp = now(); const timestamp = now();
const existing = this.getState().workers.find((worker) => worker.name === input.name); const existing = this.getState().workers.find((worker) => worker.name === input.name);
if (existing) { if (existing) {
return this.updateWorker(existing.id, { const updated = this.updateWorker(existing.id, {
os: input.os, os: input.os,
capabilities: input.capabilities, capabilities: input.capabilities,
status: "idle", status: "idle",
load: 0, 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 = { const worker: WorkerNode = {
@@ -262,6 +290,18 @@ export class BossEngine {
this.commit((state, addEvent) => { this.commit((state, addEvent) => {
state.workers.push(worker); 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({ addEvent({
sessionId: null, sessionId: null,
taskId: null, taskId: null,
@@ -280,6 +320,71 @@ export class BossEngine {
return worker; 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( updateWorker(
workerId: string, workerId: string,
input: Partial<Pick<WorkerNode, "os" | "capabilities" | "status" | "load">>, input: Partial<Pick<WorkerNode, "os" | "capabilities" | "status" | "load">>,
@@ -696,9 +801,15 @@ export class BossEngine {
return this.getState(); 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 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 tasks = materializeTasks(session.id, result);
const plannerMessage = buildPlannerMessage(result.summary); const plannerMessage = buildPlannerMessage(result.summary);
const timestamp = now(); const timestamp = now();
@@ -711,6 +822,7 @@ export class BossEngine {
} }
mutableSession.activeObjective = content; mutableSession.activeObjective = content;
mutableSession.activeWorkerId = targetWorker?.id ?? mutableSession.activeWorkerId ?? null;
mutableSession.lastPlannerSummary = plannerMessage; mutableSession.lastPlannerSummary = plannerMessage;
mutableSession.updatedAt = timestamp; mutableSession.updatedAt = timestamp;

View File

@@ -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"; import { containsKeyword, createId, now } from "./utils.js";
interface DraftTask { interface DraftTask {
@@ -7,6 +7,7 @@ interface DraftTask {
kind: string; kind: string;
requiredOs: Task["requiredOs"]; requiredOs: Task["requiredOs"];
requiredCapabilities: string[]; requiredCapabilities: string[];
preferredWorkerId: string | null;
priority: Task["priority"]; priority: Task["priority"];
dependencyIndexes: number[]; dependencyIndexes: number[];
approvalStatus: Task["approvalStatus"]; approvalStatus: Task["approvalStatus"];
@@ -55,20 +56,27 @@ function requiresApproval(content: string): boolean {
]); ]);
} }
export function createPlan(session: Session, content: string, activeTasks: Task[]): PlannerResult { export function createPlan(
const baseOs = inferRequiredOs(content); session: Session,
content: string,
activeTasks: Task[],
targetWorker: WorkerNode | null,
): PlannerResult {
const baseOs = targetWorker?.os ?? inferRequiredOs(content);
const baseCapabilities = inferCapabilities(content); const baseCapabilities = inferCapabilities(content);
const approvalStatus = requiresApproval(content) ? "pending" : "not_required"; const approvalStatus = requiresApproval(content) ? "pending" : "not_required";
const preferredWorkerId = targetWorker?.id ?? null;
const pauseExistingTasks = activeTasks.some((task) => const pauseExistingTasks = activeTasks.some((task) =>
["planning", "queued", "assigned", "running", "paused", "blocked"].includes(task.status), ["planning", "queued", "assigned", "running", "paused", "blocked"].includes(task.status),
); );
const replan = pauseExistingTasks; const replan = pauseExistingTasks;
const summarySuffix = targetWorker ? `(目标设备:${targetWorker.name}` : "";
if (containsKeyword(content, ["调研", "研究", "定位", "排查", "分析"])) { if (containsKeyword(content, ["调研", "研究", "定位", "排查", "分析"])) {
return { return {
summary: replan summary: replan
? `已根据新要求重排调研任务:${content}` ? `已根据新要求重排调研任务:${content}${summarySuffix}`
: `已生成调研型任务树:${content}`, : `已生成调研型任务树:${content}${summarySuffix}`,
pauseExistingTasks: replan, pauseExistingTasks: replan,
tasks: [ tasks: [
{ {
@@ -77,6 +85,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "research", kind: "research",
requiredOs: "any", requiredOs: "any",
requiredCapabilities: ["terminal"], requiredCapabilities: ["terminal"],
preferredWorkerId,
priority: "high", priority: "high",
dependencyIndexes: [], dependencyIndexes: [],
approvalStatus, approvalStatus,
@@ -87,6 +96,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "investigation", kind: "investigation",
requiredOs: baseOs, requiredOs: baseOs,
requiredCapabilities: baseCapabilities, requiredCapabilities: baseCapabilities,
preferredWorkerId,
priority: "high", priority: "high",
dependencyIndexes: [], dependencyIndexes: [],
approvalStatus, approvalStatus,
@@ -97,6 +107,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "summary", kind: "summary",
requiredOs: "any", requiredOs: "any",
requiredCapabilities: ["terminal"], requiredCapabilities: ["terminal"],
preferredWorkerId,
priority: "medium", priority: "medium",
dependencyIndexes: [0, 1], dependencyIndexes: [0, 1],
approvalStatus: "not_required", approvalStatus: "not_required",
@@ -107,8 +118,8 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
return { return {
summary: replan summary: replan
? `已根据最新需求重排执行计划:${content}` ? `已根据最新需求重排执行计划:${content}${summarySuffix}`
: `已生成执行型任务树:${content}`, : `已生成执行型任务树:${content}${summarySuffix}`,
pauseExistingTasks: replan, pauseExistingTasks: replan,
tasks: [ tasks: [
{ {
@@ -117,6 +128,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "planning", kind: "planning",
requiredOs: "any", requiredOs: "any",
requiredCapabilities: ["terminal"], requiredCapabilities: ["terminal"],
preferredWorkerId,
priority: "high", priority: "high",
dependencyIndexes: [], dependencyIndexes: [],
approvalStatus: "not_required", approvalStatus: "not_required",
@@ -127,6 +139,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "implementation", kind: "implementation",
requiredOs: baseOs, requiredOs: baseOs,
requiredCapabilities: baseCapabilities, requiredCapabilities: baseCapabilities,
preferredWorkerId,
priority: "high", priority: "high",
dependencyIndexes: [0], dependencyIndexes: [0],
approvalStatus, approvalStatus,
@@ -137,6 +150,7 @@ export function createPlan(session: Session, content: string, activeTasks: Task[
kind: "validation", kind: "validation",
requiredOs: "any", requiredOs: "any",
requiredCapabilities: Array.from(new Set([...baseCapabilities, "test"])), requiredCapabilities: Array.from(new Set([...baseCapabilities, "test"])),
preferredWorkerId,
priority: "medium", priority: "medium",
dependencyIndexes: [1], dependencyIndexes: [1],
approvalStatus: "not_required", approvalStatus: "not_required",
@@ -165,6 +179,7 @@ export function materializeTasks(sessionId: string, result: PlannerResult): Task
requiredCapabilities: draft.requiredCapabilities, requiredCapabilities: draft.requiredCapabilities,
dependencyIds: draft.dependencyIndexes.map((index) => placeholders[index].id), dependencyIds: draft.dependencyIndexes.map((index) => placeholders[index].id),
assignedWorkerId: null, assignedWorkerId: null,
preferredWorkerId: draft.preferredWorkerId,
approvalStatus: draft.approvalStatus, approvalStatus: draft.approvalStatus,
progressPercent: 0, progressPercent: 0,
summary: "", summary: "",
@@ -178,4 +193,3 @@ export function materializeTasks(sessionId: string, result: PlannerResult): Task
export function buildPlannerMessage(summary: string): string { export function buildPlannerMessage(summary: string): string {
return `${summary}。系统会继续调度可执行子任务,并在需要审批时暂停。`; return `${summary}。系统会继续调度可执行子任务,并在需要审批时暂停。`;
} }

View File

@@ -11,17 +11,31 @@ function workerIsIdle(worker: WorkerNode): boolean {
return worker.status === "idle" && !worker.currentTaskId; 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 { function scoreWorker(task: Task, worker: WorkerNode): number {
if (!workerCanRun(task, worker)) {
return Number.NEGATIVE_INFINITY;
}
let score = 0; let score = 0;
if (task.requiredOs === "any" || task.requiredOs === worker.os) { if (task.preferredWorkerId === worker.id) {
score += 10; score += 20;
} }
for (const capability of task.requiredCapabilities) { for (const capability of task.requiredCapabilities) {
if (worker.capabilities.includes(capability)) { score += 4;
score += 4;
}
} }
return score - worker.load; return score - worker.load;
@@ -61,4 +75,3 @@ export function chooseAssignmentCandidates(state: AppState): Array<{
return assignments; return assignments;
} }

View File

@@ -2,10 +2,13 @@ import path from "node:path";
import Fastify from "fastify"; import Fastify from "fastify";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import { BossEngine } from "./engine.js"; import { BossEngine } from "./engine.js";
import type { DeviceBinding } from "./types.js";
const engine = new BossEngine(); const engine = new BossEngine();
const app = Fastify({ logger: process.env.BOSS_DEBUG === "1" }); const app = Fastify({ logger: process.env.BOSS_DEBUG === "1" });
const basePath = normalizeBasePath(process.env.BOSS_BASE_PATH ?? ""); 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) { function normalizeBasePath(input: string) {
if (!input || input === "/") { if (!input || input === "/") {
@@ -22,6 +25,113 @@ function withBase(pathname: string) {
return pathname === "/" ? `${basePath}/` : `${basePath}${pathname}`; return pathname === "/" ? `${basePath}/` : `${basePath}${pathname}`;
} }
function shellQuote(value: string) {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
function powershellQuote(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
function publicBaseUrl(request: {
headers: Record<string, string | string[] | undefined>;
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) => { app.setErrorHandler((error, request, reply) => {
const message = const message =
typeof error === "object" && error !== null && "message" in error 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) => { app.post(withBase("/api/sessions/:sessionId/messages"), async (request) => {
const params = request.params as { sessionId: string }; const params = request.params as { sessionId: string };
const body = (request.body ?? {}) as { content?: string; channel?: string }; const body = (request.body ?? {}) as { content?: string; channel?: string; targetWorkerId?: string | null };
return engine.addMessage(params.sessionId, body.content ?? "", body.channel ?? "web"); return engine.addMessage(
params.sessionId,
body.content ?? "",
body.channel ?? "web",
body.targetWorkerId ?? null,
);
}); });
app.get(withBase("/api/events/stream"), async (_request, reply) => { 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/workers"), async () => engine.getState().workers);
app.get(withBase("/api/device-bindings"), async () => engine.listDeviceBindings());
app.get(withBase("/api/workers/:workerId"), async (request) => { app.get(withBase("/api/workers/:workerId"), async (request) => {
const params = request.params as { workerId: string }; const params = request.params as { workerId: string };
return engine.getWorker(params.workerId); 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) => { app.post(withBase("/api/workers/:workerId/heartbeat"), async (request) => {
const params = request.params as { workerId: string }; const params = request.params as { workerId: string };
const body = (request.body ?? {}) as { load?: number }; const body = (request.body ?? {}) as { load?: number };

View File

@@ -9,6 +9,7 @@ function defaultState(): AppState {
tasks: [], tasks: [],
workers: [], workers: [],
approvals: [], approvals: [],
deviceBindings: [],
events: [], events: [],
}; };
} }

View File

@@ -13,6 +13,8 @@ export type TaskStatus =
export type WorkerStatus = "idle" | "busy" | "offline"; export type WorkerStatus = "idle" | "busy" | "offline";
export type ApprovalStatus = "pending" | "approved" | "rejected"; export type ApprovalStatus = "pending" | "approved" | "rejected";
export type RiskLevel = "low" | "medium" | "high"; export type RiskLevel = "low" | "medium" | "high";
export type DeviceBindingStatus = "pending" | "claimed" | "expired";
export type ExecutorKind = "codex" | "claude";
export interface Session { export interface Session {
id: string; id: string;
@@ -20,6 +22,7 @@ export interface Session {
status: SessionStatus; status: SessionStatus;
activeObjective: string; activeObjective: string;
lastPlannerSummary: string; lastPlannerSummary: string;
activeWorkerId: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -46,6 +49,7 @@ export interface Task {
requiredCapabilities: string[]; requiredCapabilities: string[];
dependencyIds: string[]; dependencyIds: string[];
assignedWorkerId: string | null; assignedWorkerId: string | null;
preferredWorkerId: string | null;
approvalStatus: "not_required" | ApprovalStatus; approvalStatus: "not_required" | ApprovalStatus;
progressPercent: number; progressPercent: number;
summary: string; summary: string;
@@ -82,6 +86,21 @@ export interface ApprovalRequest {
updatedAt: string; 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 { export interface BossEvent {
id: string; id: string;
sessionId: string | null; sessionId: string | null;
@@ -98,6 +117,7 @@ export interface AppState {
tasks: Task[]; tasks: Task[];
workers: WorkerNode[]; workers: WorkerNode[];
approvals: ApprovalRequest[]; approvals: ApprovalRequest[];
deviceBindings: DeviceBinding[];
events: BossEvent[]; events: BossEvent[];
} }
@@ -107,4 +127,3 @@ export interface SessionDetails {
tasks: Task[]; tasks: Task[];
approvals: ApprovalRequest[]; approvals: ApprovalRequest[];
} }