feat: sync device codex projects
This commit is contained in:
@@ -10,6 +10,7 @@ data class AppStatePayload(
|
|||||||
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 deviceBindings: List<DeviceBinding> = emptyList(),
|
||||||
|
val deviceProjects: List<DeviceProject> = emptyList(),
|
||||||
val approvals: List<ApprovalRequest> = emptyList(),
|
val approvals: List<ApprovalRequest> = emptyList(),
|
||||||
val events: List<BossEvent> = emptyList(),
|
val events: List<BossEvent> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -123,6 +124,23 @@ data class DeviceBinding(
|
|||||||
val updatedAt: String,
|
val updatedAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceProject(
|
||||||
|
val id: String,
|
||||||
|
val workerId: String,
|
||||||
|
val source: String,
|
||||||
|
val workspaceRoot: String,
|
||||||
|
val workspaceLabel: String,
|
||||||
|
val projectName: String,
|
||||||
|
val status: String,
|
||||||
|
val primaryThreadId: String? = null,
|
||||||
|
val primaryThreadTitle: String = "",
|
||||||
|
val recentThreadTitles: List<String> = emptyList(),
|
||||||
|
val recentThreadCount: Int = 0,
|
||||||
|
val pinnedThreadIds: List<String> = emptyList(),
|
||||||
|
val updatedAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class DeviceBindingLaunchPayload(
|
data class DeviceBindingLaunchPayload(
|
||||||
val binding: DeviceBinding,
|
val binding: DeviceBinding,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ 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.DeviceBinding
|
||||||
|
import site.hyzq.bossandroid.model.DeviceProject
|
||||||
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
|
||||||
@@ -576,6 +577,7 @@ private fun OverviewTab(
|
|||||||
onSelectSession: (String) -> Unit,
|
onSelectSession: (String) -> Unit,
|
||||||
onSelectWorker: (String?) -> Unit,
|
onSelectWorker: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId }
|
||||||
val messagesBySession = remember(uiState.messages) {
|
val messagesBySession = remember(uiState.messages) {
|
||||||
uiState.messages.groupBy { it.sessionId }
|
uiState.messages.groupBy { it.sessionId }
|
||||||
}
|
}
|
||||||
@@ -585,11 +587,44 @@ private fun OverviewTab(
|
|||||||
val workerById = remember(uiState.workers) {
|
val workerById = remember(uiState.workers) {
|
||||||
uiState.workers.associateBy { it.id }
|
uiState.workers.associateBy { it.id }
|
||||||
}
|
}
|
||||||
val activeTasks = remember(uiState.tasks) {
|
val selectedWorkerSessionIds = remember(uiState.selectedWorkerId, uiState.sessions, uiState.tasks) {
|
||||||
uiState.tasks.filter { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
|
if (uiState.selectedWorkerId == null) {
|
||||||
|
uiState.sessions.map { it.id }.toSet()
|
||||||
|
} else {
|
||||||
|
mutableSetOf<String>().apply {
|
||||||
|
uiState.sessions
|
||||||
|
.filter { session -> session.activeWorkerId == uiState.selectedWorkerId }
|
||||||
|
.forEach { add(it.id) }
|
||||||
|
uiState.tasks
|
||||||
|
.filter {
|
||||||
|
it.assignedWorkerId == uiState.selectedWorkerId ||
|
||||||
|
it.preferredWorkerId == uiState.selectedWorkerId
|
||||||
|
}
|
||||||
|
.forEach { add(it.sessionId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val visibleSessions = remember(uiState.sessions, selectedWorkerSessionIds) {
|
||||||
|
uiState.sessions.filter { it.id in selectedWorkerSessionIds }
|
||||||
|
}
|
||||||
|
val activeTasks = remember(uiState.tasks, uiState.selectedWorkerId) {
|
||||||
|
uiState.tasks.filter {
|
||||||
|
it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") &&
|
||||||
|
(uiState.selectedWorkerId == null ||
|
||||||
|
it.assignedWorkerId == uiState.selectedWorkerId ||
|
||||||
|
it.preferredWorkerId == uiState.selectedWorkerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val recentMessages = remember(uiState.messages, selectedWorkerSessionIds) {
|
||||||
|
uiState.messages
|
||||||
|
.filter { it.sessionId in selectedWorkerSessionIds }
|
||||||
|
.sortedByDescending { it.createdAt }
|
||||||
|
.take(12)
|
||||||
|
}
|
||||||
|
val visibleDeviceProjects = remember(uiState.deviceProjects, uiState.selectedWorkerId) {
|
||||||
|
uiState.deviceProjects.filter { project ->
|
||||||
|
uiState.selectedWorkerId == null || project.workerId == uiState.selectedWorkerId
|
||||||
}
|
}
|
||||||
val recentMessages = remember(uiState.messages) {
|
|
||||||
uiState.messages.sortedByDescending { it.createdAt }.take(12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
@@ -613,14 +648,34 @@ private fun OverviewTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionHeading("所有项目")
|
SectionHeading(if (selectedWorker != null) "${selectedWorker.name} 的 Codex 项目" else "设备 Codex 项目")
|
||||||
if (uiState.sessions.isEmpty()) {
|
if (visibleDeviceProjects.isEmpty()) {
|
||||||
EmptyStateCard(
|
EmptyStateCard(
|
||||||
title = "还没有项目",
|
title = "当前还没有同步到 Codex 项目",
|
||||||
body = "你可以在手机上创建,也可以在电脑入口创建。Boss 会自动把它们同步到这里。",
|
body = if (selectedWorker != null) {
|
||||||
|
"这台设备还没把本机 Codex 项目索引上报到 Boss,等下一次心跳后会自动刷新。"
|
||||||
|
} else {
|
||||||
|
"选中一台设备后,这里会显示该设备当前打开或最近活跃的 Codex 项目。"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
uiState.sessions.forEach { session ->
|
visibleDeviceProjects.forEach { project ->
|
||||||
|
DeviceProjectCard(project = project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeading(if (selectedWorker != null) "该设备关联会话" else "所有项目")
|
||||||
|
if (visibleSessions.isEmpty()) {
|
||||||
|
EmptyStateCard(
|
||||||
|
title = if (selectedWorker != null) "这台设备还没有关联会话" else "还没有项目",
|
||||||
|
body = if (selectedWorker != null) {
|
||||||
|
"你可以先切到这台设备发起任务,Boss 会把相关会话自动归到它下面。"
|
||||||
|
} else {
|
||||||
|
"你可以在手机上创建,也可以在电脑入口创建。Boss 会自动把它们同步到这里。"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
visibleSessions.forEach { session ->
|
||||||
val recentMessage = messagesBySession[session.id]?.maxByOrNull { it.createdAt }
|
val recentMessage = messagesBySession[session.id]?.maxByOrNull { it.createdAt }
|
||||||
val sessionTasks = tasksBySession[session.id].orEmpty()
|
val sessionTasks = tasksBySession[session.id].orEmpty()
|
||||||
val sessionWorkerNames = sessionTasks
|
val sessionWorkerNames = sessionTasks
|
||||||
@@ -1360,6 +1415,72 @@ private fun ProjectOverviewCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceProjectCard(
|
||||||
|
project: DeviceProject,
|
||||||
|
) {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.outlinedCardColors(
|
||||||
|
containerColor = if (project.status == "active") {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
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(project.projectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
project.workspaceRoot,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StatusChip(
|
||||||
|
label = if (project.status == "active") "当前活跃" else "最近活跃",
|
||||||
|
tone = if (project.status == "active") "running" else "pending",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.primaryThreadTitle.isNotBlank()) {
|
||||||
|
Text("主对话:${project.primaryThreadTitle}")
|
||||||
|
}
|
||||||
|
if (project.recentThreadTitles.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"最近对话:${project.recentThreadTitles.joinToString(" · ")}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
AssistChip(onClick = {}, label = { Text("${project.recentThreadCount} 个对话") })
|
||||||
|
if (project.pinnedThreadIds.isNotEmpty()) {
|
||||||
|
AssistChip(onClick = {}, label = { Text("${project.pinnedThreadIds.size} 个置顶") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"最近同步 ${formatRelative(project.updatedAt)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GlobalMessageCard(
|
private fun GlobalMessageCard(
|
||||||
message: Message,
|
message: Message,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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.DeviceBinding
|
||||||
|
import site.hyzq.bossandroid.model.DeviceProject
|
||||||
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
|
||||||
@@ -63,6 +64,7 @@ data class BossUiState(
|
|||||||
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 deviceBindings: List<DeviceBinding> = emptyList(),
|
||||||
|
val deviceProjects: List<DeviceProject> = 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,
|
||||||
@@ -361,6 +363,10 @@ class BossViewModel(
|
|||||||
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 deviceBindings = snapshot.deviceBindings.sortedByDescending { it.updatedAt }
|
||||||
|
val deviceProjects = snapshot.deviceProjects.sortedWith(
|
||||||
|
compareByDescending<DeviceProject> { it.status == "active" }
|
||||||
|
.thenByDescending { it.updatedAt },
|
||||||
|
)
|
||||||
val events = snapshot.events.sortedByDescending { it.timestamp }
|
val events = snapshot.events.sortedByDescending { it.timestamp }
|
||||||
|
|
||||||
val selectedSessionId = preferredSessionId
|
val selectedSessionId = preferredSessionId
|
||||||
@@ -383,6 +389,7 @@ class BossViewModel(
|
|||||||
approvals = approvals,
|
approvals = approvals,
|
||||||
workers = workers,
|
workers = workers,
|
||||||
deviceBindings = deviceBindings,
|
deviceBindings = deviceBindings,
|
||||||
|
deviceProjects = deviceProjects,
|
||||||
events = events,
|
events = events,
|
||||||
health = HealthPayload(
|
health = HealthPayload(
|
||||||
status = "ok",
|
status = "ok",
|
||||||
|
|||||||
234
src/codex-project-sync.ts
Normal file
234
src/codex-project-sync.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { basename, join, resolve } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import type { DeviceProject } from "./types.js";
|
||||||
|
import { now } from "./utils.js";
|
||||||
|
|
||||||
|
interface SessionIndexEntry {
|
||||||
|
id: string;
|
||||||
|
thread_name?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalStatePayload {
|
||||||
|
["active-workspace-roots"]?: unknown;
|
||||||
|
["electron-saved-workspace-roots"]?: unknown;
|
||||||
|
["electron-workspace-root-labels"]?: unknown;
|
||||||
|
["pinned-thread-ids"]?: unknown;
|
||||||
|
["thread-workspace-root-hints"]?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectThreadSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDraft {
|
||||||
|
workspaceRoot: string;
|
||||||
|
workspaceLabel: string;
|
||||||
|
projectName: string;
|
||||||
|
active: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
threads: ProjectThreadSummary[];
|
||||||
|
pinnedThreadIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCodexHome(): string {
|
||||||
|
const configured = process.env.BOSS_CODEX_HOME?.trim();
|
||||||
|
if (configured) {
|
||||||
|
return resolve(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkspaceRoot(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonFile<T>(filePath: string): T | null {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonLines<T>(filePath: string): T[] {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = readFileSync(filePath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const result: T[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
result.push(JSON.parse(line) as T);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringList(input: unknown): string[] {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return input
|
||||||
|
.map((value) => (typeof value === "string" ? normalizeWorkspaceRoot(value) : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringRecord(input: unknown): Record<string, string> {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(input as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => [key, typeof value === "string" ? normalizeWorkspaceRoot(value) : ""] as const)
|
||||||
|
.filter(([, value]) => Boolean(value));
|
||||||
|
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asWorkspaceLabels(input: unknown): Record<string, string> {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(input as Record<string, unknown>)
|
||||||
|
.map(([key, value]) => [normalizeWorkspaceRoot(key), typeof value === "string" ? value.trim() : ""] as const)
|
||||||
|
.filter(([key, value]) => Boolean(key && value));
|
||||||
|
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectDraft(
|
||||||
|
workspaceRoot: string,
|
||||||
|
workspaceLabels: Record<string, string>,
|
||||||
|
activeRoots: Set<string>,
|
||||||
|
): ProjectDraft {
|
||||||
|
const workspaceLabel = workspaceLabels[workspaceRoot] || basename(workspaceRoot) || workspaceRoot;
|
||||||
|
return {
|
||||||
|
workspaceRoot,
|
||||||
|
workspaceLabel,
|
||||||
|
projectName: workspaceLabel,
|
||||||
|
active: activeRoots.has(workspaceRoot),
|
||||||
|
updatedAt: now(),
|
||||||
|
threads: [],
|
||||||
|
pinnedThreadIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectId(workerId: string, workspaceRoot: string): string {
|
||||||
|
const digest = createHash("sha1")
|
||||||
|
.update(`${workerId}:${workspaceRoot}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 12);
|
||||||
|
return `device_project_${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanLocalCodexProjects(workerId: string, workspaceHint?: string): DeviceProject[] {
|
||||||
|
const codexHome = resolveCodexHome();
|
||||||
|
const globalState = readJsonFile<GlobalStatePayload>(join(codexHome, ".codex-global-state.json")) ?? {};
|
||||||
|
const sessionIndex = readJsonLines<SessionIndexEntry>(join(codexHome, "session_index.jsonl"));
|
||||||
|
|
||||||
|
const activeRoots = new Set([
|
||||||
|
...asStringList(globalState["active-workspace-roots"]),
|
||||||
|
normalizeWorkspaceRoot(workspaceHint ?? ""),
|
||||||
|
].filter(Boolean));
|
||||||
|
const workspaceLabels = asWorkspaceLabels(globalState["electron-workspace-root-labels"]);
|
||||||
|
const threadWorkspaceHints = asStringRecord(globalState["thread-workspace-root-hints"]);
|
||||||
|
const pinnedThreadIds = new Set(
|
||||||
|
Array.isArray(globalState["pinned-thread-ids"])
|
||||||
|
? globalState["pinned-thread-ids"].filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const drafts = new Map<string, ProjectDraft>();
|
||||||
|
|
||||||
|
for (const workspaceRoot of activeRoots) {
|
||||||
|
drafts.set(workspaceRoot, createProjectDraft(workspaceRoot, workspaceLabels, activeRoots));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedSessions = sessionIndex
|
||||||
|
.filter((entry) => entry.id && threadWorkspaceHints[entry.id])
|
||||||
|
.sort((left, right) => (right.updated_at ?? "").localeCompare(left.updated_at ?? ""))
|
||||||
|
.slice(0, 300);
|
||||||
|
|
||||||
|
for (const entry of sortedSessions) {
|
||||||
|
const workspaceRoot = threadWorkspaceHints[entry.id];
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = entry.updated_at?.trim() || now();
|
||||||
|
const threadTitle = entry.thread_name?.trim() || "未命名对话";
|
||||||
|
const draft =
|
||||||
|
drafts.get(workspaceRoot) ??
|
||||||
|
createProjectDraft(workspaceRoot, workspaceLabels, activeRoots);
|
||||||
|
|
||||||
|
if (pinnedThreadIds.has(entry.id)) {
|
||||||
|
draft.pinnedThreadIds.add(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.updatedAt = draft.updatedAt > updatedAt ? draft.updatedAt : updatedAt;
|
||||||
|
draft.active = draft.active || activeRoots.has(workspaceRoot);
|
||||||
|
draft.threads.push({
|
||||||
|
id: entry.id,
|
||||||
|
title: threadTitle,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
drafts.set(workspaceRoot, draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(drafts.values())
|
||||||
|
.map((draft) => {
|
||||||
|
const threads = draft.threads
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
const primaryThread = threads[0];
|
||||||
|
const recentThreadTitles = Array.from(
|
||||||
|
new Set(threads.map((thread) => thread.title).filter(Boolean)),
|
||||||
|
).slice(0, 3);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: projectId(workerId, draft.workspaceRoot),
|
||||||
|
workerId,
|
||||||
|
source: "codex",
|
||||||
|
workspaceRoot: draft.workspaceRoot,
|
||||||
|
workspaceLabel: draft.workspaceLabel,
|
||||||
|
projectName: draft.projectName,
|
||||||
|
status: draft.active ? "active" : "recent",
|
||||||
|
primaryThreadId: primaryThread?.id ?? null,
|
||||||
|
primaryThreadTitle: primaryThread?.title ?? "",
|
||||||
|
recentThreadTitles,
|
||||||
|
recentThreadCount: threads.length,
|
||||||
|
pinnedThreadIds: Array.from(draft.pinnedThreadIds),
|
||||||
|
updatedAt: primaryThread?.updatedAt ?? draft.updatedAt,
|
||||||
|
} satisfies DeviceProject;
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.status !== right.status) {
|
||||||
|
return left.status === "active" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return right.updatedAt.localeCompare(left.updatedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AppState,
|
AppState,
|
||||||
BossEvent,
|
BossEvent,
|
||||||
DeviceBinding,
|
DeviceBinding,
|
||||||
|
DeviceProject,
|
||||||
ExecutorKind,
|
ExecutorKind,
|
||||||
Message,
|
Message,
|
||||||
Session,
|
Session,
|
||||||
@@ -448,6 +449,7 @@ export class BossEngine {
|
|||||||
worker.currentTaskId = null;
|
worker.currentTaskId = null;
|
||||||
worker.updatedAt = timestamp;
|
worker.updatedAt = timestamp;
|
||||||
worker.lastSeenAt = timestamp;
|
worker.lastSeenAt = timestamp;
|
||||||
|
state.deviceProjects = state.deviceProjects.filter((project) => project.workerId !== worker.id);
|
||||||
addEvent({
|
addEvent({
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
taskId: null,
|
taskId: null,
|
||||||
@@ -466,7 +468,7 @@ export class BossEngine {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeat(workerId: string, load = 0): WorkerNode {
|
heartbeat(workerId: string, load = 0, deviceProjects: DeviceProject[] = []): WorkerNode {
|
||||||
let updated!: WorkerNode;
|
let updated!: WorkerNode;
|
||||||
this.commit((state, addEvent) => {
|
this.commit((state, addEvent) => {
|
||||||
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
@@ -496,10 +498,60 @@ export class BossEngine {
|
|||||||
updated = { ...worker };
|
updated = { ...worker };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deviceProjects.length > 0 || this.getState().deviceProjects.some((project) => project.workerId === workerId)) {
|
||||||
|
this.syncWorkerProjects(workerId, deviceProjects);
|
||||||
|
}
|
||||||
|
|
||||||
this.syncAssignments();
|
this.syncAssignments();
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncWorkerProjects(workerId: string, projects: DeviceProject[]): DeviceProject[] {
|
||||||
|
let updatedProjects: DeviceProject[] = [];
|
||||||
|
|
||||||
|
this.commit((state, addEvent) => {
|
||||||
|
const worker = state.workers.find((candidate) => candidate.id === workerId);
|
||||||
|
if (!worker) {
|
||||||
|
throw new Error(`Worker not found: ${workerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProjects = projects
|
||||||
|
.map((project) => ({
|
||||||
|
...project,
|
||||||
|
workerId,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
const existingProjects = state.deviceProjects
|
||||||
|
.filter((project) => project.workerId === workerId)
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
const changed = JSON.stringify(existingProjects) !== JSON.stringify(normalizedProjects);
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
updatedProjects = normalizedProjects;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.deviceProjects = [
|
||||||
|
...state.deviceProjects.filter((project) => project.workerId !== workerId),
|
||||||
|
...normalizedProjects,
|
||||||
|
];
|
||||||
|
addEvent({
|
||||||
|
sessionId: null,
|
||||||
|
taskId: worker.currentTaskId,
|
||||||
|
source: "worker",
|
||||||
|
type: "worker.projects.synced",
|
||||||
|
payload: {
|
||||||
|
workerId,
|
||||||
|
projectCount: normalizedProjects.length,
|
||||||
|
projectNames: normalizedProjects.map((project) => project.projectName).slice(0, 8),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updatedProjects = normalizedProjects;
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProjects;
|
||||||
|
}
|
||||||
|
|
||||||
claimNextTask(workerId: string): Task | null {
|
claimNextTask(workerId: string): Task | null {
|
||||||
let claimedTask: Task | null = null;
|
let claimedTask: Task | null = null;
|
||||||
|
|
||||||
@@ -999,6 +1051,7 @@ export class BossEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
worker.currentTaskId = null;
|
worker.currentTaskId = null;
|
||||||
|
state.deviceProjects = state.deviceProjects.filter((project) => project.workerId !== worker.id);
|
||||||
addEvent({
|
addEvent({
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
taskId: null,
|
taskId: null,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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";
|
import type { DeviceBinding, DeviceProject } 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" });
|
||||||
@@ -308,8 +308,8 @@ app.get(withBase("/api/device-bindings/:token/bootstrap.ps1"), async (request, r
|
|||||||
|
|
||||||
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; deviceProjects?: DeviceProject[] };
|
||||||
return engine.heartbeat(params.workerId, body.load ?? 0);
|
return engine.heartbeat(params.workerId, body.load ?? 0, body.deviceProjects ?? []);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(withBase("/api/workers/:workerId/offline"), async (request) => {
|
app.post(withBase("/api/workers/:workerId/offline"), async (request) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function defaultState(): AppState {
|
|||||||
workers: [],
|
workers: [],
|
||||||
approvals: [],
|
approvals: [],
|
||||||
deviceBindings: [],
|
deviceBindings: [],
|
||||||
|
deviceProjects: [],
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@@ -15,6 +15,7 @@ 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 DeviceBindingStatus = "pending" | "claimed" | "expired";
|
||||||
export type ExecutorKind = "codex" | "claude";
|
export type ExecutorKind = "codex" | "claude";
|
||||||
|
export type DeviceProjectStatus = "active" | "recent";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,6 +102,22 @@ export interface DeviceBinding {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceProject {
|
||||||
|
id: string;
|
||||||
|
workerId: string;
|
||||||
|
source: "codex";
|
||||||
|
workspaceRoot: string;
|
||||||
|
workspaceLabel: string;
|
||||||
|
projectName: string;
|
||||||
|
status: DeviceProjectStatus;
|
||||||
|
primaryThreadId: string | null;
|
||||||
|
primaryThreadTitle: string;
|
||||||
|
recentThreadTitles: string[];
|
||||||
|
recentThreadCount: number;
|
||||||
|
pinnedThreadIds: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BossEvent {
|
export interface BossEvent {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -118,6 +135,7 @@ export interface AppState {
|
|||||||
workers: WorkerNode[];
|
workers: WorkerNode[];
|
||||||
approvals: ApprovalRequest[];
|
approvals: ApprovalRequest[];
|
||||||
deviceBindings: DeviceBinding[];
|
deviceBindings: DeviceBinding[];
|
||||||
|
deviceProjects: DeviceProject[];
|
||||||
events: BossEvent[];
|
events: BossEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { scanLocalCodexProjects } from "./codex-project-sync.js";
|
||||||
|
import type { DeviceProject } from "./types.js";
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +22,8 @@ interface WorkerOptions {
|
|||||||
progressIntervalMs: number;
|
progressIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_SYNC_INTERVAL_MS = Number(process.env.BOSS_PROJECT_SYNC_INTERVAL_MS ?? 15_000);
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
@@ -116,6 +120,18 @@ async function registerWorker(options: WorkerOptions) {
|
|||||||
return worker;
|
return worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncHeartbeat(
|
||||||
|
options: WorkerOptions,
|
||||||
|
workerId: string,
|
||||||
|
load: number,
|
||||||
|
deviceProjects: DeviceProject[],
|
||||||
|
) {
|
||||||
|
return postJson(`${options.server}/api/workers/${workerId}/heartbeat`, {
|
||||||
|
load,
|
||||||
|
deviceProjects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function taskStillRunnable(server: string, taskId: string) {
|
async function taskStillRunnable(server: string, taskId: string) {
|
||||||
try {
|
try {
|
||||||
const task = (await getJson(`${server}/api/tasks/${taskId}`)) as { status: string };
|
const task = (await getJson(`${server}/api/tasks/${taskId}`)) as { status: string };
|
||||||
@@ -256,6 +272,8 @@ async function runCommandTask(options: WorkerOptions, workerId: string, task: Ta
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
let cachedProjects: DeviceProject[] = [];
|
||||||
|
let lastProjectSyncAt = 0;
|
||||||
|
|
||||||
while (!exitState.done) {
|
while (!exitState.done) {
|
||||||
await delay(options.progressIntervalMs);
|
await delay(options.progressIntervalMs);
|
||||||
@@ -270,6 +288,12 @@ async function runCommandTask(options: WorkerOptions, workerId: string, task: Ta
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Date.now() - lastProjectSyncAt >= PROJECT_SYNC_INTERVAL_MS) {
|
||||||
|
cachedProjects = scanLocalCodexProjects(workerId, options.workspace);
|
||||||
|
lastProjectSyncAt = Date.now();
|
||||||
|
await syncHeartbeat(options, workerId, 1, cachedProjects);
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const progressPercent = Math.min(90, 20 + Math.floor(elapsed / options.progressIntervalMs) * 10);
|
const progressPercent = Math.min(90, 20 + Math.floor(elapsed / options.progressIntervalMs) * 10);
|
||||||
await postJson(`${options.server}/api/tasks/${task.id}/progress`, {
|
await postJson(`${options.server}/api/tasks/${task.id}/progress`, {
|
||||||
@@ -323,10 +347,17 @@ async function executeTask(options: WorkerOptions, workerId: string, task: Task)
|
|||||||
async function main() {
|
async function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
let worker = await registerWorker(options);
|
let worker = await registerWorker(options);
|
||||||
|
let cachedProjects: DeviceProject[] = [];
|
||||||
|
let lastProjectSyncAt = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 });
|
if (Date.now() - lastProjectSyncAt >= PROJECT_SYNC_INTERVAL_MS) {
|
||||||
|
cachedProjects = scanLocalCodexProjects(worker.id, options.workspace);
|
||||||
|
lastProjectSyncAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncHeartbeat(options, worker.id, 0, cachedProjects);
|
||||||
const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as {
|
const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as {
|
||||||
task: Task | null;
|
task: Task | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user