feat: harden boss local v1 runtime

This commit is contained in:
Codex
2026-03-23 13:17:11 +08:00
parent 515ce72d0d
commit 47c29c723a
13 changed files with 1281 additions and 178 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
node_modules
dist
.boss-data
.playwright-cli
npm-debug.log*

View File

@@ -6,7 +6,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。
## 当前状态
当前仓库已经完成第一轮产品设计文档:
当前仓库已经包含产品设计文档与一版可直接跑起来的本地控制台
- [文档总览](./docs/README.md)
- [竞品对比](./docs/competitor-comparison.md)
@@ -16,13 +16,14 @@ Boss 是一个面向多设备开发协作的 agent control plane。
- [消息协议与状态机](./docs/message-protocol-and-state-machine.md)
- [实施路线图](./docs/implementation-roadmap.md)
并且已经补入首版可运行原型
当前原型能力
- Fastify API
- 文件持久化状态存储
- SSE 实时事件流
- Web 控制台
- `boss-worker` 模拟执行器
- `boss-worker` 外部命令执行模式,可接本地 Codex / Claude / 自定义脚本
- `npm run smoke` 自动跑端到端验证
- `Dockerfile` + `compose.yaml` 支持容器启动
@@ -34,15 +35,6 @@ Boss 是一个面向多设备开发协作的 agent control plane。
- 工具层MCP
- 调度:持久队列或工作流引擎
## 下一步
建议直接开始:
1. 建立 Web 控制台和后端骨架
2. 实现 `boss-worker` 注册与心跳
3. 打通会话、任务树和子任务分发
4. 接入审批和中途重规划
## 本地运行
```bash
@@ -62,6 +54,12 @@ http://127.0.0.1:43210
npm run dev
```
如果你想把数据写到独立文件,避免和默认 demo 数据混用:
```bash
BOSS_DATA_FILE=.boss-data/local-dev.json npm run dev
```
如果你要手工启动 worker
```bash
@@ -70,6 +68,46 @@ npm run worker -- --name win-b --os windows --capability terminal --capability t
npm run worker -- --name mac-a --os macos --capability terminal --capability test --capability browser
```
如果你要接真实本地执行器,而不是模拟执行:
```bash
npm run worker -- \
--name mac-codex \
--os macos \
--capability terminal \
--capability test \
--mode command \
--workspace /path/to/project \
--executor ./scripts/codex_executor.sh
```
也可以接 Claude Code 或任意你自己的脚本,只要命令能从环境变量里读取任务上下文:
- `BOSS_TASK_TITLE`
- `BOSS_TASK_DESCRIPTION`
- `BOSS_TASK_KIND`
- `BOSS_TASK_JSON`
- `BOSS_WORKSPACE`
- `BOSS_WORKER_NAME`
仓库里已经自带两个可直接改造的适配脚本:
- `./scripts/codex_executor.sh`
- `./scripts/claude_executor.sh`
例如:
```bash
npm run worker -- \
--name win-claude \
--os windows \
--capability terminal \
--capability browser \
--mode command \
--workspace /path/to/project \
--executor ./scripts/claude_executor.sh
```
一键本地 demo
```bash
@@ -100,6 +138,8 @@ docker compose up --build
- 创建项目会话并持续对话
- 自动生成任务树并调度到不同 worker
- worker 心跳、掉线回收、任务重排
- worker 真实外部命令执行,支持本地命令适配
- 审批、暂停、恢复、取消、重排
- SSE 实时事件流和 Web 控制台
- 会话归档与恢复
- 一键 demo 启动

View File

@@ -6,8 +6,18 @@ const state = {
workers: [],
events: [],
selectedSessionId: null,
connection: "connecting",
banner: null,
};
const TASK_GROUPS = [
{ title: "进行中", statuses: ["assigned", "running"] },
{ title: "等待处理", statuses: ["planning", "queued"] },
{ title: "等待审批或阻塞", statuses: ["waiting_approval", "blocked", "paused"] },
{ title: "已完成", statuses: ["completed"] },
{ title: "已结束", statuses: ["failed", "cancelled"] },
];
const elements = {
sessionList: document.querySelector("#session-list"),
workerList: document.querySelector("#worker-list"),
@@ -28,8 +38,14 @@ const elements = {
messageInput: document.querySelector("#message-input"),
resetDemo: document.querySelector("#reset-demo"),
archiveSession: document.querySelector("#archive-session"),
streamStatus: document.querySelector("#stream-status"),
appBanner: document.querySelector("#app-banner"),
onboarding: document.querySelector("#onboarding"),
};
let bannerTimer = null;
let fallbackPoll = null;
function escapeHtml(input) {
return String(input)
.replaceAll("&", "&")
@@ -39,6 +55,40 @@ function escapeHtml(input) {
.replaceAll("'", "'");
}
function setBanner(message, tone = "info", persistent = false) {
state.banner = { message, tone };
renderBanner();
if (bannerTimer) {
window.clearTimeout(bannerTimer);
bannerTimer = null;
}
if (!persistent) {
bannerTimer = window.setTimeout(() => {
if (state.banner?.message === message) {
clearBanner();
}
}, 3200);
}
}
function clearBanner() {
state.banner = null;
renderBanner();
}
function renderBanner() {
if (!state.banner) {
elements.appBanner.className = "banner hidden";
elements.appBanner.textContent = "";
return;
}
elements.appBanner.className = `banner ${state.banner.tone}`;
elements.appBanner.textContent = state.banner.message;
}
async function request(url, options = {}) {
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
@@ -53,16 +103,57 @@ async function request(url, options = {}) {
}
if (!response.ok) {
throw new Error(payload.error || `${response.status} ${response.statusText}`);
const message = payload.message || payload.error || `${response.status} ${response.statusText}`;
throw new Error(message);
}
return payload;
}
async function runAction(label, operation, successMessage, options = {}) {
const {
refreshBootstrap = true,
refreshSession = Boolean(state.selectedSessionId),
persistent = false,
} = options;
setBanner(`${label}中...`, "info", true);
try {
const result = await operation();
if (refreshBootstrap) {
await loadBootstrap();
}
if (refreshSession && state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
if (successMessage) {
setBanner(successMessage, "success", persistent);
} else {
clearBanner();
}
return result;
} catch (error) {
console.error(error);
setBanner(`${label}失败:${error.message}`, "error", true);
render();
return null;
}
}
function selectedSession() {
return state.sessions.find((session) => session.id === state.selectedSessionId) ?? null;
}
function findTask(taskId) {
return state.tasks.find((task) => task.id === taskId) ?? null;
}
function findWorker(workerId) {
return state.workers.find((worker) => worker.id === workerId) ?? null;
}
function tasksForSelectedSession() {
return state.tasks.filter((task) => task.sessionId === state.selectedSessionId);
}
@@ -82,6 +173,60 @@ function eventsForSelectedSession() {
.reverse();
}
function formatClock(value) {
return new Date(value).toLocaleTimeString();
}
function formatAge(value) {
const diffMs = Date.now() - new Date(value).getTime();
const seconds = Math.max(0, Math.round(diffMs / 1000));
if (seconds < 5) {
return "刚刚";
}
if (seconds < 60) {
return `${seconds} 秒前`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes} 分钟前`;
}
const hours = Math.round(minutes / 60);
return `${hours} 小时前`;
}
function workerHealth(worker) {
if (worker.status === "offline") {
return {
tone: "offline",
label: "离线",
description: "已手动下线或被系统判定为离线。",
};
}
const ageMs = Date.now() - new Date(worker.lastSeenAt).getTime();
if (ageMs > 30_000) {
return {
tone: "stale",
label: "疑似掉线",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
if (ageMs > 10_000) {
return {
tone: "lagging",
label: "连接抖动",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
return {
tone: "live",
label: "在线",
description: `最近心跳 ${formatAge(worker.lastSeenAt)}`,
};
}
function renderSessions() {
if (state.sessions.length === 0) {
elements.sessionList.innerHTML = `<p class="muted">先创建一个项目会话。</p>`;
@@ -117,24 +262,116 @@ function renderWorkers() {
return;
}
elements.workerList.innerHTML = state.workers
.map(
(worker) => `
elements.workerList.innerHTML = [...state.workers]
.sort((left, right) => left.name.localeCompare(right.name))
.map((worker) => {
const currentTask = findTask(worker.currentTaskId);
const health = workerHealth(worker);
return `
<div class="card">
<div class="row between">
<strong>${escapeHtml(worker.name)}</strong>
<span class="badge ${worker.status}">${escapeHtml(worker.status)}</span>
<div class="row tight">
<span class="badge ${worker.status}">${escapeHtml(worker.status)}</span>
<span class="badge ${health.tone}">${escapeHtml(health.label)}</span>
</div>
</div>
<div class="muted">${escapeHtml(worker.os)}</div>
<div class="muted">current: ${escapeHtml(worker.currentTaskId || "idle")}</div>
<div class="muted">last seen: ${new Date(worker.lastSeenAt).toLocaleTimeString()}</div>
<div class="muted">当前任务:${escapeHtml(currentTask?.title || "空闲")}</div>
<div class="muted">${escapeHtml(health.description)}</div>
<div class="muted">负载:${escapeHtml(worker.load)}</div>
<div class="tags">
${worker.capabilities.map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}
</div>
<div class="actions">
${
worker.status !== "offline"
? `<button data-worker-action="offline" data-worker-id="${worker.id}" class="ghost danger">标记离线</button>`
: ""
}
</div>
</div>
`,
)
`;
})
.join("");
elements.workerList.querySelectorAll("[data-worker-action]").forEach((button) => {
button.addEventListener("click", async () => {
const workerId = button.dataset.workerId;
await runAction(
"更新 worker 状态",
() => request(`/api/workers/${workerId}/offline`, { method: "POST", body: "{}" }),
"worker 已标记离线。",
{ refreshSession: false },
);
});
});
}
function renderStreamStatus() {
const labels = {
connecting: "连接中",
live: "实时同步",
reconnecting: "重连中",
};
elements.streamStatus.className = `badge ${state.connection}`;
elements.streamStatus.textContent = labels[state.connection] ?? "未知状态";
}
function renderOnboarding() {
const needsOnboarding = state.workers.length === 0 || state.sessions.length === 0;
elements.onboarding.classList.toggle("hidden", !needsOnboarding);
if (!needsOnboarding) {
elements.onboarding.innerHTML = "";
return;
}
const steps = [
{
done: state.workers.length > 0,
title: "接入至少一台设备 worker",
detail:
state.workers.length > 0
? `当前已登记 ${state.workers.length} 台设备,可以开始接单。`
: "最快路径是运行 `npm run demo`,它会同时起服务和 3 台示例 worker。",
},
{
done: state.sessions.length > 0,
title: "创建一个项目会话",
detail:
state.sessions.length > 0
? "会话已经创建,可以持续对话和改需求。"
: "左侧填写项目标题创建会话,之后右侧就能开始对话式调度。",
},
{
done: Boolean(state.selectedSessionId && messagesForSelectedSession().length > 0),
title: "给主控发送第一条需求",
detail:
state.selectedSessionId && messagesForSelectedSession().length > 0
? "系统已经收到了需求,正在持续调度子任务。"
: "示例:先调研登录失败根因,不要急着改代码。",
},
];
elements.onboarding.innerHTML = `
<div class="panel-header">
<h2>首次上手</h2>
<span class="muted">按这 3 步就能跑通一轮协同开发</span>
</div>
<div class="checklist">
${steps
.map(
(step) => `
<article class="check-item ${step.done ? "done" : ""}">
<strong>${step.done ? "已完成" : "待完成"} · ${escapeHtml(step.title)}</strong>
<p class="muted">${escapeHtml(step.detail)}</p>
</article>
`,
)
.join("")}
</div>
`;
}
function renderSessionHeader() {
@@ -143,6 +380,7 @@ function renderSessionHeader() {
elements.sessionTitleDisplay.textContent = "选择一个项目会话";
elements.sessionSummary.textContent = "创建会话后,在这里持续对话并观察任务状态。";
elements.archiveSession.disabled = true;
elements.archiveSession.textContent = "归档会话";
elements.messageInput.disabled = true;
elements.messageInput.placeholder = "先创建或选择一个项目会话。";
return;
@@ -151,11 +389,12 @@ function renderSessionHeader() {
elements.sessionTitleDisplay.textContent = `${session.title} (${session.status})`;
elements.sessionSummary.textContent =
session.lastPlannerSummary || session.activeObjective || "等待用户输入。";
elements.archiveSession.disabled = session.status === "archived";
elements.archiveSession.disabled = false;
elements.archiveSession.textContent = session.status === "archived" ? "恢复会话" : "归档会话";
elements.messageInput.disabled = session.status === "archived";
elements.messageInput.placeholder =
session.status === "archived"
? "当前会话已归档,不能继续发送消息。"
? "当前会话已归档。先恢复会话,再继续发送消息。"
: "输入需求。示例:先调研登录失败根因,不要急着改代码。";
}
@@ -168,7 +407,7 @@ function renderMessages() {
<article class="message ${message.role}">
<header>
<strong>${escapeHtml(message.role)}</strong>
<span>${new Date(message.createdAt).toLocaleTimeString()}</span>
<span>${formatClock(message.createdAt)}</span>
</header>
<p>${escapeHtml(message.content)}</p>
</article>
@@ -178,52 +417,101 @@ function renderMessages() {
: `<p class="muted">当前没有消息。</p>`;
}
function taskGroupSections() {
const tasks = tasksForSelectedSession();
return TASK_GROUPS.map((group) => ({
...group,
tasks: tasks.filter((task) => group.statuses.includes(task.status)),
})).filter((group) => group.tasks.length > 0);
}
function renderTaskCard(task) {
const worker = findWorker(task.assignedWorkerId);
const dependencyTitles = task.dependencyIds
.map((taskId) => findTask(taskId)?.title)
.filter(Boolean);
const pendingApproval = approvalsForSelectedSession().find(
(approval) => approval.taskId === task.id && approval.status === "pending",
);
return `
<article class="card">
<div class="row between">
<strong>${escapeHtml(task.title)}</strong>
<span class="badge ${task.status}">${escapeHtml(task.status)}</span>
</div>
<p>${escapeHtml(task.description)}</p>
<div class="meta-list">
<span>worker${escapeHtml(worker?.name || "未分配")}</span>
<span>进度:${escapeHtml(task.progressPercent)}%</span>
<span>下一步:${escapeHtml(task.nextStep || "暂无")}</span>
<span>依赖:${escapeHtml(dependencyTitles.join("、") || "无")}</span>
<span>审批:${escapeHtml(task.approvalStatus === "pending" ? "等待批准" : task.approvalStatus)}</span>
</div>
<div class="muted">状态摘要:${escapeHtml(task.summary || "暂无")}</div>
${
pendingApproval
? `<div class="hint subtle">当前被审批卡住:${escapeHtml(pendingApproval.summary)}</div>`
: ""
}
<div class="actions">
${
["queued", "assigned", "running"].includes(task.status)
? `<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>`
: ""
}
${
["paused", "blocked", "waiting_approval"].includes(task.status)
? `<button data-action="resume" data-task-id="${task.id}" class="ghost">续跑</button>`
: ""
}
${
["paused", "failed", "cancelled"].includes(task.status)
? `<button data-action="requeue" data-task-id="${task.id}" class="ghost">重新排队</button>`
: ""
}
${
!["completed", "cancelled", "failed"].includes(task.status)
? `<button data-action="cancel" data-task-id="${task.id}" class="ghost danger">取消</button>`
: ""
}
</div>
</article>
`;
}
function renderTasks() {
const latestPlan = [...state.events]
.reverse()
.find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created");
if (latestPlan) {
const createdCount = Array.isArray(latestPlan.payload.taskIds) ? latestPlan.payload.taskIds.length : 0;
const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds)
? latestPlan.payload.pausedTaskIds.length
: 0;
elements.planHint.textContent = `最新计划创建了 ${latestPlan.payload.taskIds.length} 个任务,自动暂停旧任务 ${pausedCount} 个。`;
elements.planHint.textContent =
pausedCount > 0
? `本轮重规划新增 ${createdCount} 个任务,并暂停了 ${pausedCount} 个旧任务。`
: `本轮计划新增 ${createdCount} 个任务。`;
} else {
elements.planHint.textContent = "";
}
const tasks = tasksForSelectedSession();
elements.taskList.innerHTML = tasks.length
? tasks
const sections = taskGroupSections();
elements.taskList.innerHTML = sections.length
? sections
.map(
(task) => `
<div class="card">
(section) => `
<section class="task-group">
<div class="row between">
<strong>${escapeHtml(task.title)}</strong>
<span class="badge ${task.status}">${escapeHtml(task.status)}</span>
<h3>${escapeHtml(section.title)}</h3>
<span class="muted">${section.tasks.length}</span>
</div>
<p>${escapeHtml(task.description)}</p>
<div class="muted">worker: ${escapeHtml(task.assignedWorkerId || "未分配")}</div>
<div class="muted">progress: ${task.progressPercent}%</div>
<div class="muted">summary: ${escapeHtml(task.summary || "暂无")}</div>
<div class="muted">next: ${escapeHtml(task.nextStep || "暂无")}</div>
<div class="actions">
${
["queued", "assigned", "running"].includes(task.status)
? `<button data-action="pause" data-task-id="${task.id}" class="ghost">暂停</button>`
: ""
}
${
["paused", "blocked"].includes(task.status)
? `<button data-action="resume" data-task-id="${task.id}" class="ghost">续跑</button>`
: ""
}
${
!["completed", "cancelled", "failed"].includes(task.status)
? `<button data-action="cancel" data-task-id="${task.id}" class="ghost danger">取消</button>`
: ""
}
<div class="stack">
${section.tasks.map((task) => renderTaskCard(task)).join("")}
</div>
</div>
</section>
`,
)
.join("")
@@ -233,10 +521,11 @@ function renderTasks() {
button.addEventListener("click", async () => {
const taskId = button.dataset.taskId;
const action = button.dataset.action;
await request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" });
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
await runAction(
"更新任务",
() => request(`/api/tasks/${taskId}/${action}`, { method: "POST", body: "{}" }),
"任务状态已更新。",
);
});
});
}
@@ -245,21 +534,41 @@ function renderApprovals() {
const approvals = approvalsForSelectedSession();
elements.approvalList.innerHTML = approvals.length
? approvals
.map(
(approval) => `
.map((approval) => {
const task = findTask(approval.taskId);
const followUpText =
approval.status === "pending"
? "批准后任务会重新进入队列;拒绝后任务会被取消。"
: approval.status === "approved"
? "这条审批已经通过,对应任务可继续执行。"
: "这条审批已被拒绝,对应任务已停止。";
return `
<div class="card">
<div class="row between">
<strong>${escapeHtml(approval.summary)}</strong>
<strong>${escapeHtml(task?.title || approval.summary)}</strong>
<span class="badge ${approval.status}">${escapeHtml(approval.status)}</span>
</div>
<div class="muted">risk: ${escapeHtml(approval.riskLevel)}</div>
<div class="actions">
<button data-approval-id="${approval.id}" data-approved="true">批准</button>
<button data-approval-id="${approval.id}" data-approved="false" class="ghost danger">拒绝</button>
<p>${escapeHtml(approval.summary)}</p>
<div class="meta-list">
<span>风险等级:${escapeHtml(approval.riskLevel)}</span>
<span>触发类型:${escapeHtml(approval.kind)}</span>
<span>关联任务:${escapeHtml(task?.title || approval.taskId)}</span>
</div>
<div class="muted">${escapeHtml(followUpText)}</div>
${
approval.status === "pending"
? `
<div class="actions">
<button data-approval-id="${approval.id}" data-approved="true">批准</button>
<button data-approval-id="${approval.id}" data-approved="false" class="ghost danger">拒绝</button>
</div>
`
: ""
}
</div>
`,
)
`;
})
.join("")
: `<p class="muted">当前没有待审批项。</p>`;
@@ -267,13 +576,15 @@ function renderApprovals() {
button.addEventListener("click", async () => {
const approvalId = button.dataset.approvalId;
const approved = button.dataset.approved === "true";
await request(`/api/approvals/${approvalId}/respond`, {
method: "POST",
body: JSON.stringify({ approved, responder: "web-user" }),
});
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
await runAction(
approved ? "批准审批" : "拒绝审批",
() =>
request(`/api/approvals/${approvalId}/respond`, {
method: "POST",
body: JSON.stringify({ approved, responder: "web-user" }),
}),
approved ? "审批已通过。" : "审批已拒绝。",
);
});
});
}
@@ -287,7 +598,7 @@ function renderEvents() {
<article class="event">
<header>
<strong>${escapeHtml(event.type)}</strong>
<span>${new Date(event.timestamp).toLocaleTimeString()}</span>
<span>${formatClock(event.timestamp)}</span>
</header>
<pre>${escapeHtml(JSON.stringify(event.payload, null, 2))}</pre>
</article>
@@ -298,6 +609,9 @@ function renderEvents() {
}
function render() {
renderBanner();
renderStreamStatus();
renderOnboarding();
renderSessions();
renderWorkers();
renderSessionHeader();
@@ -324,27 +638,58 @@ async function loadSession(sessionId) {
if (!sessionId) return;
const details = await request(`/api/sessions/${sessionId}`);
state.sessions = state.sessions.map((session) => (session.id === sessionId ? details.session : session));
state.messages = [
...state.messages.filter((message) => message.sessionId !== sessionId),
...details.messages,
];
state.tasks = [
...state.tasks.filter((task) => task.sessionId !== sessionId),
...details.tasks,
];
state.messages = [...state.messages.filter((message) => message.sessionId !== sessionId), ...details.messages];
state.tasks = [...state.tasks.filter((task) => task.sessionId !== sessionId), ...details.tasks];
state.approvals = [
...state.approvals.filter((approval) => approval.sessionId !== sessionId),
...details.approvals,
];
}
function startFallbackPolling() {
if (fallbackPoll) {
return;
}
fallbackPoll = window.setInterval(async () => {
try {
await loadBootstrap();
if (state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
} catch (error) {
console.error(error);
}
}, 5000);
}
function stopFallbackPolling() {
if (!fallbackPoll) {
return;
}
window.clearInterval(fallbackPoll);
fallbackPoll = null;
}
elements.createSessionForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = elements.sessionTitleInput.value.trim();
const details = await request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title }),
});
const details = await runAction(
"创建会话",
() =>
request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title }),
}),
"项目会话已创建。",
{ refreshBootstrap: false, refreshSession: false },
);
if (!details) {
return;
}
state.sessions.unshift(details.session);
state.selectedSessionId = details.session.id;
await loadSession(details.session.id);
@@ -361,14 +706,24 @@ elements.createWorkerForm.addEventListener("submit", async (event) => {
.split(",")
.map((item) => item.trim())
.filter(Boolean);
await request("/api/workers/register", {
method: "POST",
body: JSON.stringify({ name, os, capabilities }),
});
const result = await runAction(
"注册 worker",
() =>
request("/api/workers/register", {
method: "POST",
body: JSON.stringify({ name, os, capabilities }),
}),
"worker 已注册。",
{ refreshSession: false },
);
if (!result) {
return;
}
elements.workerName.value = "";
elements.workerCapabilities.value = "terminal";
await loadBootstrap();
render();
});
elements.messageForm.addEventListener("submit", async (event) => {
@@ -376,40 +731,78 @@ elements.messageForm.addEventListener("submit", async (event) => {
if (!state.selectedSessionId) return;
const content = elements.messageInput.value.trim();
if (!content) return;
await request(`/api/sessions/${state.selectedSessionId}/messages`, {
method: "POST",
body: JSON.stringify({ content, channel: "web" }),
});
const result = await runAction(
"发送需求",
() =>
request(`/api/sessions/${state.selectedSessionId}/messages`, {
method: "POST",
body: JSON.stringify({ content, channel: "web" }),
}),
"需求已发送给主控。",
);
if (!result) {
return;
}
elements.messageInput.value = "";
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
});
elements.archiveSession.addEventListener("click", async () => {
if (!state.selectedSessionId) return;
await request(`/api/sessions/${state.selectedSessionId}/archive`, {
method: "POST",
body: "{}",
});
await loadSession(state.selectedSessionId);
await loadBootstrap();
render();
const session = selectedSession();
if (!session) return;
const archived = session.status === "archived";
await runAction(
archived ? "恢复会话" : "归档会话",
() =>
request(`/api/sessions/${state.selectedSessionId}/${archived ? "restore" : "archive"}`, {
method: "POST",
body: "{}",
}),
archived ? "会话已恢复。" : "会话已归档。",
);
});
elements.resetDemo.addEventListener("click", async () => {
await request("/api/demo/reset", { method: "POST", body: "{}" });
const result = await runAction(
"重置 Demo",
() =>
request("/api/demo/reset", {
method: "POST",
body: JSON.stringify({ preserveWorkers: true }),
}),
"Demo 数据已重置。",
{ refreshSession: false },
);
if (!result) {
return;
}
state.sessions = [];
state.messages = [];
state.tasks = [];
state.workers = [];
state.approvals = [];
state.events = [];
state.selectedSessionId = null;
await loadBootstrap();
render();
});
const stream = new EventSource("/api/events/stream");
stream.onopen = () => {
const wasReconnecting = state.connection === "reconnecting";
state.connection = "live";
stopFallbackPolling();
render();
if (wasReconnecting) {
setBanner("实时事件流已恢复。", "success");
}
};
stream.onmessage = async (event) => {
const payload = JSON.parse(event.data);
state.events.push(payload);
@@ -420,7 +813,24 @@ stream.onmessage = async (event) => {
render();
};
loadBootstrap().then(render).catch((error) => {
console.error(error);
elements.sessionSummary.textContent = error.message;
});
stream.onerror = () => {
if (state.connection !== "reconnecting") {
state.connection = "reconnecting";
setBanner("实时事件流暂时断开,正在重连并降级轮询。", "warn", true);
startFallbackPolling();
render();
}
};
loadBootstrap()
.then(async () => {
if (state.selectedSessionId) {
await loadSession(state.selectedSessionId);
}
render();
})
.catch((error) => {
console.error(error);
setBanner(`初始化失败:${error.message}`, "error", true);
render();
});

14
public/favicon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="boss-grad" x1="0%" x2="100%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#1f6feb" />
<stop offset="100%" stop-color="#0f7b55" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="#f8f5ec" />
<rect x="8" y="8" width="48" height="48" rx="14" fill="url(#boss-grad)" />
<path
d="M22 19h12c7.732 0 13 4.828 13 12 0 4.125-1.908 7.426-5.168 9.387C45.31 41.923 47 44.597 47 48v1H37v-1c0-3.518-2.36-5.5-6.668-5.5H30V49H22V19zm8 7v10h3.156C36.11 36 39 34.313 39 31s-2.89-5-5.844-5H30z"
fill="#f8f5ec"
/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boss Control Plane</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
@@ -48,17 +49,25 @@
<main class="content">
<section class="panel hero">
<div>
<h2 id="session-title-display">选择一个项目会话</h2>
<p id="session-summary" class="muted">创建会话后,在这里持续对话并观察任务状态。</p>
<div class="panel-header">
<div>
<h2 id="session-title-display">选择一个项目会话</h2>
<p id="session-summary" class="muted">创建会话后,在这里持续对话并观察任务状态。</p>
</div>
<div class="hero-actions">
<span id="stream-status" class="badge connecting">连接中</span>
<button id="archive-session" class="ghost">归档会话</button>
</div>
</div>
<div id="app-banner" class="banner hidden"></div>
</section>
<section id="onboarding" class="panel onboarding hidden"></section>
<section class="grid">
<div class="panel">
<div class="panel-header">
<h2>对话</h2>
<button id="archive-session" class="ghost">归档会话</button>
</div>
<div id="message-list" class="timeline"></div>
<form id="message-form" class="stack">

View File

@@ -96,6 +96,10 @@ select {
gap: 0.75rem;
}
.row.tight {
gap: 0.4rem;
}
.between {
justify-content: space-between;
}
@@ -104,6 +108,14 @@ select {
padding: 1.5rem;
}
.hero-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.grid {
display: grid;
gap: 1rem;
@@ -119,6 +131,10 @@ select {
margin-bottom: 1rem;
}
.hidden {
display: none !important;
}
.list,
.timeline {
display: grid;
@@ -184,6 +200,17 @@ select {
color: var(--accent);
}
.badge.connecting,
.badge.reconnecting,
.badge.lagging,
.badge.stale {
color: #8a6116;
}
.badge.live {
color: #0f7b55;
}
.badge.failed,
.badge.cancelled,
.badge.rejected,
@@ -226,6 +253,76 @@ select {
color: var(--accent);
}
.hint.subtle {
margin-top: 0.75rem;
margin-bottom: 0;
}
.banner {
margin-top: 1rem;
border-radius: 16px;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
background: rgba(31, 111, 235, 0.1);
color: var(--accent);
}
.banner.success {
background: rgba(15, 123, 85, 0.1);
color: #0f7b55;
}
.banner.error {
background: rgba(180, 35, 24, 0.1);
color: var(--danger);
}
.banner.warn {
background: rgba(138, 97, 22, 0.12);
color: #8a6116;
}
.onboarding {
display: grid;
gap: 1rem;
}
.checklist {
display: grid;
gap: 0.75rem;
}
.check-item {
border: 1px dashed var(--line);
border-radius: 18px;
padding: 1rem;
background: rgba(255, 255, 255, 0.66);
}
.check-item.done {
border-style: solid;
border-color: rgba(15, 123, 85, 0.2);
background: rgba(15, 123, 85, 0.05);
}
.task-group {
display: grid;
gap: 0.75rem;
}
.task-group h3 {
margin: 0;
font-size: 1rem;
}
.meta-list {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.92rem;
margin-bottom: 0.75rem;
}
pre {
margin: 0;
white-space: pre-wrap;
@@ -240,4 +337,13 @@ pre {
.grid {
grid-template-columns: 1fr;
}
.hero .panel-header {
align-items: flex-start;
flex-direction: column;
}
.hero-actions {
justify-content: flex-start;
}
}

40
scripts/claude_executor.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
workspace="${BOSS_WORKSPACE:-$PWD}"
task_title="${BOSS_TASK_TITLE:-Untitled task}"
task_kind="${BOSS_TASK_KIND:-general}"
task_description="${BOSS_TASK_DESCRIPTION:-No description provided.}"
if ! command -v claude >/dev/null 2>&1; then
echo "claude CLI not found in PATH" >&2
exit 1
fi
cd "$workspace"
prompt=$(cat <<EOF
You are the device-side execution worker for Boss.
Task title: ${task_title}
Task kind: ${task_kind}
Task description:
${task_description}
Work only inside this workspace:
${workspace}
Expectations:
- Make the smallest correct change that moves the task forward.
- If you modify code, mention validation or remaining risks in the final summary.
- If the task is research-only, summarize findings and next steps instead of forcing edits.
EOF
)
extra_flags=()
if [[ -n "${BOSS_CLAUDE_FLAGS:-}" ]]; then
# shellcheck disable=SC2206
extra_flags=(${BOSS_CLAUDE_FLAGS})
fi
exec claude --print "${extra_flags[@]}" "$prompt"

40
scripts/codex_executor.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
workspace="${BOSS_WORKSPACE:-$PWD}"
task_title="${BOSS_TASK_TITLE:-Untitled task}"
task_kind="${BOSS_TASK_KIND:-general}"
task_description="${BOSS_TASK_DESCRIPTION:-No description provided.}"
if ! command -v codex >/dev/null 2>&1; then
echo "codex CLI not found in PATH" >&2
exit 1
fi
cd "$workspace"
prompt=$(cat <<EOF
You are the device-side execution worker for Boss.
Task title: ${task_title}
Task kind: ${task_kind}
Task description:
${task_description}
Work only inside this workspace:
${workspace}
Expectations:
- Make the smallest correct change that moves the task forward.
- If you modify code, mention validation or remaining risks in the final summary.
- If the task is research-only, summarize findings and next steps instead of forcing edits.
EOF
)
extra_flags=()
if [[ -n "${BOSS_CODEX_FLAGS:-}" ]]; then
# shellcheck disable=SC2206
extra_flags=(${BOSS_CODEX_FLAGS})
fi
exec codex exec "${extra_flags[@]}" "$prompt"

View File

@@ -10,6 +10,7 @@ function run(command: string, args: string[]) {
env: {
...process.env,
PORT: process.env.PORT ?? "43210",
BOSS_DATA_FILE: process.env.BOSS_DATA_FILE ?? ".boss-data/demo-store.json",
},
});
child.on("exit", (code, signal) => {

View File

@@ -15,7 +15,9 @@ import { chooseAssignmentCandidates } from "./scheduler.js";
import { FileStore } from "./store.js";
import { createId, now } from "./utils.js";
const DATA_FILE = resolve(process.cwd(), ".boss-data", "store.json");
const DATA_FILE = process.env.BOSS_DATA_FILE
? resolve(process.cwd(), process.env.BOSS_DATA_FILE)
: resolve(process.cwd(), ".boss-data", "store.json");
function isActiveTask(task: Task): boolean {
return !["completed", "failed", "cancelled"].includes(task.status);
@@ -34,6 +36,32 @@ export class BossEngine {
return this.getState();
}
resetDemo(preserveWorkers = true): AppState {
this.commit((state) => {
state.sessions = [];
state.messages = [];
state.tasks = [];
state.approvals = [];
state.events = [];
if (!preserveWorkers) {
state.workers = [];
return;
}
const timestamp = now();
state.workers = state.workers.map((worker) => ({
...worker,
currentTaskId: null,
load: 0,
status: worker.status === "offline" ? "offline" : "idle",
updatedAt: timestamp,
}));
});
return this.getState();
}
createSession(title?: string): SessionDetails {
const timestamp = now();
const session: Session = {
@@ -134,6 +162,29 @@ export class BossEngine {
return this.getSession(sessionId);
}
restoreSession(sessionId: string): SessionDetails {
this.commit((state, addEvent) => {
const session = state.sessions.find((candidate) => candidate.id === sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
const timestamp = now();
session.status = "active";
session.updatedAt = timestamp;
addEvent({
sessionId,
taskId: null,
source: "system",
type: "session.restored",
payload: { sessionId },
});
});
return this.getSession(sessionId);
}
addMessage(sessionId: string, content: string, channel = "web"): SessionDetails {
const session = this.getSession(sessionId).session;
if (session.status === "archived") {
@@ -415,6 +466,10 @@ export class BossEngine {
if (task.status === "assigned") {
task.status = "running";
}
worker.status = "busy";
worker.currentTaskId = task.id;
worker.updatedAt = task.updatedAt;
worker.lastSeenAt = task.updatedAt;
addEvent({
sessionId: task.sessionId,
@@ -649,6 +704,7 @@ export class BossEngine {
const timestamp = now();
this.commit((state, addEvent) => {
const pausedTaskIds: string[] = [];
const mutableSession = state.sessions.find((candidate) => candidate.id === session.id);
if (!mutableSession) {
throw new Error(`Session not found: ${session.id}`);
@@ -676,6 +732,7 @@ export class BossEngine {
reason: "replan",
},
});
pausedTaskIds.push(task.id);
}
}
}
@@ -717,6 +774,7 @@ export class BossEngine {
payload: {
summary: result.summary,
taskIds: tasks.map((task) => task.id),
pausedTaskIds,
},
});
});

View File

@@ -71,6 +71,11 @@ app.post("/api/sessions/:sessionId/archive", async (request) => {
return engine.archiveSession(params.sessionId);
});
app.post("/api/sessions/:sessionId/restore", async (request) => {
const params = request.params as { sessionId: string };
return engine.restoreSession(params.sessionId);
});
app.get("/api/sessions/:sessionId", async (request) => {
const params = request.params as { sessionId: string };
return engine.getSession(params.sessionId);
@@ -210,9 +215,9 @@ app.post("/api/approvals/:approvalId/respond", async (request) => {
return engine.respondApproval(params.approvalId, body.approved, body.responder ?? "user");
});
app.post("/api/demo/reset", async () => {
engine.store.reset();
return ok();
app.post("/api/demo/reset", async (request) => {
const body = (request.body ?? {}) as { preserveWorkers?: boolean };
return engine.resetDemo(body.preserveWorkers ?? true);
});
app.post("/api/reconcile", async () => engine.reconcileNow());

View File

@@ -1,6 +1,31 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createServer } from "node:net";
import { setTimeout as delay } from "node:timers/promises";
const baseUrl = process.env.BOSS_BASE_URL || "http://127.0.0.1:43210";
const explicitBaseUrl = process.env.BOSS_BASE_URL?.replace(/\/$/, "") ?? "";
const smokeWorkerMode = process.env.SMOKE_WORKER_MODE === "command" ? "command" : "simulate";
const smokeExecutorCommand = process.env.SMOKE_EXECUTOR_COMMAND ?? "";
const smokeWorkspace = process.env.SMOKE_WORKSPACE ?? process.cwd();
let smokePort = process.env.SMOKE_PORT || "";
let baseUrl = explicitBaseUrl;
const children: ChildProcess[] = [];
const workerSpecs = [
{
name: "smoke-win-a",
os: "windows",
capabilities: ["terminal", "browser"],
},
{
name: "smoke-win-b",
os: "windows",
capabilities: ["terminal", "test"],
},
{
name: "smoke-mac-a",
os: "macos",
capabilities: ["terminal", "browser", "test"],
},
] as const;
async function request(path: string, options: RequestInit = {}) {
const response = await fetch(`${baseUrl}${path}`, {
@@ -9,12 +34,132 @@ async function request(path: string, options: RequestInit = {}) {
});
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
const text = await response.text();
throw new Error(`${response.status} ${response.statusText} ${text}`.trim());
}
return response.json();
}
function run(command: string, args: string[]) {
const child = spawn(command, args, {
stdio: "inherit",
shell: false,
env: {
...process.env,
PORT: smokePort,
BOSS_DATA_FILE: process.env.BOSS_DATA_FILE ?? ".boss-data/smoke-store.json",
},
});
children.push(child);
return child;
}
function portFromUrl(url: string) {
const parsed = new URL(url);
if (parsed.port) {
return parsed.port;
}
return parsed.protocol === "https:" ? "443" : "80";
}
async function chooseLocalPort() {
if (smokePort) {
return smokePort;
}
return new Promise<string>((resolve, reject) => {
const probe = createServer();
probe.on("error", reject);
probe.listen(0, "127.0.0.1", () => {
const address = probe.address();
if (!address || typeof address === "string") {
probe.close(() => reject(new Error("Unable to determine a free smoke port.")));
return;
}
const selectedPort = String(address.port);
probe.close((error) => {
if (error) {
reject(error);
return;
}
resolve(selectedPort);
});
});
});
}
async function isHealthy() {
try {
const health = await request("/api/health");
return health.status === "ok";
} catch {
return false;
}
}
async function waitForHealth(timeoutMs = 20_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await isHealthy()) {
return;
}
await delay(1_000);
}
throw new Error(`Server did not become healthy at ${baseUrl}`);
}
async function ensureEnvironment() {
if (smokeWorkerMode === "command" && !smokeExecutorCommand.trim()) {
throw new Error("SMOKE_WORKER_MODE=command requires SMOKE_EXECUTOR_COMMAND.");
}
if (explicitBaseUrl) {
smokePort = portFromUrl(baseUrl);
if (!(await isHealthy())) {
throw new Error(`Explicit BOSS_BASE_URL is not healthy: ${baseUrl}`);
}
} else {
smokePort = await chooseLocalPort();
baseUrl = `http://127.0.0.1:${smokePort}`;
run(process.execPath, ["./node_modules/tsx/dist/cli.mjs", "src/server.ts"]);
await waitForHealth();
}
for (const spec of workerSpecs) {
const args = [
"./node_modules/tsx/dist/cli.mjs",
"src/worker.ts",
"--name",
spec.name,
"--os",
spec.os,
];
for (const capability of spec.capabilities) {
args.push("--capability", capability);
}
if (smokeWorkerMode === "command") {
args.push("--mode", "command", "--workspace", smokeWorkspace, "--executor", smokeExecutorCommand);
}
args.push("--server", baseUrl);
run(process.execPath, args);
}
await delay(2_000);
}
function shutdown() {
for (const child of children) {
if (!child.killed) {
child.kill("SIGTERM");
}
}
}
async function waitForSessionSettled(sessionId: string, timeoutMs = 30_000) {
const start = Date.now();
@@ -42,43 +187,51 @@ async function waitForSessionSettled(sessionId: string, timeoutMs = 30_000) {
}
async function main() {
await request("/api/demo/reset", { method: "POST", body: "{}" });
const created = await request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title: "Smoke Test" }),
});
const sessionId = created.session.id;
try {
await ensureEnvironment();
await request("/api/demo/reset", {
method: "POST",
body: JSON.stringify({ preserveWorkers: true }),
});
const created = await request("/api/sessions", {
method: "POST",
body: JSON.stringify({ title: "Smoke Test" }),
});
const sessionId = created.session.id;
await request(`/api/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify({
content: "先调研登录失败根因,然后 delete 旧缓存目录并做验证。",
channel: "smoke",
}),
});
await request(`/api/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify({
content: "先调研登录失败根因,然后 delete 旧缓存目录并做验证。",
channel: "smoke",
}),
});
const settled = await waitForSessionSettled(sessionId);
console.log(
JSON.stringify(
{
sessionId,
taskStatuses: settled.tasks.map((task: { title: string; status: string }) => ({
title: task.title,
status: task.status,
})),
approvalStatuses: settled.approvals.map((approval: { id: string; status: string }) => ({
id: approval.id,
status: approval.status,
})),
},
null,
2,
),
);
const settled = await waitForSessionSettled(sessionId);
console.log(
JSON.stringify(
{
baseUrl,
sessionId,
taskStatuses: settled.tasks.map((task: { title: string; status: string }) => ({
title: task.title,
status: task.status,
})),
approvalStatuses: settled.approvals.map((approval: { id: string; status: string }) => ({
id: approval.id,
status: approval.status,
})),
},
null,
2,
),
);
} finally {
shutdown();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -1,3 +1,5 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
interface Task {
@@ -7,12 +9,36 @@ interface Task {
kind: string;
}
function parseArgs(argv: string[]) {
const options = {
interface WorkerOptions {
name: string;
os: string;
capabilities: string[];
server: string;
mode: "simulate" | "command";
executorCommand: string;
workspace: string;
progressIntervalMs: number;
}
class HttpError extends Error {
constructor(
readonly status: number,
readonly responseBody: string,
) {
super(`Request failed: ${status} ${responseBody}`.trim());
}
}
function parseArgs(argv: string[]): WorkerOptions {
const options: WorkerOptions = {
name: "",
os: "linux",
capabilities: ["terminal"],
server: "http://127.0.0.1:43210",
mode: process.env.BOSS_WORKER_MODE === "command" ? "command" : "simulate",
executorCommand: process.env.BOSS_EXECUTOR_COMMAND ?? "",
workspace: resolve(process.env.BOSS_WORKSPACE ?? process.cwd()),
progressIntervalMs: Number(process.env.BOSS_PROGRESS_INTERVAL_MS ?? 4_000),
};
for (let index = 0; index < argv.length; index += 1) {
@@ -29,11 +55,27 @@ function parseArgs(argv: string[]) {
} else if (token === "--server") {
options.server = argv[index + 1] ?? options.server;
index += 1;
} else if (token === "--mode") {
const candidate = argv[index + 1];
options.mode = candidate === "command" ? "command" : "simulate";
index += 1;
} else if (token === "--executor") {
options.executorCommand = argv[index + 1] ?? "";
index += 1;
} else if (token === "--workspace") {
options.workspace = resolve(argv[index + 1] ?? options.workspace);
index += 1;
}
}
if (!options.name) {
throw new Error("Usage: npm run worker -- --name <worker-name> [--os windows|macos|linux]");
throw new Error(
"Usage: npm run worker -- --name <worker-name> [--os windows|macos|linux] [--mode simulate|command] [--executor \"cmd\"] [--workspace /path]",
);
}
if (options.mode === "command" && !options.executorCommand.trim()) {
throw new Error("Command mode requires --executor or BOSS_EXECUTOR_COMMAND.");
}
options.capabilities = Array.from(new Set(options.capabilities));
@@ -48,7 +90,8 @@ async function postJson(url: string, body: unknown) {
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
const text = await response.text();
throw new HttpError(response.status, text);
}
return response.json();
@@ -57,14 +100,32 @@ async function postJson(url: string, body: unknown) {
async function getJson(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
const text = await response.text();
throw new HttpError(response.status, text);
}
return response.json();
}
async function registerWorker(options: WorkerOptions) {
const worker = (await postJson(`${options.server}/api/workers/register`, {
name: options.name,
os: options.os,
capabilities: options.capabilities,
})) as { id: string; name: string };
console.log(`worker ready: ${worker.name} (${worker.id})`);
return worker;
}
async function taskStillRunnable(server: string, taskId: string) {
const task = await getJson(`${server}/api/tasks/${taskId}`);
return ["assigned", "running"].includes(task.status);
try {
const task = (await getJson(`${server}/api/tasks/${taskId}`)) as { status: string };
return ["assigned", "running"].includes(task.status);
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
return false;
}
throw error;
}
}
async function simulateTask(server: string, workerId: string, task: Task) {
@@ -93,6 +154,7 @@ async function simulateTask(server: string, workerId: string, task: Task) {
if (!(await taskStillRunnable(server, task.id))) {
return;
}
await delay(1_500);
await postJson(`${server}/api/tasks/${task.id}/progress`, {
workerId,
@@ -103,6 +165,7 @@ async function simulateTask(server: string, workerId: string, task: Task) {
if (!(await taskStillRunnable(server, task.id))) {
return;
}
await delay(1_000);
await postJson(`${server}/api/tasks/${task.id}/complete`, {
workerId,
@@ -110,35 +173,199 @@ async function simulateTask(server: string, workerId: string, task: Task) {
});
}
function appendOutput(lines: string[], chunk: Buffer | string, source: "stdout" | "stderr") {
for (const entry of String(chunk).split(/\r?\n/)) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
lines.push(`${source}: ${trimmed}`);
if (lines.length > 30) {
lines.splice(0, lines.length - 30);
}
}
}
function summarizeOutput(lines: string[], fallback: string) {
if (lines.length === 0) {
return fallback;
}
return lines.slice(-3).join(" | ");
}
async function runCommandTask(options: WorkerOptions, workerId: string, task: Task) {
await postJson(`${options.server}/api/tasks/${task.id}/progress`, {
workerId,
progressPercent: 10,
summary: `${task.title}: 已启动外部执行器`,
currentStep: "boot_executor",
nextStep: "run_command",
});
const outputLines: string[] = [];
const child = spawn(options.executorCommand, [], {
cwd: options.workspace,
env: {
...process.env,
BOSS_SERVER_URL: options.server,
BOSS_WORKER_ID: workerId,
BOSS_WORKER_NAME: options.name,
BOSS_WORKER_OS: options.os,
BOSS_WORKER_CAPABILITIES: options.capabilities.join(","),
BOSS_WORKSPACE: options.workspace,
BOSS_TASK_ID: task.id,
BOSS_TASK_TITLE: task.title,
BOSS_TASK_DESCRIPTION: task.description,
BOSS_TASK_KIND: task.kind,
BOSS_TASK_JSON: JSON.stringify(task),
},
shell: true,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk) => {
appendOutput(outputLines, chunk, "stdout");
});
child.stderr?.on("data", (chunk) => {
appendOutput(outputLines, chunk, "stderr");
});
const exitState: {
done: boolean;
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
} = {
done: false,
code: null,
signal: null,
error: null,
};
child.on("error", (error) => {
exitState.done = true;
exitState.error = error;
});
child.on("exit", (code, signal) => {
exitState.done = true;
exitState.code = code;
exitState.signal = signal;
});
let cancelled = false;
const startedAt = Date.now();
while (!exitState.done) {
await delay(options.progressIntervalMs);
if (exitState.done) {
break;
}
if (!(await taskStillRunnable(options.server, task.id))) {
cancelled = true;
child.kill("SIGTERM");
break;
}
const elapsed = Date.now() - startedAt;
const progressPercent = Math.min(90, 20 + Math.floor(elapsed / options.progressIntervalMs) * 10);
await postJson(`${options.server}/api/tasks/${task.id}/progress`, {
workerId,
progressPercent,
summary: summarizeOutput(outputLines, `${task.title}: 外部执行器运行中`),
currentStep: "run_command",
nextStep: "await_exit",
});
}
while (!exitState.done) {
await delay(100);
}
if (cancelled) {
return;
}
if (exitState.error) {
throw exitState.error;
}
if (exitState.signal && exitState.signal !== "SIGTERM") {
throw new Error(`Executor exited via signal ${exitState.signal}`);
}
if ((exitState.code ?? 1) !== 0) {
throw new Error(summarizeOutput(outputLines, `Executor exited with code ${exitState.code ?? "unknown"}`));
}
if (!(await taskStillRunnable(options.server, task.id))) {
return;
}
await postJson(`${options.server}/api/tasks/${task.id}/complete`, {
workerId,
summary: summarizeOutput(outputLines, `${task.title} 已通过外部执行器完成。`),
});
}
async function executeTask(options: WorkerOptions, workerId: string, task: Task) {
if (options.mode === "command") {
await runCommandTask(options, workerId, task);
return;
}
await simulateTask(options.server, workerId, task);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const worker = (await postJson(`${options.server}/api/workers/register`, {
name: options.name,
os: options.os,
capabilities: options.capabilities,
})) as { id: string; name: string };
console.log(`worker ready: ${worker.name} (${worker.id})`);
let worker = await registerWorker(options);
for (;;) {
await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 });
const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as {
task: Task | null;
};
try {
await postJson(`${options.server}/api/workers/${worker.id}/heartbeat`, { load: 0 });
const response = (await postJson(`${options.server}/api/workers/${worker.id}/claim-next`, {})) as {
task: Task | null;
};
if (!response.task) {
await delay(2_500);
continue;
}
if (response.task) {
console.log(`claimed task: ${response.task.title}`);
try {
await simulateTask(options.server, worker.id, response.task);
await executeTask(options, worker.id, response.task);
} catch (error) {
if (error instanceof HttpError && [404, 409].includes(error.status)) {
console.warn(`task abandoned: ${response.task.title} (${error.status})`);
continue;
}
const message = error instanceof Error ? error.message : "unknown error";
await postJson(`${options.server}/api/tasks/${response.task.id}/fail`, {
workerId: worker.id,
errorMessage: message,
});
}
} else {
await delay(2_500);
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
console.warn(`worker state lost, re-registering: ${options.name}`);
await delay(1_000);
worker = await registerWorker(options);
continue;
}
if (error instanceof HttpError && error.status === 409) {
await delay(1_500);
continue;
}
throw error;
}
}
}