@realqiyan/agent-task 0.0.0-stage → 0.0.1-rc.1
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 +45 -2
- package/bin/agent-task.mjs +66 -0
- package/config.example.json +7 -0
- package/deploy/agent-task.service.example +17 -0
- package/dist/acp.js +168 -0
- package/dist/admin.js +31 -0
- package/dist/config.js +113 -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 +23 -0
- package/dist/http-auth.js +9 -0
- package/dist/human-input.js +62 -0
- package/dist/lock.js +27 -0
- package/dist/main.js +131 -0
- package/dist/mcp.js +40 -0
- package/dist/model.js +28 -0
- package/dist/node-execution.js +103 -0
- package/dist/permissions.js +10 -0
- package/dist/private-files.js +81 -0
- package/dist/results.js +85 -0
- package/dist/settings-contract.js +16 -0
- package/dist/settings.js +163 -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 +509 -0
- package/dist/version.js +3 -0
- package/dist/web/api.js +26 -0
- package/dist/web/app.js +65 -0
- package/dist/web/dom.js +65 -0
- package/dist/web/index.html +33 -0
- package/dist/web/settings-view.js +121 -0
- package/dist/web/style.css +24 -0
- package/dist/web/tasks-view.js +83 -0
- package/dist/web.js +105 -0
- package/dist/worker.js +89 -0
- package/dist/workspace.js +16 -0
- package/docs/architecture-guidelines.md +52 -0
- package/docs/deployment.md +113 -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 +100 -0
- package/docs/web-console.md +46 -0
- package/package.json +68 -4
- package/scripts/install-lib.mjs +191 -0
- package/scripts/mcp-call.mjs +17 -0
- package/scripts/probe-agent.mjs +34 -0
- package/scripts/service-runner.mjs +14 -0
- package/scripts/setup-wizard.mjs +29 -0
- package/scripts/setup.mjs +126 -0
- package/scripts/uninstall.mjs +53 -0
|
@@ -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,121 @@
|
|
|
1
|
+
import { request } from "./api.js";
|
|
2
|
+
import { element, field, select, button, submit, block, table, notice } from "./dom.js";
|
|
3
|
+
|
|
4
|
+
const lines = input => input.value.split("\n").map(value => value.trim()).filter(Boolean);
|
|
5
|
+
function executorEditor(host, executor) {
|
|
6
|
+
const card = element("fieldset"), fields = {};
|
|
7
|
+
card.append(element("legend", "ACP 执行端"));
|
|
8
|
+
for (const [key, label, type, value] of [
|
|
9
|
+
["id", "注册 ID", "text", executor.id],
|
|
10
|
+
["command", "ACP 可执行文件(绝对路径,不含密钥)", "text", executor.command],
|
|
11
|
+
["cwd", "默认工作空间(绝对路径)", "text", executor.cwd],
|
|
12
|
+
["args", "参数(JSON 字符串数组;不得包含密钥)", "textarea", JSON.stringify(executor.args ?? [])],
|
|
13
|
+
["envKeys", "传入执行端的环境变量名(每行一个,不填值)", "textarea", (executor.envKeys ?? []).join("\n")],
|
|
14
|
+
["permissionAllowlist", "ACP 审批工具名白名单(每行一个)", "textarea", (executor.permissionAllowlist ?? []).join("\n")],
|
|
15
|
+
["resultReadRoots", "结果读取目录(规范绝对路径,每行一个)", "textarea", (executor.resultReadRoots ?? []).join("\n")],
|
|
16
|
+
]) {
|
|
17
|
+
const item = field(label, value, type); fields[key] = item.input; card.append(item.row);
|
|
18
|
+
}
|
|
19
|
+
const approved = field("已由管理员审核原生授权,允许无人值守执行", executor.unattendedApproved, "checkbox");
|
|
20
|
+
for (const input of Object.values(fields)) input.addEventListener("input", () => { approved.input.checked = false; });
|
|
21
|
+
card.append(approved.row, element("p", "修改注册信息后须重新明确批准。白名单只约束实际发出的 ACP 审批请求,不是沙箱,也不是完整工具授权。"),
|
|
22
|
+
button("从待保存配置中移除", () => { card.remove(); host.dispatchEvent(new Event("input", { bubbles: true })); }));
|
|
23
|
+
host.append(card);
|
|
24
|
+
return () => {
|
|
25
|
+
if (card.parentNode !== host) return [];
|
|
26
|
+
let args;
|
|
27
|
+
try { args = JSON.parse(fields.args.value); } catch { throw new Error("执行端参数必须是 JSON 字符串数组。"); }
|
|
28
|
+
return [{ id: fields.id.value, command: fields.command.value, cwd: fields.cwd.value, args,
|
|
29
|
+
envKeys: lines(fields.envKeys), permissionAllowlist: lines(fields.permissionAllowlist),
|
|
30
|
+
resultReadRoots: lines(fields.resultReadRoots), unattendedApproved: approved.input.checked }];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function coordinatorEditor(host, config) {
|
|
34
|
+
const provider = select("协调模型提供方", [["", "未配置"], ["openai", "OpenAI"], ["anthropic", "Anthropic"],
|
|
35
|
+
["deepseek", "DeepSeek"], ["openai-compatible", "OpenAI-compatible"]], config?.provider ?? "");
|
|
36
|
+
const model = field("模型 ID", config?.model), compatible = element("fieldset");
|
|
37
|
+
compatible.append(element("legend", "兼容端点元数据(由管理员确认,不自动查询)"));
|
|
38
|
+
const custom = {
|
|
39
|
+
baseUrl: field("端点 URL(不得包含凭据、查询参数或片段)", config?.baseUrl),
|
|
40
|
+
apiKeyEnv: field("密钥变量名(先在下方保存新密钥)", config?.apiKeyEnv ?? "COORDINATOR_API_KEY"),
|
|
41
|
+
api: select("API", [["openai-completions", "openai-completions"], ["openai-responses", "openai-responses"]], config?.api ?? "openai-completions"),
|
|
42
|
+
contextWindow: field("上下文窗口", config?.contextWindow, "number"),
|
|
43
|
+
maxTokens: field("最大输出 token", config?.maxTokens, "number"),
|
|
44
|
+
reasoning: field("支持 reasoning", config?.reasoning, "checkbox"),
|
|
45
|
+
};
|
|
46
|
+
for (const item of Object.values(custom)) compatible.append(item.row);
|
|
47
|
+
const visibility = () => { compatible.hidden = provider.input.value !== "openai-compatible"; model.row.hidden = !provider.input.value; };
|
|
48
|
+
provider.input.addEventListener("change", visibility); visibility();
|
|
49
|
+
host.append(provider.row, model.row, compatible);
|
|
50
|
+
return () => {
|
|
51
|
+
if (!provider.input.value) return undefined;
|
|
52
|
+
const result = { provider: provider.input.value, model: model.input.value };
|
|
53
|
+
if (result.provider === "openai-compatible") Object.assign(result, {
|
|
54
|
+
baseUrl: custom.baseUrl.input.value, apiKeyEnv: custom.apiKeyEnv.input.value, api: custom.api.input.value,
|
|
55
|
+
contextWindow: custom.contextWindow.input.valueAsNumber, maxTokens: custom.maxTokens.input.valueAsNumber,
|
|
56
|
+
reasoning: custom.reasoning.input.checked,
|
|
57
|
+
});
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function renderSettings(root, navigate) {
|
|
62
|
+
let state = await request("/settings");
|
|
63
|
+
root.append(element("h2", "配置与凭据"), element("p", "配置和密钥分别保存。保存不修改当前进程;每次重新启动(包括故障后拉起)读取已保存值。"),
|
|
64
|
+
element("p", "先保存新密钥,再保存引用它的配置。执行端原生登录和原生权限仍通过官方工具管理;这里不编辑其认证文件。"),
|
|
65
|
+
block("当前运行配置(只读,无密钥值)", state.running),
|
|
66
|
+
block("启动级配置(只读)", { dataDir: state.saved.dataDir, port: state.saved.port, tokenEnv: state.saved.tokenEnv }));
|
|
67
|
+
if (state.writeBlocked) root.append(element("p", "上次文件提交结果不确定。请停服核查;当前禁止继续保存。"));
|
|
68
|
+
const form = element("form"), modelHost = element("fieldset"), executors = element("div"), readers = [];
|
|
69
|
+
modelHost.append(element("legend", "协调模型"));
|
|
70
|
+
const readModel = coordinatorEditor(modelHost, state.saved.coordinator);
|
|
71
|
+
form.append(modelHost, element("h3", "执行端注册与权限"), executors);
|
|
72
|
+
for (const executor of state.saved.executors) readers.push(executorEditor(executors, executor));
|
|
73
|
+
form.append(button("添加执行端", () => {
|
|
74
|
+
readers.push(executorEditor(executors, { id: "", command: "", cwd: "", unattendedApproved: false }));
|
|
75
|
+
}, state.writeBlocked));
|
|
76
|
+
const preview = element("div");
|
|
77
|
+
const save = submit(form, "校验并预览配置变更", async () => {
|
|
78
|
+
const input = { editToken: state.editToken, config: { coordinator: readModel(), executors: readers.flatMap(read => read()) } };
|
|
79
|
+
const checked = await request("/settings/validate", input);
|
|
80
|
+
preview.replaceChildren(block("校验后的待保存配置", checked.config),
|
|
81
|
+
block("重启后受执行端变更影响的任务", checked.affectedTasks));
|
|
82
|
+
const affected = checked.affectedTasks.map(task => task.name + " (" + task.id + ")").join("\n") || "无";
|
|
83
|
+
if (!confirm("本地校验通过,不代表模型认证或 ACP 执行已验证。\n受影响任务:\n" + affected +
|
|
84
|
+
"\n\n旧任务快照不会被重写,派发可能被拒绝。确认保存配置?保存后仍须手动重启。")) return;
|
|
85
|
+
await request("/settings/config", input);
|
|
86
|
+
notice("配置已保存,未热更新。请检查活动运行后在终端手动重启。");
|
|
87
|
+
navigate({ kind: "settings" }, true);
|
|
88
|
+
});
|
|
89
|
+
save.disabled = state.writeBlocked; form.append(save, preview); root.append(form);
|
|
90
|
+
|
|
91
|
+
root.append(element("h3", "密钥(仅显示是否设置)"),
|
|
92
|
+
element("p", "不提交即保留原值。新变量名使用 _KEY、_TOKEN、_SECRET 或 _PASSWORD 结尾;不能在这里修改服务/管理员令牌或运行环境。"));
|
|
93
|
+
const statuses = element("div"), credentialForm = element("form");
|
|
94
|
+
const key = field("凭据变量名"), value = field("新值(替换后不回显)", "", "password");
|
|
95
|
+
key.input.required = true; value.input.required = true;
|
|
96
|
+
value.input.maxLength = 16384;
|
|
97
|
+
const drawCredentials = () => {
|
|
98
|
+
statuses.replaceChildren(table(["变量名", "已保存", "当前进程已设置", "待重启", "操作"], state.credentials.map(credential => [
|
|
99
|
+
credential.key, credential.saved ? "是" : "否", credential.running ? "是" : "否", credential.pendingRestart ? "是" : "否",
|
|
100
|
+
button("删除已保存值", async () => {
|
|
101
|
+
if (!confirm("删除 " + credential.key + " 的已保存值?当前进程仍保留旧值,直到重启。")) return;
|
|
102
|
+
state = await request("/settings/credential", { editToken: state.editToken, key: credential.key, action: "delete" });
|
|
103
|
+
drawCredentials(); notice("密钥删除已保存,待重启。配置表单中的未保存编辑仍保留。");
|
|
104
|
+
}, !credential.saved || credential.referenced || state.writeBlocked),
|
|
105
|
+
])));
|
|
106
|
+
};
|
|
107
|
+
drawCredentials();
|
|
108
|
+
credentialForm.append(key.row, value.row);
|
|
109
|
+
const replace = submit(credentialForm, "单独保存此密钥", async () => {
|
|
110
|
+
const input = { editToken: state.editToken, key: key.input.value, action: "replace", value: value.input.value };
|
|
111
|
+
value.input.value = "";
|
|
112
|
+
state = await request("/settings/credential", input);
|
|
113
|
+
drawCredentials(); notice("密钥已保存,未热更新。配置表单中的未保存编辑仍保留,请继续校验并保存配置。");
|
|
114
|
+
});
|
|
115
|
+
replace.disabled = state.writeBlocked;
|
|
116
|
+
credentialForm.append(replace);
|
|
117
|
+
root.append(statuses, credentialForm, element("h3", "手动重启"),
|
|
118
|
+
element("p", "先检查活动运行及待回答运行;待回答运行也可能持有 ACP 会话。必要时暂停相关未来触发,并等待活动工作结束。重启可能中断会话,不会自动重放不确定执行。"),
|
|
119
|
+
block("Linux 用户服务", "systemctl --user restart agent-task.service"),
|
|
120
|
+
element("p", "macOS 按部署说明 bootout 后用 setup 重新加载 LaunchAgent。前台服务停止后用原启动参数重新启动。启动失败时从终端修正私有文件,不删除历史或工作空间。"));
|
|
121
|
+
}
|
|
@@ -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,105 @@
|
|
|
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) {
|
|
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() })));
|
|
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.use((_req, res) => { res.status(404).end(); });
|
|
100
|
+
router.use(((error, _req, res, _next) => {
|
|
101
|
+
res.status(error.status === 413 ? 413 : error.status === 415 ? 415 : 400).json({ error: "Invalid request body" });
|
|
102
|
+
}));
|
|
103
|
+
return router;
|
|
104
|
+
}
|
|
105
|
+
//# 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) }).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.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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# 架构原则
|
|
2
|
+
|
|
3
|
+
目标是让维护者可以局部理解并安全修改系统,不为目录对称或未来假设增加机制。产品边界见[总体方案](design-proposal.md),当前差距见[代码导航](technical-design.md)。
|
|
4
|
+
|
|
5
|
+
## 职责与依赖
|
|
6
|
+
|
|
7
|
+
| 责任 | 所有者 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| 输入、输出、schema、错误契约和调用适配 | 工具 |
|
|
10
|
+
| 业务决定、授权、状态转换、副作用顺序及恢复策略 | 服务 |
|
|
11
|
+
| 严格读写、事务、索引和持久化健康状态 | 存储 |
|
|
12
|
+
| MCP/ACP、进程及操作系统协议 | 接入 |
|
|
13
|
+
| 依赖装配、运行实例及生命周期 | 应用入口 |
|
|
14
|
+
| 普通领域数据,不含业务行为或 SDK 对象 | 模型 |
|
|
15
|
+
| 展示与收集操作,通过服务执行 | Web 控制台 |
|
|
16
|
+
|
|
17
|
+
服务只接收普通数据和窄能力,不依赖 SDK 上下文、HTTP 对象、UI 或完整进程对象。接入实现服务所需能力,由入口装配;存储不反向依赖服务或协议。禁止循环依赖、隐藏的全局可变状态和服务定位器。
|
|
18
|
+
|
|
19
|
+
当前仍是扁平 MVP,不要求立即按层建目录。修改受影响职责时连同边界收敛,并保持行为测试;不做纯目录搬迁,不加空目录、无人使用的接口、转发层或动态插件平台。依赖边界检查应在分层落实后引入,不能提前声称已有。
|
|
20
|
+
|
|
21
|
+
## 执行与持久性
|
|
22
|
+
|
|
23
|
+
- 复用 SDK 模型循环;只有协调员推进既定 DAG,不再增加规划器或并行节点调度器。
|
|
24
|
+
- Task、Run、模型会话、ACP Session 和外部业务记录各有责任主体,不能互相代替。
|
|
25
|
+
- 先可靠保存接收或派发意图,再触发外部工作。业务幂等不能证明外部副作用只发生一次。
|
|
26
|
+
- 只有明确接受的执行端交付才能解锁依赖;模型判断不能替代领域证据。
|
|
27
|
+
- 未收到结果不等于没有执行。中断或结果不明时阻止新派发,包括模型新发起的调用;不自动重试、切换 Agent 或创建新身份重放。
|
|
28
|
+
- 截止时间限制新执行开始,执行超时是另一规则。客户端断开不取消任务;取消不回滚外部操作。
|
|
29
|
+
- 资源有明确所有者,关闭幂等,关闭后拒绝操作。先停止新派发,再处理活动执行、网络与存储,最后释放所有权。
|
|
30
|
+
- 目录锁、事务、worker 监督和服务管理器解决不同问题,不等于跨机一致性、无孤儿进程或工具沙箱。
|
|
31
|
+
|
|
32
|
+
## 权限与敏感数据
|
|
33
|
+
|
|
34
|
+
- 执行端声明能力、管理员授权、真实验证证据分别表达,不能相互推导。
|
|
35
|
+
- 只有管理员注册的执行端可启动。命令、凭据和能力端点属于管理员;任务可选择现有绝对工作空间,但路径校验不是授权或沙箱。
|
|
36
|
+
- 授权须在可控制的实际操作入口检查。ACP 审批只约束经过回调的请求,白名单及 `unattendedApproved` 不能证明所有原生工具已受控。
|
|
37
|
+
- Prompt、角色、技能、工具标题、协议成功和人工事实回复都不授予权限。取消、恢复和跨 Agent 传递不能扩大授权。
|
|
38
|
+
- 无人值守执行不等待交互审批;授权不充分或元数据不足时拒绝,不退回更宽权限或自动永久允许。
|
|
39
|
+
- MCP token、管理员 token、协调模型与执行端凭据分开,不整套继承服务环境,不有意记录秘密。Agent 输出和协调日志仍可能敏感。
|
|
40
|
+
- 优先使用执行端原生配置,不读取其私有业务存储格式,不将特定领域规则塞进平台。
|
|
41
|
+
|
|
42
|
+
## 存储与发布
|
|
43
|
+
|
|
44
|
+
使用本项目版本,不复制其他项目的存储标记。调研阶段不保留兼容层、额外版本计数或旧执行引擎;单节点和多节点共用 Task API。
|
|
45
|
+
|
|
46
|
+
新库初始化与既有数据升级分开。严格加载支持的契约,损坏或未知数据拒绝使用。普通启动不静默迁移、猜测修复或删除历史。
|
|
47
|
+
|
|
48
|
+
实际转换应是独立离线操作:声明源与目标格式、停止服务并取得排他所有权、备份、转换后验证、支持显式接续。不通过重放外部请求或重建工作空间修复记录,不引入文件哈希验证。
|
|
49
|
+
|
|
50
|
+
## 开发与文档
|
|
51
|
+
|
|
52
|
+
测试方法、命令和文档维护规则统一见 [AGENTS.md](../AGENTS.md)。需求、当前实现和验证证据分别维护;具体字段和流程链接到代码,不在多份文档中重复。
|