@seqyuan/ennote 0.4.0 → 0.5.0
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/README.md +106 -13
- package/bin/ennote.js +417 -2
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +4 -4
- package/out/__next._full.txt +16 -16
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +4 -4
- package/out/_next/static/chunks/01j7xr5p-4q8-.js +8 -0
- package/out/_next/static/chunks/0_yt3a8a9j1yd.js +1 -0
- package/out/_next/static/chunks/{0l9f15ts9g8~w.css → 0bxrnkjb.apni.css} +2 -2
- package/out/_next/static/chunks/{0j5eflqzx5_i2.css → 0k-~g5-_lbznv.css} +1 -1
- package/out/_next/static/chunks/0l9r.o-l3s.5h.js +1 -0
- package/out/_next/static/chunks/0r3uxu723bo0o.css +4 -0
- package/out/_not-found/__next._full.txt +14 -14
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +3 -3
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +14 -14
- package/out/graphs/__next._full.txt +16 -16
- package/out/graphs/__next._head.txt +4 -4
- package/out/graphs/__next._index.txt +8 -8
- package/out/graphs/__next._tree.txt +4 -4
- package/out/graphs/__next.graphs.__PAGE__.txt +4 -4
- package/out/graphs/__next.graphs.txt +3 -3
- package/out/graphs.html +1 -1
- package/out/graphs.txt +16 -16
- package/out/index.html +2 -2
- package/out/index.txt +16 -16
- package/out/roles/__next._full.txt +16 -16
- package/out/roles/__next._head.txt +4 -4
- package/out/roles/__next._index.txt +8 -8
- package/out/roles/__next._tree.txt +4 -4
- package/out/roles/__next.roles.__PAGE__.txt +4 -4
- package/out/roles/__next.roles.txt +3 -3
- package/out/roles.html +1 -1
- package/out/roles.txt +16 -16
- package/package.json +13 -3
- package/skills/bio-workspace/SKILL.md +11 -0
- package/skills/bio-workspace/skill.json +7 -0
- package/skills/skill-creator/SKILL.md +87 -0
- package/skills/skill-creator/skill.json +7 -0
- package/out/_next/static/chunks/0qavlry.eoeqb.js +0 -1
- package/out/_next/static/chunks/0uuaelr0o3sep.css +0 -4
- package/out/_next/static/chunks/0yuqqoxr67j_q.js +0 -8
- /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → I9_aKPkRc3su-s0AsXKQM}/_buildManifest.js +0 -0
- /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → I9_aKPkRc3su-s0AsXKQM}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{Mlvju0_auR3eZP02RZD1- → I9_aKPkRc3su-s0AsXKQM}/_ssgManifest.js +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
# Ennote
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
本地优先、可审计的 Agent 运行时,核心能力是 **Agent Flow**。
|
|
4
|
+
|
|
5
|
+
Ennote 把长任务编排成可恢复的 Agent Flow:Task 图、Role 绑定、预算、审批、
|
|
6
|
+
checkpoint 和恢复状态都持久化在本地 SQLite 中。它不依赖账号或云端即可启动、
|
|
7
|
+
计算和保存结果;生信是参考工作流,而不是平台承诺。
|
|
8
|
+
|
|
9
|
+
## 核心特点
|
|
10
|
+
|
|
11
|
+
### Agent Flow
|
|
12
|
+
|
|
13
|
+
- 用 Task 图表达依赖、分支、收敛和只读 fan-out,而不是把复杂流程塞进一条提示词。
|
|
14
|
+
- Agent Flow 是不可变版本;每次运行冻结 Role、Skill、模型和权限配置。
|
|
15
|
+
- 每个 Task 是独立子 Run,带自己的预算、审批和输出契约。
|
|
16
|
+
- Task 的 `writes` 声明只影响调度(reader/writer lane 与 disjoint writers),**不是沙箱隔离边界**。
|
|
17
|
+
- 串行执行、checkpoint 续跑、取消与恢复都已落地,并经过真实 Provider 验证。
|
|
18
|
+
- Graph 编辑器、版本校验、导入/导出共用同一套文件格式。
|
|
19
|
+
|
|
20
|
+
### 可审计执行
|
|
21
|
+
|
|
22
|
+
- append-only Run 事件、commit-before-publish、durable SQLite 历史。
|
|
23
|
+
- 危险工具调用走审批;支持 standing grants 与冻结的策略快照。
|
|
24
|
+
- 失败/中断 Run 可恢复,Session 可 rewind 和 branch,不覆盖已有历史。
|
|
25
|
+
- 每个请求、Run、审批和 Artifact 都有稳定的本地标识,便于回溯“谁在什么时候做了什么”。
|
|
26
|
+
|
|
27
|
+
### 本地优先
|
|
28
|
+
|
|
29
|
+
- `ennoworker` 只监听 loopback,`ennogate` 负责认证、静态 UI 和进程监督。
|
|
30
|
+
- 没有账号、Relay 或 Connect Token 也能完整使用 localhost/LAN Web。
|
|
31
|
+
- 云端不可用只会影响远程访问,不会停止 Worker、取消 Run 或删除数据。
|
|
32
|
+
- LAN 访问默认关闭;需要显式开启,并优先使用 TLS。
|
|
33
|
+
|
|
34
|
+
### 可组合
|
|
35
|
+
|
|
36
|
+
- 不可变 Role、Skills、MCP 工具、typed Artifact 和托管委托。
|
|
37
|
+
- 生信示例(`skills/bio-workspace`)展示如何把本地数据、shell 工具和 Agent Flow 组合起来。
|
|
4
38
|
|
|
5
39
|
## 架构
|
|
6
40
|
|
|
@@ -9,9 +43,12 @@ AI-native bioinformatics agent workspace.
|
|
|
9
43
|
```
|
|
10
44
|
Browser → ennogate (Go, :45131) → ennoworker (Go, :0, loopback)
|
|
11
45
|
守门人 劳动者
|
|
12
|
-
认证 / 代理 / 静态文件
|
|
46
|
+
认证 / 代理 / 静态文件 SQLite / Agent Flow / 审批 / 工具 / Skill
|
|
13
47
|
```
|
|
14
48
|
|
|
49
|
+
Worker 是 Project、Session、Message、Run、Attention、审批、Role/模型配置、
|
|
50
|
+
Workspace、Artifact 和委托状态的唯一权威。Web UI 只是同一套 Worker API 的客户端。
|
|
51
|
+
|
|
15
52
|
## 快速开始
|
|
16
53
|
|
|
17
54
|
```bash
|
|
@@ -32,7 +69,56 @@ ennote start
|
|
|
32
69
|
- npm 包(`@seqyuan/ennote`)只包含 CLI 脚本和编译后的静态前端(约 12 MB)。
|
|
33
70
|
- 首次 `ennote start` 时,CLI 会从本仓库([seqyuan/ennote](https://github.com/seqyuan/ennote))的 GitHub Release 按当前平台/架构下载 Go 二进制(ennogate + ennoworker),缓存到 `~/.ennote/bin/`,后续启动不再下载。
|
|
34
71
|
- 支持平台:linux-x64 / linux-arm64 / darwin-x64 / darwin-arm64。
|
|
35
|
-
- 发布流程:打版本 tag(`vX.Y.Z`)后,[release workflow](.github/workflows/release.yml)
|
|
72
|
+
- 发布流程:打版本 tag(`vX.Y.Z`)后,[release workflow](.github/workflows/release.yml) 运行测试、构建四平台二进制、验证 npm 版本已生效,最后创建 GitHub Release。
|
|
73
|
+
|
|
74
|
+
### 审查 Agent Pack
|
|
75
|
+
|
|
76
|
+
`ennote pack inspect` 调用正在运行的 Worker 做只读审查:不安装、不执行、不验证签名、不写磁盘。
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ennote pack inspect ~/Downloads/cell-qc.enpack # 归档
|
|
80
|
+
ennote pack inspect ~/src/cell-qc # 目录包(需含 pack.yaml)
|
|
81
|
+
ennote pack inspect ~/Downloads/cell-qc.enpack --json # 原始 inspect 结果
|
|
82
|
+
ennote pack inspect ~/Downloads/cell-qc.enpack --documents # 附带 flow/role/skill 正文
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
输出包含 `manifestDigest`(`pack.yaml` 原始字节)和 `packDigest`(排序后的 payload 行,排除顶层 `pack.sig`)、flow/role/skill 摘要、能力信封与 warnings。`format: flow` 与 `format: graph` 会如实标注,不会被悄悄降级。Worker 未启动时命令直接失败并提示 `ennote start`,不做离线降级。
|
|
86
|
+
|
|
87
|
+
### 安装 / 列出 / 卸载 Agent Pack
|
|
88
|
+
|
|
89
|
+
安装同样只通过 loopback Worker 落盘,CLI 自己不做解析或写盘。
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
ennote pack install ~/Downloads/cell-qc.enpack --dry-run # 只出计划,不写任何文件
|
|
93
|
+
ennote pack install ~/Downloads/cell-qc.enpack # 正式安装(可传目录包)
|
|
94
|
+
ennote pack install ~/src/cell-qc --as-flow qc-review=cell-qc-review
|
|
95
|
+
ennote pack list # 已安装 Pack 摘要
|
|
96
|
+
ennote pack remove seqyuan/cell-qc-review # 卸载
|
|
97
|
+
ennote pack remove seqyuan/cell-qc-review --force # 覆盖本地编辑 / 共享 Role 的拒绝
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
安装支持三种形态:Role-only Pack(每个 Role 变成 Worker 全局 Role 并发布 revision)、`format: graph` Flow Pack(未发布 revision 的 Graph draft + 其私有 Role/Skill)、以及 `format: flow` Agent Flow Pack(未发布 revision 的完整 FlowDefinition draft + 其 flow-local Role/Skill;安装前用真实 `agentflow.Validator` 校验 check/fan-out/terminal/next,不会降级成 graph)。非空 `mcp`、非空 `requires` 仍会被明确拒绝并返回稳定错误码,不会降级安装。dry-run 恒返回计划(含 blockers),正式安装遇到 blocker/冲突返回 422。
|
|
101
|
+
|
|
102
|
+
安装后的 source 默认不可运行:`format: graph` 需要发布 Graph revision;`format: flow` 需要 `POST /v1/flows/{id}/publish` 发布 revision,再由 Project GraphBinding 显式启用。安装器永远不会自动发布或自动启用。
|
|
103
|
+
|
|
104
|
+
### 从 v0.1.x 升级
|
|
105
|
+
|
|
106
|
+
v0.1.x 的旧数据库(`data/ennote.db`)不再被读取,但也不会被删除:升级后首次启动会把它(含 `-wal`/`-shm`)原子移动到 `data/legacy-v1/` 并打印一条 `archived legacy V1 database` 警告。迁移完成后再次启动不会改动该归档。需要找回旧数据时,从 `data/legacy-v1/` 手动复制即可。
|
|
107
|
+
|
|
108
|
+
### 局域网访问
|
|
109
|
+
|
|
110
|
+
默认只监听 `127.0.0.1`。需要从局域网打开时:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# 推荐:TLS 终止在 ennogate,cookie 带 Secure
|
|
114
|
+
ENNOTE_HOSTNAME=0.0.0.0 ENNOTE_ALLOW_LAN=1 \
|
|
115
|
+
ENNOTE_TLS_CERT=/path/cert.pem ENNOTE_TLS_KEY=/path/key.pem ennogate
|
|
116
|
+
|
|
117
|
+
# 不推荐:显式接受明文风险(密码和 7 天 cookie 会明文过网)
|
|
118
|
+
ENNOTE_HOSTNAME=0.0.0.0 ENNOTE_ALLOW_LAN=1 ENNOTE_ALLOW_INSECURE_LAN=1 ennogate
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
非 loopback 地址在缺少 `ENNOTE_ALLOW_LAN=1` 时会拒绝启动;缺少 TLS 且没有 `ENNOTE_ALLOW_INSECURE_LAN=1` 时同样拒绝启动。
|
|
36
122
|
|
|
37
123
|
## 目录
|
|
38
124
|
|
|
@@ -46,33 +132,40 @@ ennoworker/
|
|
|
46
132
|
config/ 配置与环境变量
|
|
47
133
|
domain/ 领域类型和状态机
|
|
48
134
|
events/ Durable EventWriter + 订阅 Hub
|
|
135
|
+
graphrun/ Agent Flow 执行与恢复
|
|
49
136
|
llm/ Provider 接口、OpenAI-compatible、fake
|
|
50
137
|
runs/ Run Coordinator(并发、取消、恢复)
|
|
51
138
|
skills/ Skill bundle 加载与 per-run 快照
|
|
52
|
-
store/ SQLite
|
|
53
|
-
tools/
|
|
139
|
+
store/ SQLite、Repository
|
|
140
|
+
tools/ 文件/Shell/审批工具 + registry
|
|
54
141
|
workspace/ Path jail、bubblewrap 沙箱
|
|
55
|
-
|
|
56
|
-
|
|
142
|
+
sessionmigrations/ Session SQL 版本化迁移
|
|
143
|
+
catalogmigrations/ Catalog 投影迁移
|
|
144
|
+
usagemigrations/ Usage 投影迁移
|
|
57
145
|
app/ Next.js 前端(React + SSE 消费)
|
|
58
146
|
contracts/ OpenAPI 3.1 契约
|
|
59
147
|
skills/ 内置 Skill bundle
|
|
60
|
-
docs/ 设计与实施计划
|
|
61
148
|
```
|
|
62
149
|
|
|
63
|
-
##
|
|
150
|
+
## 当前重点
|
|
64
151
|
|
|
65
|
-
|
|
152
|
+
- Agent Flow 的可观测性、恢复语义和实际采用,而不是继续扩张产品表面。
|
|
153
|
+
- Run 的可审计性、审批体验和本地留痕。
|
|
154
|
+
- 本地优先与沙箱边界(bwrap 网络隔离、SandboxNone 的凭据保护)。
|
|
155
|
+
- 生信作为参考工作流维护,不承诺 Warm Kernel、Notebook 或 Publication 工作台。
|
|
66
156
|
|
|
67
157
|
## 开发
|
|
68
158
|
|
|
69
159
|
```bash
|
|
70
160
|
make dev # 启动 ennogate + ennoworker
|
|
71
|
-
make test-go # Go
|
|
161
|
+
make test-go # Go 测试(go test ./...)
|
|
72
162
|
make test # JS 测试
|
|
73
|
-
make lint # ESLint
|
|
163
|
+
make lint # ESLint + design token guard
|
|
74
164
|
make typecheck # tsc --noEmit
|
|
75
165
|
make build # 构建静态前端和本机 Go 产物
|
|
166
|
+
npm run test:e2e # Playwright
|
|
76
167
|
npm run release:prepare # 构建四个平台的发布包并检查 tarball
|
|
77
|
-
npm run smoke:installed
|
|
168
|
+
npm run smoke:installed # 安装临时 tarball 并验证双进程生命周期
|
|
78
169
|
```
|
|
170
|
+
|
|
171
|
+
CI 会在 Go 测试矩阵里额外运行 `-race`。
|
package/bin/ennote.js
CHANGED
|
@@ -16,6 +16,8 @@ const DEFAULT_PORT = "45131";
|
|
|
16
16
|
const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
17
17
|
const START_TIMEOUT_MS = 45_000;
|
|
18
18
|
const STOP_TIMEOUT_MS = 15_000;
|
|
19
|
+
const PACK_INSPECT_TIMEOUT_MS = 15_000;
|
|
20
|
+
const PACK_REQUEST_TIMEOUT_MS = 15_000;
|
|
19
21
|
const GITHUB_RELEASES_BASE = "https://github.com/seqyuan/ennote/releases/download";
|
|
20
22
|
|
|
21
23
|
function platformSuffix(platform = os.platform(), arch = os.arch()) {
|
|
@@ -54,6 +56,18 @@ function statePaths(home) {
|
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// workerStatePath is the owner-only handshake file ennoworker writes and
|
|
60
|
+
// ennogate supervises (see internal/runtimeinfo.WorkerState).
|
|
61
|
+
function workerStatePath(home) {
|
|
62
|
+
return path.join(home, "runtime", "worker-state.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function usageError(message) {
|
|
66
|
+
const error = new Error(message);
|
|
67
|
+
error.exitCode = 2;
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
function validateRuntime(paths) {
|
|
58
72
|
for (const [name, file] of [["ennogate", paths.gate], ["ennoworker", paths.worker]]) {
|
|
59
73
|
if (!fs.existsSync(file)) throw new Error(`Missing ${name} binary: ${file}`);
|
|
@@ -302,6 +316,9 @@ async function startService(options) {
|
|
|
302
316
|
ENNOTE_HOME: home,
|
|
303
317
|
ENNOTE_STATIC_DIR: runtime.staticDir,
|
|
304
318
|
ENNOTE_WORKER_PATH: runtime.worker,
|
|
319
|
+
...(fs.existsSync(path.join(packageDir, "skills"))
|
|
320
|
+
? { ENNOTE_BUILTIN_SKILLS_DIR: path.join(packageDir, "skills") }
|
|
321
|
+
: {}),
|
|
305
322
|
PORT: String(port),
|
|
306
323
|
ENNOTE_HOSTNAME: hostname,
|
|
307
324
|
},
|
|
@@ -362,6 +379,373 @@ async function serviceStatus(home) {
|
|
|
362
379
|
return { running: healthy, healthy, pid: state.pid, url: state.url, port: state.port };
|
|
363
380
|
}
|
|
364
381
|
|
|
382
|
+
function isLoopbackHostname(hostname) {
|
|
383
|
+
const name = String(hostname ?? "").replace(/^\[|\]$/g, "").toLowerCase();
|
|
384
|
+
if (name === "localhost" || name === "::1") return true;
|
|
385
|
+
const match = name.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
386
|
+
return Boolean(match) && match.slice(1).every(part => Number(part) <= 255);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// loadWorkerEndpoint reads the Worker handshake written by ennoworker and
|
|
390
|
+
// refuses anything that is not a live loopback http Worker: ennote pack inspect
|
|
391
|
+
// talks to the real Worker instead of re-implementing pack parsing in Node.
|
|
392
|
+
function loadWorkerEndpoint(home) {
|
|
393
|
+
const file = workerStatePath(home);
|
|
394
|
+
if (!fs.existsSync(file)) {
|
|
395
|
+
throw new Error(`Ennote Worker is not running (${file} is missing). Start it with: ennote start`);
|
|
396
|
+
}
|
|
397
|
+
let state;
|
|
398
|
+
try {
|
|
399
|
+
state = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
400
|
+
} catch {
|
|
401
|
+
throw new Error(`Ennote Worker state ${file} is unreadable. Restart it with: ennote restart`);
|
|
402
|
+
}
|
|
403
|
+
let parsed;
|
|
404
|
+
try {
|
|
405
|
+
parsed = new URL(String(state?.url ?? ""));
|
|
406
|
+
} catch {
|
|
407
|
+
throw new Error(`Ennote Worker state ${file} has an invalid url: ${state?.url ?? ""}`);
|
|
408
|
+
}
|
|
409
|
+
if (parsed.protocol !== "http:") {
|
|
410
|
+
throw new Error(`refusing Worker url ${state.url}: expected http:`);
|
|
411
|
+
}
|
|
412
|
+
if (!isLoopbackHostname(parsed.hostname)) {
|
|
413
|
+
throw new Error(`refusing non-loopback Worker url ${state.url}`);
|
|
414
|
+
}
|
|
415
|
+
if (!isPidAlive(state.pid)) {
|
|
416
|
+
throw new Error(`Ennote Worker pid ${state.pid} is not running. Start it with: ennote start`);
|
|
417
|
+
}
|
|
418
|
+
if (typeof state.bootstrapToken !== "string" || state.bootstrapToken === "") {
|
|
419
|
+
throw new Error(`Ennote Worker state ${file} has no bootstrap token. Restart it with: ennote restart`);
|
|
420
|
+
}
|
|
421
|
+
return { url: parsed.origin, token: state.bootstrapToken, pid: state.pid, instanceId: state.instanceId };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function postJSON(url, body, headers, timeoutMs) {
|
|
425
|
+
return httpRequest("POST", url, body, headers, timeoutMs);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// httpRequest is the one place the CLI talks to the Worker. GET/DELETE carry no
|
|
429
|
+
// body; POST serializes it. The Worker url is already pinned to loopback http by
|
|
430
|
+
// loadWorkerEndpoint, so no transport negotiation happens here.
|
|
431
|
+
function httpRequest(method, url, body, headers, timeoutMs) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body), "utf8");
|
|
434
|
+
const request = http.request(url, {
|
|
435
|
+
method,
|
|
436
|
+
headers: payload
|
|
437
|
+
? { "Content-Type": "application/json", "Content-Length": payload.length, ...headers }
|
|
438
|
+
: headers,
|
|
439
|
+
}, (response) => {
|
|
440
|
+
const chunks = [];
|
|
441
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
442
|
+
response.on("end", () => resolve({
|
|
443
|
+
statusCode: response.statusCode ?? 0,
|
|
444
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
445
|
+
}));
|
|
446
|
+
});
|
|
447
|
+
request.once("error", reject);
|
|
448
|
+
request.setTimeout(timeoutMs, () => {
|
|
449
|
+
request.destroy(new Error(`Worker request timed out after ${timeoutMs / 1000}s`));
|
|
450
|
+
});
|
|
451
|
+
request.end(payload ?? undefined);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// decodeWorkerResponse unwraps the Worker error envelope and the data envelope.
|
|
456
|
+
// A 204 (pack remove) has no data and yields null.
|
|
457
|
+
function decodeWorkerResponse(response, action) {
|
|
458
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
459
|
+
let message = `Worker returned HTTP ${response.statusCode}`;
|
|
460
|
+
let code;
|
|
461
|
+
try {
|
|
462
|
+
const parsed = JSON.parse(response.body);
|
|
463
|
+
code = parsed?.error?.code;
|
|
464
|
+
if (parsed?.error?.message) message = parsed.error.message;
|
|
465
|
+
} catch {
|
|
466
|
+
// A non-JSON error body keeps the status-only message.
|
|
467
|
+
}
|
|
468
|
+
const error = new Error(code ? `${message} (${code})` : message);
|
|
469
|
+
error.code = code;
|
|
470
|
+
error.statusCode = response.statusCode;
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
if (response.statusCode === 204 || response.body.trim() === "") return null;
|
|
474
|
+
let parsed;
|
|
475
|
+
try {
|
|
476
|
+
parsed = JSON.parse(response.body);
|
|
477
|
+
} catch {
|
|
478
|
+
throw new Error(`Worker returned an unreadable ${action} response`);
|
|
479
|
+
}
|
|
480
|
+
if (!parsed || typeof parsed !== "object" || parsed.data == null) {
|
|
481
|
+
throw new Error(`Worker returned an unexpected ${action} response`);
|
|
482
|
+
}
|
|
483
|
+
return parsed.data;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// requestJSON performs one authenticated Worker call. Every pack command except
|
|
487
|
+
// install is GET/DELETE; install reuses postJSON so its body and headers stay in
|
|
488
|
+
// one place.
|
|
489
|
+
async function requestJSON(endpoint, method, pathname, options = {}) {
|
|
490
|
+
if (method === "POST") {
|
|
491
|
+
const response = await postJSON(
|
|
492
|
+
`${endpoint.url}${pathname}`,
|
|
493
|
+
options.body,
|
|
494
|
+
{ Authorization: `Bearer ${endpoint.token}` },
|
|
495
|
+
options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
|
|
496
|
+
);
|
|
497
|
+
return decodeWorkerResponse(response, options.action ?? "pack");
|
|
498
|
+
}
|
|
499
|
+
const response = await httpRequest(
|
|
500
|
+
method,
|
|
501
|
+
`${endpoint.url}${pathname}`,
|
|
502
|
+
undefined,
|
|
503
|
+
{ Authorization: `Bearer ${endpoint.token}` },
|
|
504
|
+
options.timeoutMs ?? PACK_REQUEST_TIMEOUT_MS,
|
|
505
|
+
);
|
|
506
|
+
return decodeWorkerResponse(response, options.action ?? "pack");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function requestPackInspect(endpoint, target, options = {}) {
|
|
510
|
+
const body = {
|
|
511
|
+
source: { kind: "path", path: target },
|
|
512
|
+
includeDocuments: Boolean(options.documents),
|
|
513
|
+
};
|
|
514
|
+
if (options.expectedDigest) body.expectedDigest = options.expectedDigest;
|
|
515
|
+
const response = await postJSON(
|
|
516
|
+
`${endpoint.url}/v1/packs/inspect`,
|
|
517
|
+
body,
|
|
518
|
+
{ Authorization: `Bearer ${endpoint.token}` },
|
|
519
|
+
options.timeoutMs ?? PACK_INSPECT_TIMEOUT_MS,
|
|
520
|
+
);
|
|
521
|
+
return decodeWorkerResponse(response, "inspect");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function formatPackSummary(data) {
|
|
525
|
+
const pack = data.pack ?? {};
|
|
526
|
+
const container = data.container ?? {};
|
|
527
|
+
const signature = data.signature ?? {};
|
|
528
|
+
const lines = [];
|
|
529
|
+
lines.push(`${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
|
|
530
|
+
if (pack.description) lines.push(pack.description);
|
|
531
|
+
lines.push(`Source: ${data.source?.path ?? "?"} (${container.kind ?? "?"}, ${container.bytes ?? 0} bytes)`);
|
|
532
|
+
lines.push(signature.present
|
|
533
|
+
? `Signature: ${signature.algorithm} at ${signature.path} — UNVERIFIED (inspect never verifies signatures)`
|
|
534
|
+
: "Signature: Unsigned");
|
|
535
|
+
lines.push(`Manifest: ${data.manifestDigest ?? "?"}`);
|
|
536
|
+
lines.push(`Pack: ${data.packDigest ?? "?"}`);
|
|
537
|
+
|
|
538
|
+
const flows = data.flows ?? [];
|
|
539
|
+
lines.push(`Flows (${flows.length})`);
|
|
540
|
+
for (const flow of flows) {
|
|
541
|
+
lines.push(` - ${flow.id} [${flow.format}] ${flow.taskCount} task(s), ${flow.checkTasks} check, ${flow.fanOutTasks} fan-out, ${flow.maxTotalTokens} tokens`);
|
|
542
|
+
}
|
|
543
|
+
const roles = data.roles ?? [];
|
|
544
|
+
lines.push(`Roles (${roles.length})`);
|
|
545
|
+
for (const role of roles) {
|
|
546
|
+
lines.push(` - ${role.handle} (${role.scope}) ${role.authority}, tools: ${(role.allowedTools ?? []).join(", ") || "none"}`);
|
|
547
|
+
}
|
|
548
|
+
const skills = data.skills ?? [];
|
|
549
|
+
lines.push(`Skills (${skills.length})`);
|
|
550
|
+
for (const skill of skills) {
|
|
551
|
+
lines.push(` - ${skill.kind === "external" ? skill.ref : skill.id} (${skill.kind})`);
|
|
552
|
+
}
|
|
553
|
+
// External MCP profile refs are reported but never resolved: the summary says
|
|
554
|
+
// so explicitly so a reviewer does not read the line as an installed profile.
|
|
555
|
+
const mcp = data.mcp ?? [];
|
|
556
|
+
if (mcp.length > 0) {
|
|
557
|
+
lines.push(`MCP (${mcp.length})`);
|
|
558
|
+
for (const profile of mcp) {
|
|
559
|
+
lines.push(` - ${profile.ref} (external ref, not resolved or executed)`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const envelope = data.capabilityEnvelope ?? {};
|
|
563
|
+
lines.push(`Checks: ${(envelope.checkCommands ?? []).join(", ") || "none"}`);
|
|
564
|
+
lines.push(`Writes: ${(envelope.writes ?? []).join(", ") || "none"}`);
|
|
565
|
+
lines.push(`Files: ${data.filesTotal ?? 0}${data.filesTruncated ? " (truncated)" : ""}`);
|
|
566
|
+
|
|
567
|
+
const warnings = data.warnings ?? [];
|
|
568
|
+
lines.push(`Warnings (${warnings.length})`);
|
|
569
|
+
for (const warning of warnings) lines.push(` - ${warning.code} (${warning.severity}): ${warning.message}`);
|
|
570
|
+
return lines.join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function packCommand(home, positionals, values) {
|
|
574
|
+
const action = positionals[1];
|
|
575
|
+
const target = positionals[2];
|
|
576
|
+
if (action === "inspect") {
|
|
577
|
+
if (!target) {
|
|
578
|
+
throw usageError("Usage: ennote pack inspect <file|dir> [--json] [--documents]");
|
|
579
|
+
}
|
|
580
|
+
const endpoint = loadWorkerEndpoint(home);
|
|
581
|
+
const data = await requestPackInspect(endpoint, path.resolve(target), {
|
|
582
|
+
documents: values.documents,
|
|
583
|
+
});
|
|
584
|
+
if (values.json) console.log(JSON.stringify(data));
|
|
585
|
+
else console.log(formatPackSummary(data));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (action === "install") {
|
|
589
|
+
if (!target) {
|
|
590
|
+
throw usageError("Usage: ennote pack install <file|dir> [--dry-run] [--json] [--as-flow old=new] [--as-role old=new]");
|
|
591
|
+
}
|
|
592
|
+
// Renames are validated before the Worker is contacted, so a typo is a
|
|
593
|
+
// usage error instead of a refused install.
|
|
594
|
+
const renames = {
|
|
595
|
+
flows: parsePackRenames(values["as-flow"], "--as-flow"),
|
|
596
|
+
roles: parsePackRenames(values["as-role"], "--as-role"),
|
|
597
|
+
};
|
|
598
|
+
const endpoint = loadWorkerEndpoint(home);
|
|
599
|
+
const plan = await requestPackInstall(endpoint, path.resolve(target), {
|
|
600
|
+
dryRun: Boolean(values["dry-run"]),
|
|
601
|
+
renames,
|
|
602
|
+
});
|
|
603
|
+
if (values.json) console.log(JSON.stringify(plan));
|
|
604
|
+
else console.log(formatPackInstallPlan(plan, { dryRun: Boolean(values["dry-run"]) }));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (action === "list") {
|
|
608
|
+
const endpoint = loadWorkerEndpoint(home);
|
|
609
|
+
const summaries = await requestJSON(endpoint, "GET", "/v1/packs", { action: "pack list" });
|
|
610
|
+
if (values.json) console.log(JSON.stringify(summaries ?? []));
|
|
611
|
+
else console.log(formatPackList(summaries ?? []));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (action === "remove") {
|
|
615
|
+
if (!target) {
|
|
616
|
+
throw usageError("Usage: ennote pack remove <publisher>/<name> [--force] [--json]");
|
|
617
|
+
}
|
|
618
|
+
const packId = parsePackID(target);
|
|
619
|
+
const endpoint = loadWorkerEndpoint(home);
|
|
620
|
+
const [publisher, name] = packId.split("/");
|
|
621
|
+
const query = values.force ? "?force=1" : "";
|
|
622
|
+
const pathname = `/v1/packs/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}${query}`;
|
|
623
|
+
// A 204 carries no body; the CLI still reports the same shape as --json so
|
|
624
|
+
// scripts do not have to special-case the empty response.
|
|
625
|
+
const data = (await requestJSON(endpoint, "DELETE", pathname, { action: "pack remove" })) ?? {
|
|
626
|
+
packId,
|
|
627
|
+
removed: true,
|
|
628
|
+
};
|
|
629
|
+
if (values.json) console.log(JSON.stringify(data));
|
|
630
|
+
else console.log(`Removed ${data.packId ?? packId}${values.force ? " (forced)" : ""}`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
throw usageError(
|
|
634
|
+
`Unknown pack command: ${action ?? ""}\nUsage: ennote pack <inspect|install|list|remove> ...`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// parsePackRenames turns repeated --as-flow/--as-role old=new flags into the
|
|
639
|
+
// renames object. A rename only changes the installed store id.
|
|
640
|
+
function parsePackRenames(entries, flag) {
|
|
641
|
+
const renames = {};
|
|
642
|
+
for (const entry of entries ?? []) {
|
|
643
|
+
const text = String(entry);
|
|
644
|
+
const separator = text.indexOf("=");
|
|
645
|
+
if (separator <= 0 || separator === text.length - 1) {
|
|
646
|
+
throw usageError(`${flag} expects old=new, got ${text}`);
|
|
647
|
+
}
|
|
648
|
+
const from = text.slice(0, separator).trim();
|
|
649
|
+
const to = text.slice(separator + 1).trim();
|
|
650
|
+
if (from === "" || to === "") throw usageError(`${flag} expects old=new, got ${text}`);
|
|
651
|
+
if (Object.prototype.hasOwnProperty.call(renames, from) && renames[from] !== to) {
|
|
652
|
+
throw usageError(`${flag} maps ${from} twice (${renames[from]} and ${to})`);
|
|
653
|
+
}
|
|
654
|
+
renames[from] = to;
|
|
655
|
+
}
|
|
656
|
+
return renames;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// parsePackID validates <publisher>/<name> before it goes on the wire: a bad id
|
|
660
|
+
// is a usage error, not a Worker 400/404.
|
|
661
|
+
function parsePackID(value) {
|
|
662
|
+
const segments = String(value).split("/");
|
|
663
|
+
if (segments.length !== 2 || segments.some(segment => segment === "" || segment === "." || segment === "..")) {
|
|
664
|
+
throw usageError(`pack id must be <publisher>/<name>, got ${value}`);
|
|
665
|
+
}
|
|
666
|
+
return segments.join("/");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function requestPackInstall(endpoint, target, options = {}) {
|
|
670
|
+
const body = { source: { kind: "path", path: target }, dryRun: Boolean(options.dryRun) };
|
|
671
|
+
const renames = options.renames ?? {};
|
|
672
|
+
const encoded = {};
|
|
673
|
+
if (Object.keys(renames.flows ?? {}).length > 0) encoded.flows = renames.flows;
|
|
674
|
+
if (Object.keys(renames.roles ?? {}).length > 0) encoded.roles = renames.roles;
|
|
675
|
+
if (Object.keys(encoded).length > 0) body.renames = encoded;
|
|
676
|
+
return requestJSON(endpoint, "POST", "/v1/packs/install", { body, action: "pack install" });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function formatPackActionCounts(actions) {
|
|
680
|
+
const counts = new Map();
|
|
681
|
+
for (const action of actions) {
|
|
682
|
+
counts.set(action.action, (counts.get(action.action) ?? 0) + 1);
|
|
683
|
+
}
|
|
684
|
+
return [...counts.entries()]
|
|
685
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
686
|
+
.map(([name, count]) => `${count} ${name}`)
|
|
687
|
+
.join(", ");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function formatPackInstallPlan(plan, options = {}) {
|
|
691
|
+
const pack = plan?.pack ?? {};
|
|
692
|
+
const lines = [];
|
|
693
|
+
const headline = plan?.reinstall === "noop"
|
|
694
|
+
? "Already installed"
|
|
695
|
+
: options.dryRun ? "Dry run (nothing written)" : "Installed";
|
|
696
|
+
lines.push(`${headline}: ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"})`);
|
|
697
|
+
lines.push(`Manifest: ${plan?.manifestDigest ?? "?"}`);
|
|
698
|
+
lines.push(`Pack: ${plan?.packDigest ?? "?"}`);
|
|
699
|
+
|
|
700
|
+
const actions = plan?.actions ?? [];
|
|
701
|
+
lines.push(`Actions (${actions.length}): ${formatPackActionCounts(actions) || "none"}`);
|
|
702
|
+
for (const action of actions) {
|
|
703
|
+
const scope = action.scope ? ` (${action.scope})` : "";
|
|
704
|
+
lines.push(` - ${action.action} ${action.kind} ${action.storeId}${scope}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const state = plan?.state;
|
|
708
|
+
if (state) {
|
|
709
|
+
lines.push(`State: ${state.installState ?? "?"}; flows ${state.flows?.length ?? 0}, roles ${state.roles?.length ?? 0}, skills ${state.skills?.length ?? 0}`);
|
|
710
|
+
const drafts = (state.flows ?? []).filter(flow => !flow.publishedRevision);
|
|
711
|
+
if (drafts.length > 0) {
|
|
712
|
+
lines.push(`Disabled: ${drafts.map(flow => flow.storeId).join(", ")} (draft Graph, no published revision — publish it to enable runs)`);
|
|
713
|
+
}
|
|
714
|
+
if (state.source?.path) lines.push(`Source: ${state.source.path}`);
|
|
715
|
+
const external = state.externalRefs ?? {};
|
|
716
|
+
const externalSkills = external.skills ?? [];
|
|
717
|
+
const externalMcp = external.mcp ?? [];
|
|
718
|
+
if (externalSkills.length > 0 || externalMcp.length > 0) {
|
|
719
|
+
lines.push(`Recorded (not installed): skills ${externalSkills.join(", ") || "none"}; mcp ${externalMcp.join(", ") || "none"}`);
|
|
720
|
+
}
|
|
721
|
+
} else if (options.dryRun) {
|
|
722
|
+
lines.push("State: not written (dry run)");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const blockers = plan?.blockers ?? [];
|
|
726
|
+
lines.push(`Blockers (${blockers.length})`);
|
|
727
|
+
for (const blocker of blockers) {
|
|
728
|
+
lines.push(` - ${blocker.code} (${blocker.severity ?? "error"}): ${blocker.message}`);
|
|
729
|
+
}
|
|
730
|
+
const warnings = plan?.warnings ?? [];
|
|
731
|
+
lines.push(`Warnings (${warnings.length})`);
|
|
732
|
+
for (const warning of warnings) {
|
|
733
|
+
lines.push(` - ${warning.code} (${warning.severity ?? "warning"}): ${warning.message}`);
|
|
734
|
+
}
|
|
735
|
+
return lines.join("\n");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function formatPackList(summaries) {
|
|
739
|
+
if (summaries.length === 0) return "No Agent Packs installed.";
|
|
740
|
+
const lines = [`Installed Agent Packs (${summaries.length})`];
|
|
741
|
+
for (const summary of summaries) {
|
|
742
|
+
const pack = summary.pack ?? {};
|
|
743
|
+
lines.push(` - ${pack.name ?? "(unnamed pack)"} (${pack.id ?? "?"}@${pack.version ?? "?"}) ${summary.installState ?? "?"}`);
|
|
744
|
+
lines.push(` flows ${summary.flowCount ?? 0}, roles ${summary.roleCount ?? 0}, skills ${summary.skillCount ?? 0}; installed ${summary.installedAt ?? "?"}`);
|
|
745
|
+
}
|
|
746
|
+
return lines.join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
365
749
|
async function setPassword(home, reset) {
|
|
366
750
|
const authFile = statePaths(home).auth;
|
|
367
751
|
ensureDirectory(path.dirname(authFile));
|
|
@@ -397,11 +781,20 @@ Commands:
|
|
|
397
781
|
logs Show recent service logs
|
|
398
782
|
passwd Set the local password
|
|
399
783
|
passwd --reset Remove the local password and require setup
|
|
784
|
+
pack inspect Inspect an Agent Pack (.enpack or directory) without installing
|
|
785
|
+
pack install Install an Agent Pack from a .enpack file or directory
|
|
786
|
+
pack list List installed Agent Packs
|
|
787
|
+
pack remove Uninstall an Agent Pack (<publisher>/<name>)
|
|
400
788
|
|
|
401
789
|
Options:
|
|
402
790
|
--port, -p Browser-facing port (default ${DEFAULT_PORT})
|
|
403
791
|
--hostname, -H Bind address (default ${DEFAULT_HOSTNAME})
|
|
404
|
-
--json Machine-readable output
|
|
792
|
+
--json Machine-readable output (prints the Worker data payload)
|
|
793
|
+
--documents pack inspect: include flow, role, and skill source text
|
|
794
|
+
--dry-run pack install: plan only and write nothing
|
|
795
|
+
--as-flow pack install: old=new rename of an installed Graph id
|
|
796
|
+
--as-role pack install: old=new rename of an installed Role handle
|
|
797
|
+
--force pack remove: override local edits and shared global Roles
|
|
405
798
|
--version, -v Show version
|
|
406
799
|
--help, -h Show this help`);
|
|
407
800
|
}
|
|
@@ -415,6 +808,11 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
415
808
|
port: { type: "string", short: "p", default: DEFAULT_PORT },
|
|
416
809
|
hostname: { type: "string", short: "H", default: DEFAULT_HOSTNAME },
|
|
417
810
|
json: { type: "boolean", default: false },
|
|
811
|
+
documents: { type: "boolean", default: false },
|
|
812
|
+
"dry-run": { type: "boolean", default: false },
|
|
813
|
+
"as-flow": { type: "string", multiple: true },
|
|
814
|
+
"as-role": { type: "string", multiple: true },
|
|
815
|
+
force: { type: "boolean", default: false },
|
|
418
816
|
reset: { type: "boolean", default: false },
|
|
419
817
|
version: { type: "boolean", short: "v", default: false },
|
|
420
818
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -451,6 +849,11 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
451
849
|
case "passwd":
|
|
452
850
|
result = await setPassword(home, values.reset);
|
|
453
851
|
break;
|
|
852
|
+
case "pack":
|
|
853
|
+
// Every pack command talks to the running Worker instead of re-implementing
|
|
854
|
+
// manifest parsing, installation, or store writes in Node.
|
|
855
|
+
await packCommand(home, positionals, values);
|
|
856
|
+
return;
|
|
454
857
|
default:
|
|
455
858
|
throw new Error(`Unknown command: ${command}`);
|
|
456
859
|
}
|
|
@@ -466,6 +869,7 @@ module.exports = {
|
|
|
466
869
|
runtimeBinDir,
|
|
467
870
|
runtimePaths,
|
|
468
871
|
statePaths,
|
|
872
|
+
workerStatePath,
|
|
469
873
|
serviceURL,
|
|
470
874
|
validateRuntime,
|
|
471
875
|
healthCheck,
|
|
@@ -478,12 +882,23 @@ module.exports = {
|
|
|
478
882
|
stopService,
|
|
479
883
|
serviceStatus,
|
|
480
884
|
setPassword,
|
|
885
|
+
isLoopbackHostname,
|
|
886
|
+
loadWorkerEndpoint,
|
|
887
|
+
requestPackInspect,
|
|
888
|
+
formatPackSummary,
|
|
889
|
+
requestJSON,
|
|
890
|
+
requestPackInstall,
|
|
891
|
+
parsePackRenames,
|
|
892
|
+
parsePackID,
|
|
893
|
+
formatPackInstallPlan,
|
|
894
|
+
formatPackList,
|
|
895
|
+
packCommand,
|
|
481
896
|
main,
|
|
482
897
|
};
|
|
483
898
|
|
|
484
899
|
if (require.main === module) {
|
|
485
900
|
main().catch((error) => {
|
|
486
901
|
console.error(error instanceof Error ? error.message : String(error));
|
|
487
|
-
process.exitCode = 1;
|
|
902
|
+
process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
488
903
|
});
|
|
489
904
|
}
|