feat: add device-targeted android control flow
This commit is contained in:
@@ -212,11 +212,11 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
|
|||||||
|
|
||||||
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
|
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
|
||||||
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
|
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
|
||||||
- 会话创建、切换、持续对话
|
- 会话创建、切换,并按“先选设备再对话”的方式把需求定向发到对应客户端
|
||||||
- 任务分组查看、暂停、恢复、取消、重排
|
- 任务分组查看、暂停、恢复、取消、重排
|
||||||
- 审批查看与批准/拒绝
|
- 审批查看与批准/拒绝
|
||||||
- 设备列表、设备聚焦切换、设备下线
|
- 设备列表、设备聚焦切换、设备下线,以及按设备视角过滤任务
|
||||||
- 绑定新设备并生成启动命令
|
- 绑定新设备时自动生成 Mac / Windows 对应的超链接和终端命令,拿到目标电脑执行即可完成绑定
|
||||||
- 云端 Boss 地址切换与重排入口
|
- 云端 Boss 地址切换与重排入口
|
||||||
|
|
||||||
同步模型说明:
|
同步模型说明:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
122
src/engine.ts
122
src/engine.ts
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}。系统会继续调度可执行子任务,并在需要审批时暂停。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,32 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
src/server.ts
161
src/server.ts
@@ -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 };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function defaultState(): AppState {
|
|||||||
tasks: [],
|
tasks: [],
|
||||||
workers: [],
|
workers: [],
|
||||||
approvals: [],
|
approvals: [],
|
||||||
|
deviceBindings: [],
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/types.ts
21
src/types.ts
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user