feat: harden boss local v1 runtime
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.boss-data
|
||||
.playwright-cli
|
||||
npm-debug.log*
|
||||
|
||||
|
||||
62
README.md
62
README.md
@@ -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 启动
|
||||
|
||||
568
public/app.js
568
public/app.js
@@ -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>
|
||||
<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("");
|
||||
.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,34 +417,43 @@ function renderMessages() {
|
||||
: `<p class="muted">当前没有消息。</p>`;
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const latestPlan = [...state.events]
|
||||
.reverse()
|
||||
.find((event) => event.sessionId === state.selectedSessionId && event.type === "plan.created");
|
||||
if (latestPlan) {
|
||||
const pausedCount = Array.isArray(latestPlan.payload.pausedTaskIds)
|
||||
? latestPlan.payload.pausedTaskIds.length
|
||||
: 0;
|
||||
elements.planHint.textContent = `最新计划创建了 ${latestPlan.payload.taskIds.length} 个任务,自动暂停旧任务 ${pausedCount} 个。`;
|
||||
} else {
|
||||
elements.planHint.textContent = "";
|
||||
}
|
||||
|
||||
function taskGroupSections() {
|
||||
const tasks = tasksForSelectedSession();
|
||||
elements.taskList.innerHTML = tasks.length
|
||||
? tasks
|
||||
.map(
|
||||
(task) => `
|
||||
<div class="card">
|
||||
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="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="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)
|
||||
@@ -213,17 +461,57 @@ function renderTasks() {
|
||||
: ""
|
||||
}
|
||||
${
|
||||
["paused", "blocked"].includes(task.status)
|
||||
["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 =
|
||||
pausedCount > 0
|
||||
? `本轮重规划新增 ${createdCount} 个任务,并暂停了 ${pausedCount} 个旧任务。`
|
||||
: `本轮计划新增 ${createdCount} 个任务。`;
|
||||
} else {
|
||||
elements.planHint.textContent = "";
|
||||
}
|
||||
|
||||
const sections = taskGroupSections();
|
||||
elements.taskList.innerHTML = sections.length
|
||||
? sections
|
||||
.map(
|
||||
(section) => `
|
||||
<section class="task-group">
|
||||
<div class="row between">
|
||||
<h3>${escapeHtml(section.title)}</h3>
|
||||
<span class="muted">${section.tasks.length} 项</span>
|
||||
</div>
|
||||
<div class="stack">
|
||||
${section.tasks.map((task) => renderTaskCard(task)).join("")}
|
||||
</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>
|
||||
<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`, {
|
||||
await runAction(
|
||||
approved ? "批准审批" : "拒绝审批",
|
||||
() =>
|
||||
request(`/api/approvals/${approvalId}/respond`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ approved, responder: "web-user" }),
|
||||
});
|
||||
await loadSession(state.selectedSessionId);
|
||||
await loadBootstrap();
|
||||
render();
|
||||
}),
|
||||
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", {
|
||||
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", {
|
||||
|
||||
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`, {
|
||||
|
||||
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`, {
|
||||
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: "{}",
|
||||
});
|
||||
await loadSession(state.selectedSessionId);
|
||||
await loadBootstrap();
|
||||
render();
|
||||
}),
|
||||
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) => {
|
||||
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);
|
||||
elements.sessionSummary.textContent = error.message;
|
||||
});
|
||||
setBanner(`初始化失败:${error.message}`, "error", true);
|
||||
render();
|
||||
});
|
||||
|
||||
14
public/favicon.svg
Normal file
14
public/favicon.svg
Normal 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 |
@@ -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 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">
|
||||
|
||||
@@ -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
40
scripts/claude_executor.sh
Executable 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
40
scripts/codex_executor.sh
Executable 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"
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
161
src/smoke.ts
161
src/smoke.ts
@@ -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,7 +187,12 @@ async function waitForSessionSettled(sessionId: string, timeoutMs = 30_000) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await request("/api/demo/reset", { method: "POST", body: "{}" });
|
||||
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" }),
|
||||
@@ -61,6 +211,7 @@ async function main() {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
baseUrl,
|
||||
sessionId,
|
||||
taskStatuses: settled.tasks.map((task: { title: string; status: string }) => ({
|
||||
title: task.title,
|
||||
@@ -75,10 +226,12 @@ async function main() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
261
src/worker.ts
261
src/worker.ts
@@ -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}`);
|
||||
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 (;;) {
|
||||
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) {
|
||||
if (!response.task) {
|
||||
await delay(2_500);
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user