@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/bin/agent-task.mjs +61 -0
- package/config.example.json +7 -0
- package/deploy/agent-task.service.example +17 -0
- package/dist/acp.js +169 -0
- package/dist/admin.js +31 -0
- package/dist/config.js +72 -0
- package/dist/coordinator-provider.js +28 -0
- package/dist/coordinator-storage.js +43 -0
- package/dist/coordinator.js +117 -0
- package/dist/environment.js +36 -0
- package/dist/executor-runtime.js +37 -0
- package/dist/http-auth.js +9 -0
- package/dist/human-input.js +62 -0
- package/dist/launcher.js +238 -0
- package/dist/lock.js +43 -0
- package/dist/main.js +175 -0
- package/dist/mcp.js +40 -0
- package/dist/model.js +25 -0
- package/dist/node-execution.js +102 -0
- package/dist/permissions.js +9 -0
- package/dist/private-files.js +95 -0
- package/dist/results.js +79 -0
- package/dist/settings-contract.js +16 -0
- package/dist/settings.js +180 -0
- package/dist/startup.js +56 -0
- package/dist/task-contract.js +37 -0
- package/dist/task-model.js +2 -0
- package/dist/task-storage.js +123 -0
- package/dist/tasks.js +508 -0
- package/dist/version.js +3 -0
- package/dist/web/api.js +26 -0
- package/dist/web/app.js +72 -0
- package/dist/web/dom.js +65 -0
- package/dist/web/index.html +33 -0
- package/dist/web/settings-view.js +126 -0
- package/dist/web/style.css +24 -0
- package/dist/web/tasks-view.js +83 -0
- package/dist/web.js +117 -0
- package/dist/worker.js +89 -0
- package/dist/workspace.js +16 -0
- package/docs/architecture-guidelines.md +52 -0
- package/docs/deployment.md +115 -0
- package/docs/design-proposal.md +55 -0
- package/docs/release.md +57 -0
- package/docs/technical-design.md +37 -0
- package/docs/validation.md +108 -0
- package/docs/web-console.md +40 -0
- package/package.json +68 -4
- package/scripts/install-lib.mjs +184 -0
- package/scripts/mcp-call.mjs +17 -0
- package/scripts/probe-agent.mjs +39 -0
- package/scripts/service-runner.mjs +9 -0
- package/scripts/setup-wizard.mjs +29 -0
- package/scripts/setup.mjs +130 -0
- package/scripts/uninstall.mjs +57 -0
package/dist/web/app.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { connect, disconnect, request } from "./api.js";
|
|
2
|
+
import { element, notice, report, submit } from "./dom.js";
|
|
3
|
+
import { renderTasks } from "./tasks-view.js";
|
|
4
|
+
import { renderSettings } from "./settings-view.js";
|
|
5
|
+
|
|
6
|
+
const login = document.querySelector("#login"), navigation = document.querySelector("#navigation");
|
|
7
|
+
const main = document.querySelector("#view"), status = document.querySelector("#status"), token = document.querySelector("#token");
|
|
8
|
+
let connected = false, view = { kind: "tasks", after: 0 }, sequence = 0, loading = false, dirty = false;
|
|
9
|
+
|
|
10
|
+
function health(value) {
|
|
11
|
+
status.textContent = (value.running && value.storageHealthy ? "服务运行中" : "服务不可用") +
|
|
12
|
+
" · 活动协调运行:" + value.activeTaskRunIds.length + " · ACP 会话:" + value.activeNodeRunIds.length +
|
|
13
|
+
(value.settings.pendingRestart ? " · 已保存变更,待手动重启(当前权限和凭据未变)" : " · 无待重启变更") +
|
|
14
|
+
(value.settings.writeBlocked ? " · 配置保存已冻结,请停服核查" : "");
|
|
15
|
+
}
|
|
16
|
+
async function render() {
|
|
17
|
+
const current = ++sequence, target = { ...view }, root = element("section");
|
|
18
|
+
const open = new Set([...main.querySelectorAll("details[open]")].map(node => node.dataset.key));
|
|
19
|
+
loading = true;
|
|
20
|
+
const navigate = (next, force = false) => { if (root.isConnected) void go(next, force); };
|
|
21
|
+
try {
|
|
22
|
+
if (target.kind === "settings") await renderSettings(root, navigate);
|
|
23
|
+
else await renderTasks(root, target, navigate);
|
|
24
|
+
if (current !== sequence || !connected) return;
|
|
25
|
+
for (const node of root.querySelectorAll("details")) node.open = open.has(node.dataset.key);
|
|
26
|
+
root.addEventListener("input", () => { dirty = true; });
|
|
27
|
+
main.replaceChildren(root); dirty = false;
|
|
28
|
+
} catch (error) { if (current === sequence && connected) report(error); }
|
|
29
|
+
finally { if (current === sequence) loading = false; }
|
|
30
|
+
}
|
|
31
|
+
async function go(next, force = false) {
|
|
32
|
+
if (!force && dirty && !confirm("离开或刷新将丢弃尚未保存的表单编辑,继续?")) return;
|
|
33
|
+
view = next; dirty = false; await render();
|
|
34
|
+
}
|
|
35
|
+
function logout() {
|
|
36
|
+
connected = false; sequence++; loading = false; dirty = false; disconnect();
|
|
37
|
+
main.replaceChildren(); status.textContent = ""; token.value = "";
|
|
38
|
+
navigation.hidden = true; login.hidden = false;
|
|
39
|
+
}
|
|
40
|
+
login.append(submit(login, "连接控制台", async () => {
|
|
41
|
+
const value = token.value; token.value = "";
|
|
42
|
+
const result = await connect(value);
|
|
43
|
+
connected = true; login.hidden = true; navigation.hidden = false; notice(); health(result);
|
|
44
|
+
await go({ kind: "tasks", after: 0 }, true);
|
|
45
|
+
}));
|
|
46
|
+
document.querySelector("#tasks").addEventListener("click", () => { void go({ kind: "tasks", after: 0 }); });
|
|
47
|
+
document.querySelector("#settings").addEventListener("click", () => { void go({ kind: "settings" }); });
|
|
48
|
+
document.querySelector("#refresh").addEventListener("click", async () => {
|
|
49
|
+
await go(view);
|
|
50
|
+
if (connected) { try { health(await request("/status")); } catch (error) { report(error); } }
|
|
51
|
+
});
|
|
52
|
+
document.querySelector("#logout").addEventListener("click", () => { logout(); notice("已退出;刷新后也须重新输入管理员令牌。"); });
|
|
53
|
+
document.addEventListener("unauthorized", logout);
|
|
54
|
+
document.addEventListener("service-restarted", event => {
|
|
55
|
+
logout();
|
|
56
|
+
notice("新实例已通过认证健康检查。请在新地址重新连接;令牌没有放入链接。");
|
|
57
|
+
const url = new URL(event.detail.consoleUrl);
|
|
58
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") return;
|
|
59
|
+
const link = element("a", url.href); link.href = url.href; main.append(link);
|
|
60
|
+
});
|
|
61
|
+
window.addEventListener("pagehide", logout);
|
|
62
|
+
setInterval(async () => {
|
|
63
|
+
if (!connected || document.hidden || loading) return;
|
|
64
|
+
const current = sequence;
|
|
65
|
+
try {
|
|
66
|
+
const value = await request("/status");
|
|
67
|
+
if (connected && current === sequence) {
|
|
68
|
+
health(value);
|
|
69
|
+
if (view.kind !== "settings" && !main.contains(document.activeElement)) await render();
|
|
70
|
+
}
|
|
71
|
+
} catch (error) { if (connected && current === sequence) report(error); }
|
|
72
|
+
}, 10_000);
|
package/dist/web/dom.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function element(tag, text) {
|
|
2
|
+
const node = document.createElement(tag);
|
|
3
|
+
if (text !== undefined) node.textContent = String(text);
|
|
4
|
+
return node;
|
|
5
|
+
}
|
|
6
|
+
export function notice(text = "") { document.querySelector("#notice").textContent = text; }
|
|
7
|
+
export function report(error) {
|
|
8
|
+
notice(error.message ?? "操作失败");
|
|
9
|
+
if (error.status === 401) document.dispatchEvent(new Event("unauthorized"));
|
|
10
|
+
}
|
|
11
|
+
async function act(node, action) {
|
|
12
|
+
node.disabled = true;
|
|
13
|
+
try { await action(); } catch (error) { if (node.isConnected) report(error); }
|
|
14
|
+
finally { node.disabled = false; }
|
|
15
|
+
}
|
|
16
|
+
export function button(text, action, disabled = false) {
|
|
17
|
+
const node = element("button", text); node.type = "button"; node.disabled = disabled;
|
|
18
|
+
node.addEventListener("click", () => { void act(node, action); });
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
export function submit(form, text, action) {
|
|
22
|
+
const node = form.querySelector("button[type=submit]") ?? element("button", text); node.type = "submit";
|
|
23
|
+
form.addEventListener("submit", event => { event.preventDefault(); void act(node, action); });
|
|
24
|
+
return node;
|
|
25
|
+
}
|
|
26
|
+
export function block(title, value, key = title) {
|
|
27
|
+
const details = element("details"); details.dataset.key = key;
|
|
28
|
+
details.append(element("summary", title), element("pre", typeof value === "string" ? value : JSON.stringify(value, null, 2)));
|
|
29
|
+
return details;
|
|
30
|
+
}
|
|
31
|
+
export function table(headers, rows) {
|
|
32
|
+
const wrapper = element("div"); wrapper.className = "table-wrap";
|
|
33
|
+
const node = element("table"), head = element("tr");
|
|
34
|
+
for (const label of headers) head.append(element("th", label));
|
|
35
|
+
const thead = element("thead"); thead.append(head); node.append(thead);
|
|
36
|
+
const body = element("tbody");
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
const tr = element("tr");
|
|
39
|
+
for (const value of row) {
|
|
40
|
+
const td = element("td");
|
|
41
|
+
td.append(value instanceof Node ? value : document.createTextNode(value == null ? "—" : String(value)));
|
|
42
|
+
tr.append(td);
|
|
43
|
+
}
|
|
44
|
+
body.append(tr);
|
|
45
|
+
}
|
|
46
|
+
node.append(body); wrapper.append(node);
|
|
47
|
+
return wrapper;
|
|
48
|
+
}
|
|
49
|
+
export function field(label, value = "", type = "text") {
|
|
50
|
+
const row = element("label", label), input = element(type === "textarea" ? "textarea" : "input");
|
|
51
|
+
if (type !== "textarea") input.type = type;
|
|
52
|
+
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
|
53
|
+
if (type === "password") { input.autocomplete = "off"; input.spellcheck = false; }
|
|
54
|
+
row.append(input);
|
|
55
|
+
return { row, input };
|
|
56
|
+
}
|
|
57
|
+
export function select(label, choices, value = "") {
|
|
58
|
+
const row = element("label", label), input = element("select");
|
|
59
|
+
for (const [key, text] of choices) {
|
|
60
|
+
const option = element("option", text); option.value = key; input.append(option);
|
|
61
|
+
}
|
|
62
|
+
input.value = value; row.append(input);
|
|
63
|
+
return { row, input };
|
|
64
|
+
}
|
|
65
|
+
export function time(value) { return value == null ? "—" : new Date(value).toLocaleString(); }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Agent Task 控制台</title>
|
|
7
|
+
<link rel="stylesheet" href="/admin/style.css">
|
|
8
|
+
<script type="module" src="/admin/app.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header>
|
|
12
|
+
<h1>Agent Task</h1>
|
|
13
|
+
<p>本机管理控制台 · 配置保存后须手动重启</p>
|
|
14
|
+
<form id="login" autocomplete="off">
|
|
15
|
+
<label>管理员令牌
|
|
16
|
+
<input id="token" type="password" required maxlength="256" autocomplete="off" spellcheck="false">
|
|
17
|
+
</label>
|
|
18
|
+
<button type="submit">连接</button>
|
|
19
|
+
<p class="hint">使用独立的 AGENT_TASK_ADMIN_TOKEN,不是 MCP token。令牌只保存在当前页面内存中。</p>
|
|
20
|
+
</form>
|
|
21
|
+
<nav id="navigation" hidden>
|
|
22
|
+
<button id="tasks" type="button">任务</button>
|
|
23
|
+
<button id="settings" type="button">配置</button>
|
|
24
|
+
<button id="refresh" type="button">刷新</button>
|
|
25
|
+
<button id="logout" type="button">退出</button>
|
|
26
|
+
</nav>
|
|
27
|
+
<p id="status" role="status"></p>
|
|
28
|
+
</header>
|
|
29
|
+
<p id="notice" role="alert"></p>
|
|
30
|
+
<main id="view"></main>
|
|
31
|
+
<noscript>控制台需要 JavaScript;没有加载任何第三方脚本。</noscript>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { request } from "./api.js";
|
|
2
|
+
import { element, field, select, button, submit, block, table, notice } from "./dom.js";
|
|
3
|
+
|
|
4
|
+
function executorEditor(host, executor) {
|
|
5
|
+
const card = element("fieldset"), fields = {};
|
|
6
|
+
card.append(element("legend", "ACP 执行端"));
|
|
7
|
+
const type = select("类型", ["pi", "codex", "claude", "opencode"].map(value => [value, value]), executor.type ?? "pi");
|
|
8
|
+
fields.type = type.input; card.append(type.row);
|
|
9
|
+
for (const [key, label, kind, value] of [
|
|
10
|
+
["id", "注册 ID", "text", executor.id],
|
|
11
|
+
["note", "备注", "textarea", executor.note],
|
|
12
|
+
["cwd", "默认工作空间(绝对路径)", "text", executor.cwd],
|
|
13
|
+
]) {
|
|
14
|
+
const item = field(label, value, kind); fields[key] = item.input; card.append(item.row);
|
|
15
|
+
}
|
|
16
|
+
const approved = field("已由管理员审核原生授权,允许无人值守执行", executor.unattendedApproved, "checkbox");
|
|
17
|
+
for (const [key, input] of Object.entries(fields)) if (key !== "note") input.addEventListener("input", () => { approved.input.checked = false; });
|
|
18
|
+
card.append(approved.row, element("p", "按类型从用户原生 PATH 查找 ACP 适配器。继承用户原生环境,但过滤服务私密变量;额外 ACP 审批一律拒绝。结果固定保存在 dataDir/results,只向节点提供指定上游文件,不提供整个数据目录读取。"),
|
|
19
|
+
button("从待保存配置中移除", () => { card.remove(); host.dispatchEvent(new Event("input", { bubbles: true })); }));
|
|
20
|
+
host.append(card);
|
|
21
|
+
return () => card.parentNode === host ? [{
|
|
22
|
+
id: fields.id.value, type: fields.type.value, note: fields.note.value,
|
|
23
|
+
cwd: fields.cwd.value, unattendedApproved: approved.input.checked,
|
|
24
|
+
}] : [];
|
|
25
|
+
}
|
|
26
|
+
function coordinatorEditor(host, config) {
|
|
27
|
+
const provider = select("协调模型提供方", [["", "未配置"], ["openai", "OpenAI"], ["anthropic", "Anthropic"],
|
|
28
|
+
["deepseek", "DeepSeek"], ["openai-compatible", "OpenAI-compatible"]], config?.provider ?? "");
|
|
29
|
+
const model = field("模型 ID", config?.model), compatible = element("fieldset");
|
|
30
|
+
compatible.append(element("legend", "兼容端点元数据(由管理员确认,不自动查询)"));
|
|
31
|
+
const custom = {
|
|
32
|
+
baseUrl: field("端点 URL(不得包含凭据、查询参数或片段)", config?.baseUrl),
|
|
33
|
+
apiKeyEnv: field("密钥变量名(先在下方保存新密钥)", config?.apiKeyEnv ?? "COORDINATOR_API_KEY"),
|
|
34
|
+
api: select("API", [["openai-completions", "openai-completions"], ["openai-responses", "openai-responses"]], config?.api ?? "openai-completions"),
|
|
35
|
+
contextWindow: field("上下文窗口", config?.contextWindow, "number"),
|
|
36
|
+
maxTokens: field("最大输出 token", config?.maxTokens, "number"),
|
|
37
|
+
reasoning: field("支持 reasoning", config?.reasoning, "checkbox"),
|
|
38
|
+
};
|
|
39
|
+
for (const item of Object.values(custom)) compatible.append(item.row);
|
|
40
|
+
const visibility = () => { compatible.hidden = provider.input.value !== "openai-compatible"; model.row.hidden = !provider.input.value; };
|
|
41
|
+
provider.input.addEventListener("change", visibility); visibility();
|
|
42
|
+
host.append(provider.row, model.row, compatible);
|
|
43
|
+
return () => {
|
|
44
|
+
if (!provider.input.value) return undefined;
|
|
45
|
+
const result = { provider: provider.input.value, model: model.input.value };
|
|
46
|
+
if (result.provider === "openai-compatible") Object.assign(result, {
|
|
47
|
+
baseUrl: custom.baseUrl.input.value, apiKeyEnv: custom.apiKeyEnv.input.value, api: custom.api.input.value,
|
|
48
|
+
contextWindow: custom.contextWindow.input.valueAsNumber, maxTokens: custom.maxTokens.input.valueAsNumber,
|
|
49
|
+
reasoning: custom.reasoning.input.checked,
|
|
50
|
+
});
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function renderSettings(root, navigate) {
|
|
55
|
+
let state = await request("/settings");
|
|
56
|
+
root.append(element("h2", "配置与凭据"), element("p", "配置和密钥分别保存。保存不修改当前进程;每次重新启动(包括故障后拉起)读取已保存值。"),
|
|
57
|
+
element("p", "先保存新密钥,再保存引用它的配置。执行端原生登录和原生权限仍通过官方工具管理;这里不编辑其认证文件。"),
|
|
58
|
+
block("当前运行配置(只读,无密钥值)", state.running));
|
|
59
|
+
if (state.writeBlocked) root.append(element("p", "上次文件提交结果不确定。请停服核查;当前禁止继续保存。"));
|
|
60
|
+
const form = element("form"), modelHost = element("fieldset"), executors = element("div"), readers = [];
|
|
61
|
+
modelHost.append(element("legend", "协调模型"));
|
|
62
|
+
const readModel = coordinatorEditor(modelHost, state.saved.coordinator);
|
|
63
|
+
const startup = {
|
|
64
|
+
dataDir: field("下次启动的数据目录(规范绝对路径;不搬迁或删除旧历史)", state.saved.dataDir),
|
|
65
|
+
port: field("下次启动的监听端口(0 为自动分配)", state.saved.port, "number"),
|
|
66
|
+
tokenEnv: field("MCP 令牌环境变量名(改名保留令牌值)", state.saved.tokenEnv),
|
|
67
|
+
};
|
|
68
|
+
form.append(element("h3", "启动配置"), ...Object.values(startup).map(item => item.row),
|
|
69
|
+
modelHost, element("h3", "执行端注册与权限"), executors);
|
|
70
|
+
for (const executor of state.saved.executors) readers.push(executorEditor(executors, executor));
|
|
71
|
+
form.append(button("添加执行端", () => {
|
|
72
|
+
readers.push(executorEditor(executors, { id: "", type: "pi", note: "", cwd: "", unattendedApproved: false }));
|
|
73
|
+
}, state.writeBlocked));
|
|
74
|
+
const preview = element("div");
|
|
75
|
+
const save = submit(form, "校验并预览配置变更", async () => {
|
|
76
|
+
const input = { editToken: state.editToken, config: { dataDir: startup.dataDir.input.value, port: startup.port.input.valueAsNumber, tokenEnv: startup.tokenEnv.input.value,
|
|
77
|
+
coordinator: readModel(), executors: readers.flatMap(read => read()) } };
|
|
78
|
+
const checked = await request("/settings/validate", input);
|
|
79
|
+
preview.replaceChildren(block("校验后的待保存配置", checked.config),
|
|
80
|
+
block("重启后受执行端变更影响的任务", checked.affectedTasks));
|
|
81
|
+
const affected = checked.affectedTasks.map(task => task.name + " (" + task.id + ")").join("\n") || "无";
|
|
82
|
+
if (!confirm("本地校验通过,不代表模型认证或 ACP 执行已验证。\n受影响任务:\n" + affected +
|
|
83
|
+
"\n\n旧任务快照不会被重写,派发可能被拒绝。确认保存配置?保存后仍须手动重启。")) return;
|
|
84
|
+
await request("/settings/config", input);
|
|
85
|
+
notice("配置已保存,未热更新。检查活动运行后可在本页或 CLI 重启。");
|
|
86
|
+
navigate({ kind: "settings" }, true);
|
|
87
|
+
});
|
|
88
|
+
save.disabled = state.writeBlocked; form.append(save, preview); root.append(form);
|
|
89
|
+
|
|
90
|
+
root.append(element("h3", "密钥(仅显示是否设置)"),
|
|
91
|
+
element("p", "不提交即保留原值。新变量名使用 _KEY、_TOKEN、_SECRET 或 _PASSWORD 结尾;不能在这里修改服务/管理员令牌或运行环境。"));
|
|
92
|
+
const statuses = element("div"), credentialForm = element("form");
|
|
93
|
+
const key = field("凭据变量名"), value = field("新值(替换后不回显)", "", "password");
|
|
94
|
+
key.input.required = true; value.input.required = true;
|
|
95
|
+
value.input.maxLength = 16384;
|
|
96
|
+
const drawCredentials = () => {
|
|
97
|
+
statuses.replaceChildren(table(["变量名", "已保存", "当前进程已设置", "待重启", "操作"], state.credentials.map(credential => [
|
|
98
|
+
credential.key, credential.saved ? "是" : "否", credential.running ? "是" : "否", credential.pendingRestart ? "是" : "否",
|
|
99
|
+
button("删除已保存值", async () => {
|
|
100
|
+
if (!confirm("删除 " + credential.key + " 的已保存值?当前进程仍保留旧值,直到重启。")) return;
|
|
101
|
+
state = await request("/settings/credential", { editToken: state.editToken, key: credential.key, action: "delete" });
|
|
102
|
+
drawCredentials(); notice("密钥删除已保存,待重启。配置表单中的未保存编辑仍保留。");
|
|
103
|
+
}, !credential.saved || credential.referenced || state.writeBlocked),
|
|
104
|
+
])));
|
|
105
|
+
};
|
|
106
|
+
drawCredentials();
|
|
107
|
+
credentialForm.append(key.row, value.row);
|
|
108
|
+
const replace = submit(credentialForm, "单独保存此密钥", async () => {
|
|
109
|
+
const input = { editToken: state.editToken, key: key.input.value, action: "replace", value: value.input.value };
|
|
110
|
+
value.input.value = "";
|
|
111
|
+
state = await request("/settings/credential", input);
|
|
112
|
+
drawCredentials(); notice("密钥已保存,未热更新。配置表单中的未保存编辑仍保留,请继续校验并保存配置。");
|
|
113
|
+
});
|
|
114
|
+
replace.disabled = state.writeBlocked;
|
|
115
|
+
credentialForm.append(replace);
|
|
116
|
+
root.append(statuses, credentialForm, element("h3", "重启服务"),
|
|
117
|
+
element("p", "重启只应用已保存配置,未保存草稿会丢失。活动执行及待回答运行可能持有 ACP 会话;重启会关闭会话,不自动重试或重放不确定执行。"),
|
|
118
|
+
button("重启并检查可用性", async () => {
|
|
119
|
+
if (!confirm("确认使用已保存配置重启?活动工作可能中断,旧数据不会迁移。")) return;
|
|
120
|
+
notice("正在关闭旧运行并启动新实例,请勿重复操作……");
|
|
121
|
+
const ready = await request("/restart", {});
|
|
122
|
+
document.dispatchEvent(new CustomEvent("service-restarted", { detail: ready }));
|
|
123
|
+
}, state.writeBlocked),
|
|
124
|
+
block("前台或 systemd / launchd 托管实例", "agent-task restart [--home /absolute/installation]"),
|
|
125
|
+
element("p", "只有新实例通过认证健康检查才显示成功。若请求结果不确定,请先运行 agent-task status;启动失败时检查当前 dataDir/service.log,不删除历史或重放任务来修复。"));
|
|
126
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
:root { color-scheme: light dark; font: 15px/1.55 system-ui, sans-serif; }
|
|
2
|
+
body { max-width: 1150px; margin: 0 auto; padding: 1.2rem; }
|
|
3
|
+
h1, h2, h3 { line-height: 1.25; }
|
|
4
|
+
header { border-bottom: 1px solid #8886; margin-bottom: 1rem; }
|
|
5
|
+
nav, .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: .8rem 0; }
|
|
6
|
+
button, input, select, textarea { font: inherit; }
|
|
7
|
+
button { padding: .35rem .8rem; cursor: pointer; }
|
|
8
|
+
button:disabled { cursor: default; opacity: .55; }
|
|
9
|
+
label { display: block; margin: .7rem 0; overflow-wrap: anywhere; }
|
|
10
|
+
input:not([type="checkbox"]), select, textarea { display: block; box-sizing: border-box; width: 100%; max-width: 48rem; padding: .35rem; }
|
|
11
|
+
input[type="checkbox"] { margin-left: .7rem; }
|
|
12
|
+
textarea { min-height: 5rem; resize: vertical; }
|
|
13
|
+
fieldset, article { border: 1px solid #8886; border-radius: .3rem; padding: .8rem; margin: 1rem 0; min-width: 0; }
|
|
14
|
+
.table-wrap { overflow-x: auto; }
|
|
15
|
+
table { border-collapse: collapse; width: 100%; margin: .8rem 0; }
|
|
16
|
+
th, td { text-align: left; vertical-align: top; border-bottom: 1px solid #8886; padding: .45rem; overflow-wrap: anywhere; }
|
|
17
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: .8rem; background: #8881; max-height: 32rem; overflow-y: auto; }
|
|
18
|
+
details { margin: .8rem 0; }
|
|
19
|
+
summary { cursor: pointer; font-weight: 600; }
|
|
20
|
+
.hint { opacity: .8; font-size: .92rem; }
|
|
21
|
+
#notice:not(:empty) { border-left: 4px solid #c88712; padding: .7rem; background: #c8871215; }
|
|
22
|
+
#status { font-weight: 600; }
|
|
23
|
+
[hidden] { display: none !important; }
|
|
24
|
+
@media (max-width: 600px) { body { padding: .7rem; } th, td { padding: .3rem; } }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { request } from "./api.js";
|
|
2
|
+
import { element, table, button, block, notice, time } from "./dom.js";
|
|
3
|
+
|
|
4
|
+
function paging(root, page, state, navigate) {
|
|
5
|
+
const actions = element("div"); actions.className = "actions";
|
|
6
|
+
actions.append(button("上一页", () => navigate({ ...state, after: Math.max(0, state.after - 20) }), state.after === 0),
|
|
7
|
+
button("下一页", () => navigate({ ...state, after: page.next }), page.next === null));
|
|
8
|
+
root.append(actions);
|
|
9
|
+
}
|
|
10
|
+
export async function renderTasks(root, state, navigate) {
|
|
11
|
+
if (state.kind === "tasks") {
|
|
12
|
+
const page = await request("/tasks?after=" + state.after);
|
|
13
|
+
root.append(element("h2", "任务"), element("p", "触发计划状态不等于运行结果;空闲页面每 10 秒刷新,交互时可手动刷新。"),
|
|
14
|
+
table(["任务", "触发计划状态", "计划", "下次触发"], page.items.map(task => [
|
|
15
|
+
button(task.name, () => navigate({ kind: "task", id: task.id, after: 0 })),
|
|
16
|
+
task.status, JSON.stringify(task.schedule), time(task.nextAt),
|
|
17
|
+
])));
|
|
18
|
+
paging(root, page, state, navigate);
|
|
19
|
+
} else if (state.kind === "task") await task(root, state, navigate);
|
|
20
|
+
else await run(root, state, navigate);
|
|
21
|
+
}
|
|
22
|
+
async function task(root, state, navigate) {
|
|
23
|
+
const [task, page] = await Promise.all([
|
|
24
|
+
request("/tasks/" + encodeURIComponent(state.id)),
|
|
25
|
+
request("/runs?taskId=" + encodeURIComponent(state.id) + "&after=" + state.after),
|
|
26
|
+
]);
|
|
27
|
+
root.append(button("返回任务列表", () => navigate({ kind: "tasks", after: 0 })), element("h2", task.input.name),
|
|
28
|
+
element("p", task.id + " · 触发计划:" + task.status + " · 下次触发:" + time(task.nextAt)),
|
|
29
|
+
element("p", "以下操作只影响未来触发,不停止当前运行,也不回滚外部操作。恢复不会补跑;取消后不可恢复未来触发。"));
|
|
30
|
+
const actions = element("div"); actions.className = "actions";
|
|
31
|
+
for (const [action, label, enabled] of [
|
|
32
|
+
["pause", "暂停未来触发", task.status === "active"],
|
|
33
|
+
["resume", "恢复未来触发", task.status === "paused"],
|
|
34
|
+
["cancel", "取消未来触发", !["cancelled", "expired"].includes(task.status)],
|
|
35
|
+
]) {
|
|
36
|
+
actions.append(button(label, async () => {
|
|
37
|
+
if (!confirm(label + "?\n不影响当前运行。恢复不会补跑;取消不可恢复。")) return;
|
|
38
|
+
await request("/task-update", { id: task.id, action });
|
|
39
|
+
notice("未来触发已更新;当前运行未被取消。");
|
|
40
|
+
navigate(state);
|
|
41
|
+
}, !enabled || task.nextAt === null));
|
|
42
|
+
}
|
|
43
|
+
root.append(actions, block("整体目标", task.input.prompt),
|
|
44
|
+
block("计划、预算与工作空间", { ...task.input, prompt: undefined, nodes: undefined, workspace: task.workspace }),
|
|
45
|
+
table(["节点", "执行端", "依赖节点"], task.input.nodes.map(node => [node.id, node.executorId, node.dependsOn.join(", ") || "无"])),
|
|
46
|
+
block("固定 DAG 与执行端快照(只读)", { nodes: task.input.nodes, executors: task.executors }), element("h3", "运行历史"),
|
|
47
|
+
table(["运行", "状态", "已接受节点", "开始 / 结束", "错误"], page.items.map(run => [
|
|
48
|
+
button(run.id, () => navigate({ kind: "run", id: run.id })), run.status,
|
|
49
|
+
run.accepted + " / " + run.total, time(run.createdAt) + " / " + time(run.finishedAt), run.error,
|
|
50
|
+
])));
|
|
51
|
+
paging(root, page, state, navigate);
|
|
52
|
+
}
|
|
53
|
+
async function run(root, state, navigate) {
|
|
54
|
+
const run = await request("/runs/" + encodeURIComponent(state.id));
|
|
55
|
+
root.append(button("返回任务", () => navigate({ kind: "task", id: run.taskId, after: 0 })),
|
|
56
|
+
element("h2", "运行 " + run.id), element("p", run.status + " · " + time(run.createdAt) + " → " + time(run.finishedAt)),
|
|
57
|
+
element("p", "协议回合结束不等于任务完成;只有明确接受的交付才能解锁依赖。"));
|
|
58
|
+
if (run.error) root.append(block("运行错误 / 阻塞原因", run.error));
|
|
59
|
+
if (run.summary) root.append(block("运行摘要", run.summary));
|
|
60
|
+
root.append(table(["节点", "执行端", "状态", "开始", "错误"], run.nodes.map(node =>
|
|
61
|
+
[node.definition.id, node.definition.executorId, node.status, time(node.startedAt), node.error])));
|
|
62
|
+
for (const node of run.nodes) {
|
|
63
|
+
const details = element("details"); details.dataset.key = node.id;
|
|
64
|
+
details.append(element("summary", node.definition.id + " · " + node.status),
|
|
65
|
+
block("节点与会话记录", { definition: node.definition, workspace: node.workspace, inputFiles: node.inputFiles,
|
|
66
|
+
resultFile: node.resultFile, sessionId: node.sessionId, error: node.error }, node.id + "-metadata"));
|
|
67
|
+
if (node.result !== null) details.append(block("已接受交付", node.result, node.id + "-result"),
|
|
68
|
+
block("接受理由", node.acceptance, node.id + "-acceptance"));
|
|
69
|
+
node.turns.forEach((turn, index) => details.append(block("回合 " + (index + 1) + "(原始材料,不等于交付)", turn, node.id + "-turn-" + index)));
|
|
70
|
+
root.append(details);
|
|
71
|
+
}
|
|
72
|
+
root.append(element("h3", "问题与已有回复(只读)"), element("p", "本页面不回答问题。请通过 MCP 的现有人工回复流程处理。"));
|
|
73
|
+
if (!run.questions.length) root.append(element("p", "无问题记录。"));
|
|
74
|
+
for (const question of run.questions) {
|
|
75
|
+
const card = element("article");
|
|
76
|
+
card.append(element("h3", question.reply ? "已回复" : "待回答"),
|
|
77
|
+
element("p", question.question), block("已完成工作", question.completedWork, question.id + "-work"),
|
|
78
|
+
element("p", "问题 ID:" + question.id + " · 节点:" + (question.nodeId ?? "整体任务")),
|
|
79
|
+
element("p", "问题期限:" + time(question.expiresAt) + " · 会话期限:" + time(question.sessionExpiresAt)));
|
|
80
|
+
if (question.reply) card.append(block("已有回复", question.reply.answer, question.id + "-answer"));
|
|
81
|
+
root.append(card);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/web.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { bearerVerifier } from "./http-auth.js";
|
|
4
|
+
import { SettingsError } from "./settings.js";
|
|
5
|
+
import { StorageError } from "./task-storage.js";
|
|
6
|
+
import { taskIdSchema, taskListSchema, taskRunListSchema, taskUpdateSchema } from "./task-contract.js";
|
|
7
|
+
function query(schema, request) {
|
|
8
|
+
const value = { ...request.query };
|
|
9
|
+
for (const key of ["after", "limit"]) {
|
|
10
|
+
if (value[key] === undefined)
|
|
11
|
+
continue;
|
|
12
|
+
if (typeof value[key] !== "string" || !/^\d+$/.test(value[key]))
|
|
13
|
+
throw new Error("Invalid pagination");
|
|
14
|
+
// Protocol adaptation only; the shared contract owns range checks.
|
|
15
|
+
value[key] = Number(value[key]);
|
|
16
|
+
}
|
|
17
|
+
return schema.parse(value);
|
|
18
|
+
}
|
|
19
|
+
export function createWebRouter(tasks, settings, token, fatal, restart, instanceId) {
|
|
20
|
+
const router = express.Router(), authorized = bearerVerifier(token);
|
|
21
|
+
router.use((req, res, next) => {
|
|
22
|
+
res.set({
|
|
23
|
+
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
|
|
24
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
|
|
25
|
+
"X-Frame-Options": "DENY",
|
|
26
|
+
});
|
|
27
|
+
const origin = new URL("http://127.0.0.1:" + req.socket.localPort).origin;
|
|
28
|
+
if (req.headers.host !== new URL(origin).host || (req.headers.origin !== undefined && req.headers.origin !== origin)) {
|
|
29
|
+
res.status(403).end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
next();
|
|
33
|
+
});
|
|
34
|
+
// Mount authentication with the same router matching rules as the API, including case variants.
|
|
35
|
+
router.use("/api", (req, res, next) => {
|
|
36
|
+
const origin = new URL("http://127.0.0.1:" + req.socket.localPort).origin;
|
|
37
|
+
if (req.headers["sec-fetch-site"] !== undefined && req.headers["sec-fetch-site"] !== "same-origin") {
|
|
38
|
+
res.status(403).end();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!authorized(req.headers.authorization)) {
|
|
42
|
+
res.status(401).set("WWW-Authenticate", "Bearer").end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (req.method !== "GET" && (req.method !== "POST" || req.headers.origin !== origin)) {
|
|
46
|
+
res.status(403).end();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (req.method === "POST" && !req.is("application/json")) {
|
|
50
|
+
res.status(415).end();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
const assets = new Map(["index.html", "app.js", "api.js", "dom.js", "tasks-view.js", "settings-view.js", "style.css"]
|
|
56
|
+
.map(name => [name === "index.html" ? "/" : "/" + name, fileURLToPath(new URL("./web/" + name, import.meta.url))]));
|
|
57
|
+
router.get(["/", ...[...assets.keys()].filter(path => path !== "/")], (req, res) => {
|
|
58
|
+
res.sendFile(assets.get(req.path), { lastModified: false, cacheControl: false, acceptRanges: false }, error => { if (error && !res.headersSent)
|
|
59
|
+
res.status(404).end(); });
|
|
60
|
+
});
|
|
61
|
+
router.use("/api", express.json({ limit: "512kb" }));
|
|
62
|
+
const respond = (execute) => (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
res.json({ data: execute(req) });
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof StorageError) {
|
|
68
|
+
fatal(error);
|
|
69
|
+
res.status(503).json({ error: "Task storage unavailable" });
|
|
70
|
+
}
|
|
71
|
+
else if (error instanceof SettingsError) {
|
|
72
|
+
res.status({ invalid: 400, conflict: 409, unavailable: 503 }[error.code]).json({ error: error.message });
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
res.status(400).json({ error: "Request rejected; check inputs and refresh the current state" });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
router.get("/api/status", respond(() => ({ ...tasks.status(), settings: settings.status(), instanceId })));
|
|
79
|
+
router.get("/api/tasks", respond(req => {
|
|
80
|
+
const input = query(taskListSchema, req), page = tasks.list(input.after, input.limit);
|
|
81
|
+
return { ...page, items: page.items.map(task => ({ id: task.id, name: task.input.name, status: task.status, nextAt: task.nextAt, schedule: task.input.schedule })) };
|
|
82
|
+
}));
|
|
83
|
+
router.get("/api/tasks/:id", respond(req => tasks.get(taskIdSchema.parse(req.params).id)));
|
|
84
|
+
router.get("/api/runs", respond(req => {
|
|
85
|
+
const input = query(taskRunListSchema, req), page = tasks.runs(input.taskId, input.after, input.limit);
|
|
86
|
+
return { ...page, items: page.items.map(run => ({ id: run.id, taskId: run.taskId, status: run.status,
|
|
87
|
+
createdAt: run.createdAt, finishedAt: run.finishedAt, error: run.error,
|
|
88
|
+
accepted: run.nodes.filter(node => node.status === "succeeded").length, total: run.nodes.length })) };
|
|
89
|
+
}));
|
|
90
|
+
router.get("/api/runs/:id", respond(req => tasks.run(taskIdSchema.parse(req.params).id)));
|
|
91
|
+
router.post("/api/task-update", respond(req => tasks.update(taskUpdateSchema.parse(req.body))));
|
|
92
|
+
router.get("/api/settings", respond(() => settings.get()));
|
|
93
|
+
router.post("/api/settings/validate", respond(req => {
|
|
94
|
+
const config = settings.validate(req.body);
|
|
95
|
+
return { config, affectedTasks: tasks.executorImpact(config.executors) };
|
|
96
|
+
}));
|
|
97
|
+
router.post("/api/settings/config", respond(req => settings.saveConfig(req.body)));
|
|
98
|
+
router.post("/api/settings/credential", respond(req => settings.saveCredential(req.body)));
|
|
99
|
+
router.post("/api/restart", async (req, res) => {
|
|
100
|
+
if (!req.body || Array.isArray(req.body) || typeof req.body !== "object" || Object.keys(req.body).length) {
|
|
101
|
+
res.status(400).json({ error: "Expected an empty request" });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
res.json({ data: await restart() });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
res.status(503).json({ error: "Restart could not be confirmed; inspect private logs and use CLI status before retrying" });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
router.use((_req, res) => { res.status(404).end(); });
|
|
112
|
+
router.use(((error, _req, res, _next) => {
|
|
113
|
+
res.status(error.status === 413 ? 413 : error.status === 415 ? 415 : 400).json({ error: "Invalid request body" });
|
|
114
|
+
}));
|
|
115
|
+
return router;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=web.js.map
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { openAcp } from "./acp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { errorText, executorSchema } from "./model.js";
|
|
4
|
+
const inputSchema = z.object({ executor: executorSchema, inputFiles: z.array(z.string()).max(20), resultsRoot: z.string().min(1) }).strict();
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
let sequence = 0, started = false, busy = false;
|
|
8
|
+
let session;
|
|
9
|
+
let opening, closing;
|
|
10
|
+
function send(value) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
if (!process.connected || !process.send) {
|
|
13
|
+
reject(new Error("Supervisor disconnected"));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
process.send(value, error => error ? reject(error) : resolve());
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function emit(kind, data, text = "") {
|
|
20
|
+
const seq = ++sequence;
|
|
21
|
+
const acknowledged = new Promise((resolve, reject) => {
|
|
22
|
+
const timer = setTimeout(() => { pending.delete(seq); reject(new Error("Progress acknowledgement timed out")); }, 5000);
|
|
23
|
+
pending.set(seq, { resolve, reject, timer });
|
|
24
|
+
});
|
|
25
|
+
void acknowledged.catch(() => { });
|
|
26
|
+
try {
|
|
27
|
+
await send({ type: "event", seq, kind, data, text });
|
|
28
|
+
await acknowledged;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
const item = pending.get(seq);
|
|
32
|
+
if (item)
|
|
33
|
+
clearTimeout(item.timer);
|
|
34
|
+
pending.delete(seq);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function close() {
|
|
38
|
+
return closing ??= (async () => {
|
|
39
|
+
controller.abort(new Error("Execution stopped"));
|
|
40
|
+
for (const item of pending.values()) {
|
|
41
|
+
clearTimeout(item.timer);
|
|
42
|
+
item.reject(new Error("Execution stopped"));
|
|
43
|
+
}
|
|
44
|
+
pending.clear();
|
|
45
|
+
await opening?.catch(() => { });
|
|
46
|
+
await session?.close();
|
|
47
|
+
if (process.connected)
|
|
48
|
+
process.disconnect();
|
|
49
|
+
})();
|
|
50
|
+
}
|
|
51
|
+
async function fail(error) {
|
|
52
|
+
await send({ type: "failed", error: errorText(error) }).catch(() => { });
|
|
53
|
+
await close();
|
|
54
|
+
}
|
|
55
|
+
process.on("disconnect", () => { void close(); });
|
|
56
|
+
process.on("SIGTERM", () => { void close(); });
|
|
57
|
+
process.on("SIGINT", () => { void close(); });
|
|
58
|
+
process.on("message", (message) => {
|
|
59
|
+
if (message.type === "ack" && message.seq !== undefined)
|
|
60
|
+
pending.get(message.seq)?.resolve();
|
|
61
|
+
else if (message.type === "close")
|
|
62
|
+
void close();
|
|
63
|
+
else if (message.type === "open" && !started && !closing) {
|
|
64
|
+
started = true;
|
|
65
|
+
opening = (async () => {
|
|
66
|
+
const input = inputSchema.parse(message.input);
|
|
67
|
+
session = await openAcp(input.executor, controller.signal, emit, input.resultsRoot, input.inputFiles);
|
|
68
|
+
void session.closed.then(() => { if (!closing)
|
|
69
|
+
void fail(new Error("ACP process exited")); });
|
|
70
|
+
if (!controller.signal.aborted)
|
|
71
|
+
await send({ type: "opened" });
|
|
72
|
+
})();
|
|
73
|
+
void opening.catch(error => { void fail(error); });
|
|
74
|
+
}
|
|
75
|
+
else if (message.type === "prompt" && session && !closing) {
|
|
76
|
+
if (busy || typeof message.text !== "string") {
|
|
77
|
+
void fail(new Error("Invalid or concurrent turn"));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
busy = true;
|
|
81
|
+
void session.prompt(message.text).then(async (stopReason) => {
|
|
82
|
+
// Release admission before the parent can observe completion and send the next turn.
|
|
83
|
+
busy = false;
|
|
84
|
+
await send({ type: "turn", stopReason });
|
|
85
|
+
}).catch(error => { busy = false; return fail(error); });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
void send({ type: "ready" }).catch(() => { void close(); });
|
|
89
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
/** Path validity only; native executor policy owns directory and tool access. */
|
|
4
|
+
export function resolveWorkspace(path) {
|
|
5
|
+
if (!path || path.includes("\0") || !isAbsolute(path))
|
|
6
|
+
throw new Error("Workspace must be an absolute directory path");
|
|
7
|
+
const canonical = realpathSync(path);
|
|
8
|
+
if (!statSync(canonical).isDirectory())
|
|
9
|
+
throw new Error("Workspace must be an existing directory");
|
|
10
|
+
return canonical;
|
|
11
|
+
}
|
|
12
|
+
export function checkWorkspace(path) {
|
|
13
|
+
if (resolveWorkspace(path) !== path)
|
|
14
|
+
throw new Error("Workspace path changed since admission");
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=workspace.js.map
|