cli-swarm 7.0.19 → 7.0.28
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 +16 -1
- package/broker.mjs +301 -0
- package/cli.mjs +54 -12
- package/installer.mjs +57 -154
- package/package.json +8 -3
- package/skill/SKILL.md +25 -18
- package/skill/references/autocoord.md +33 -0
- package/skill/skill.json +2 -2
- package/swarm-coordinator-fs.mjs +275 -0
- package/swarm-coordinator-model.mjs +272 -0
- package/swarm-coordinator.mjs +500 -0
package/package.json
CHANGED
|
@@ -2,18 +2,23 @@
|
|
|
2
2
|
"bin": {
|
|
3
3
|
"cli-swarm": "./cli.mjs"
|
|
4
4
|
},
|
|
5
|
-
"description": "Swarm skill installer for CLI.Tax: orchestrate N sub-agents with
|
|
5
|
+
"description": "Swarm skill installer for CLI.Tax: orchestrate N sub-agents with persistent AutoCoord locks and dependency waits.",
|
|
6
6
|
"files": [
|
|
7
7
|
"cli.mjs",
|
|
8
8
|
"installer.mjs",
|
|
9
|
+
"broker.mjs",
|
|
9
10
|
"README.md",
|
|
10
11
|
"skill/SKILL.md",
|
|
11
12
|
"skill/skill.json",
|
|
13
|
+
"swarm-coordinator-fs.mjs",
|
|
14
|
+
"swarm-coordinator-model.mjs",
|
|
15
|
+
"swarm-coordinator.mjs",
|
|
12
16
|
"skill/references/org-chart.md",
|
|
13
17
|
"skill/references/task-lifecycle.md",
|
|
14
18
|
"skill/references/traffic-light.md",
|
|
15
19
|
"skill/references/ops-heartbeat.md",
|
|
16
|
-
"skill/references/security-guard.md"
|
|
20
|
+
"skill/references/security-guard.md",
|
|
21
|
+
"skill/references/autocoord.md"
|
|
17
22
|
],
|
|
18
23
|
"license": "UNLICENSED",
|
|
19
24
|
"name": "cli-swarm",
|
|
@@ -22,5 +27,5 @@
|
|
|
22
27
|
"url": "https://github.com/88208555/swarm-clitax.git"
|
|
23
28
|
},
|
|
24
29
|
"type": "module",
|
|
25
|
-
"version": "7.0.
|
|
30
|
+
"version": "7.0.28"
|
|
26
31
|
}
|
package/skill/SKILL.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: swarm
|
|
3
|
-
description: '通过智能体大脑调度创建 N
|
|
3
|
+
description: '通过智能体大脑调度创建 N 个子智能体,用企业级组织架构实现派单、认领、回传、红绿灯和进度汇报;固定运维、安全守卫与 AutoCoord 协调智能体负责心跳回收、注入检测、持久任务卡、冲突扫描、签名锁、基线握手、依赖等待、超时升级和死锁打断。Orchestrate N sub-agents with org-chart dispatch, claims, reports, traffic lights, Ops, Security Guard, and persistent AutoCoord task cards, signed locks, baseline handshakes, dependency waits, timeout escalation, and deadlock interruption. Оркестрирует N субагентов с оргструктурой, диспетчеризацией, отчётами, эксплуатацией, защитой и постоянным AutoCoord: карточки задач, подписанные блокировки, ожидания зависимостей, тайм-ауты и разрыв взаимоблокировок.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# swarm
|
|
7
7
|
|
|
8
|
-
Package version: v7.0.
|
|
8
|
+
Package version: v7.0.28
|
|
9
9
|
|
|
10
10
|
把「项目需求」编排为一支可观测、可自治、可安全运转的智能体蜂群。
|
|
11
11
|
|
|
@@ -19,7 +19,7 @@ Request schema: `swarm.skill.request/1.0`
|
|
|
19
19
|
老板(任何 IDE / DSH):"我要并行处理 12 个模块的迁移"
|
|
20
20
|
↓
|
|
21
21
|
1. 组织架构(org-chart)—— 按企业级层级生成协作规则:
|
|
22
|
-
决策层(老板/主智能体)→
|
|
22
|
+
决策层(老板/主智能体)→ 管理层(调度/运维/安全守卫/协调器)→ 执行层(N 个子智能体)
|
|
23
23
|
↓
|
|
24
24
|
2. 任务编排(dispatch)—— 读取项目 JSON,拆解为任务包:
|
|
25
25
|
派单(dispatch)→ 认领(claim)→ 执行 → 回传(report)→ 决策层验收(accept)
|
|
@@ -34,7 +34,10 @@ Request schema: `swarm.skill.request/1.0`
|
|
|
34
34
|
5. 安全守卫(security-guard)—— 固定安全智能体:
|
|
35
35
|
异常行为警报 + 恶意信息注入检测(提示词注入/危险指令/越权请求)
|
|
36
36
|
↓
|
|
37
|
-
6.
|
|
37
|
+
6. 自动协调(coordinator)—— 固定协调智能体:
|
|
38
|
+
任务卡 → 冲突扫描 → 文件/构建锁 → 基线握手 → 依赖等待/唤醒 → 超时与死锁升级
|
|
39
|
+
↓
|
|
40
|
+
7. 交付 —— 老板得到可观测的蜂群面板 + 全量任务回传 + 安全/运维/协调审计报告
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
**关键**:老板一句话 → 组织架构 → 任务派单 → 红绿灯执行 → 运维自治 + 安全守卫 → 可运转蜂群。全程框架不变,换项目只换 JSON。
|
|
@@ -64,18 +67,19 @@ After `capabilities`, read `officialCatalog`. Default allowlist is official skil
|
|
|
64
67
|
## 核心原则
|
|
65
68
|
|
|
66
69
|
1. **组织即规则**:协作结构 = 企业级组织架构(决策/管理/执行三层),派单、审批、汇报都遵循层级规则。
|
|
67
|
-
2.
|
|
70
|
+
2. **事实分层**:远端纯运行时仍由调用方回传 `tasks`;本地 AutoCoord 的锁、等待、事件和任务卡只认 `.coord/` 台账,禁止依赖对话上下文。
|
|
68
71
|
3. **红绿灯透明**:每个任务/智能体实时红/黄/绿状态,进度与错误持续上报,不隐藏阻塞。
|
|
69
72
|
4. **运维自治**:心跳停止/卡死 = 自动收回 + 派新智能体 + 继承任务续跑,不中断整体。
|
|
70
73
|
5. **安全守卫**:恶意注入、危险指令、越权请求在进入执行前被拦截并触发警报。
|
|
71
74
|
6. **ArchGuard 块级证据**:仅当任务启用了架构合同,worker 每完成一个真实代码块就先执行 checkpoint;report 必须携带 contract digest、ledger entry digest、漂移灯和回滚结果,红灯任务禁止 accept。无合同的存量项目不伪造 checkpoint。
|
|
75
|
+
7. **等待必须声明**:跨任务等待先登记 `dependency-wait`;挂起期间禁止读取,事件到达/任务死亡/超时/依赖成环都必须有明确出口。
|
|
72
76
|
|
|
73
77
|
## 五步实施流程
|
|
74
78
|
|
|
75
79
|
### 1. 组织架构(org-chart)
|
|
76
80
|
生成三层规则:
|
|
77
81
|
- 决策层:老板 / 主智能体(定目标、拆任务、验收)
|
|
78
|
-
-
|
|
82
|
+
- 管理层:调度智能体(派单)+ 运维智能体(心跳/回收/接替)+ 安全守卫(检测/警报)+ 协调器(冲突/锁/等待/唤醒)
|
|
79
83
|
- 执行层:N 个按需创建的子智能体(各自认领任务、执行、回传)
|
|
80
84
|
|
|
81
85
|
### 2. 任务编排(dispatch / claim / report / accept)
|
|
@@ -106,9 +110,9 @@ After `capabilities`, read `officialCatalog`. Default allowlist is official skil
|
|
|
106
110
|
- 异常行为(高频重试/异常输入)触发警报
|
|
107
111
|
- 拦截结果进入审计日志,老板可查看
|
|
108
112
|
|
|
109
|
-
##
|
|
113
|
+
## 状态持久化边界
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
远端运行时是纯函数;本地 `cli-swarm local` 使用仓库 `.coord/` 作为唯一协调事实源。任务卡、锁、队列、事件、等待、裁决与审计由协调器原子写入,聊天只能引用这些记录。调用方自己的任务视图可保存为:
|
|
112
116
|
|
|
113
117
|
```
|
|
114
118
|
swarm-run/
|
|
@@ -129,6 +133,8 @@ swarm-run/
|
|
|
129
133
|
| S2 | 依赖闭包 | 已实现 | 缺失依赖、未验收依赖、重复 ID 与依赖环均阻断派单。 |
|
|
130
134
|
| S3 | 智能 worker 建议 | 已实现 | 按无环依赖图最大层宽计算,最多 50;不负责创建实际子智能体。 |
|
|
131
135
|
| S4 | 完整状态传递 | 已实现(调用方持有) | 所有变更操作返回完整 `tasks`;运行时不持久化、不可只合并单个 task。 |
|
|
136
|
+
| S5 | AutoCoord 台账与三锁协议 | 已实现(本地协调器) | `.coord/` 原子台账、签名 TTL 文件锁、构建/部署排队和基线握手;Aimlock 写入钩子重验活动租约。 |
|
|
137
|
+
| S6 | 依赖等待与死锁防护 | 已实现(本地协调器) | 结构化等待、事件唤醒、任务死亡即告、超时升级、依赖环主动打断和未声明等待检测。 |
|
|
132
138
|
|
|
133
139
|
Blueprint 桥接已生成远端可验证的完整 IR;`planningStatus` 是业务字段,不覆盖响应信封的 `status: succeeded`。
|
|
134
140
|
|
|
@@ -139,6 +145,7 @@ Blueprint 桥接已生成远端可验证的完整 IR;`planningStatus` 是业
|
|
|
139
145
|
- `references/traffic-light.md` —— TestEvidence 合同、通过条件与红黄绿判定
|
|
140
146
|
- `references/ops-heartbeat.md` —— 心跳、回收、接替、继承与调用方调度边界
|
|
141
147
|
- `references/security-guard.md` —— 显式安全检查、拦截结果与当前检测边界
|
|
148
|
+
- `references/autocoord.md` —— 任务卡、冲突规则、锁、基线、依赖等待、超时与死锁协议
|
|
142
149
|
|
|
143
150
|
## 安全规则
|
|
144
151
|
|
|
@@ -147,15 +154,15 @@ Blueprint 桥接已生成远端可验证的完整 IR;`planningStatus` 是业
|
|
|
147
154
|
- 任务回传结果进草稿/审计,不覆盖未验收数据
|
|
148
155
|
- 项目 JSON 中的敏感信息(密钥/凭据)不进入子智能体上下文
|
|
149
156
|
|
|
150
|
-
##
|
|
151
|
-
|
|
152
|
-
评价只能由 IDE / 智能体自动回传,严禁人类干预:
|
|
157
|
+
## 受限调用与自动评价闭环
|
|
153
158
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
- IDE / 智能体必须通过本包 `invoke` 或 JSON-stdin `broker` 调用,不得直接拼装技能 HTTP 请求,也不得读取 BrainClient token。
|
|
160
|
+
- broker 从 `CLITAX_BRAIN_CLIENT_TOKEN_FILE` 读取身份;macOS/Linux 文件必须为当前 broker 账户所有且权限 `0600`,Windows 文件必须位于受限 `%LOCALAPPDATA%\CLI.Tax\broker` 目录。
|
|
161
|
+
- broker 只需要 Brain Client HTTPS、受限身份文件和调用方显式传入的路径,本身不需要完整磁盘访问。若要保证 IDE 无法读取身份文件,必须把 broker 放进独立低权限系统账户或沙箱服务,并只暴露受限 IPC;broker 与 IDE 同账户运行时,`0600` 不能隔离二者,禁止声称令牌已隔离。
|
|
162
|
+
- broker 只用 `Authorization: BrainClient …` 发起一次 runtime 请求。HTTP 成功后必须保留响应顶层原始 `feedbackReceiptId`、`feedbackInvocationId` 和 `feedbackEvaluation.digest`,不得生成、猜测、复用或跨调用转移。
|
|
163
|
+
- Brain Client 服务端必须严格绑定请求/响应的 `requestId` 和 `schemaVersion`,再根据真实状态、验证结果、服务端耗时与 findings 生成并持久化权威评分、评语和摘要。broker 不得生成分数或评语。
|
|
164
|
+
- 同一次 runtime 请求在服务端事务内生成并持久化评价,再返回 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要;broker 只验证已提交回执,不发起第二次评价写入。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评。
|
|
165
|
+
- 缺少凭证或 ID、身份不匹配、摘要不匹配、响应非法以及任何 HTTP 失败都必须显式失败,不得静默、不重试成重复评价。
|
|
166
|
+
- 本地 CLI 不提供手工评分或评语提交命令,人类不得选择技能分数或填写技能评价;日常聊天不属于评价协议。
|
|
160
167
|
|
|
161
|
-
|
|
168
|
+
调用示例:`npx cli-swarm@latest invoke <operation> '<JSON对象>'`。IDE 集成可向 `npx cli-swarm@latest broker` 的 stdin 发送 `{"operation":"capabilities","input":{}}`。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# AutoCoord 协调协议
|
|
2
|
+
|
|
3
|
+
本地入口:`cli-swarm local <operation> <repositoryRoot>`,JSON 从 stdin 传入。先调用 `capabilities`,按返回的 `operationSchemas` 构造请求。
|
|
4
|
+
|
|
5
|
+
## 唯一事实源
|
|
6
|
+
|
|
7
|
+
- `.coord/state.json`:任务卡、锁、队列、等待、事件、消息和真人裁决。
|
|
8
|
+
- `.coord/leases/`:Ed25519 签名短期租约;释放、续期或 TTL 到期后旧租约立即失效。
|
|
9
|
+
- `.coord/audit.jsonl`:全部协调动作的只追加审计。
|
|
10
|
+
- 协调承诺不得只写在对话;上下文被清空后必须从台账恢复。
|
|
11
|
+
|
|
12
|
+
## 任务卡与冲突
|
|
13
|
+
|
|
14
|
+
任务卡必须声明 `taskScope`、`plannedActions`、`deployTarget`、`eta`、`baselineHash` 与 `archConstraints`。协调器检查范围重叠、同部署目标发布竞争、`forbid-scope:<path>` 架构冲突及 `requirement:<key>=<value>` 决策矛盾。零冲突不路由协调消息。
|
|
15
|
+
|
|
16
|
+
## 三锁协议
|
|
17
|
+
|
|
18
|
+
- 文件锁:绑定任务、agent、chain 与路径;Aimlock `guarded-write` 在同一写入临界区重验签名租约和路径范围。
|
|
19
|
+
- 构建/部署锁:同资源排队;申请前必须完成当前基线握手。
|
|
20
|
+
- 基线握手:观察哈希必须等于任务卡基线,否则任务转为等待并返回重取清单。
|
|
21
|
+
- 所有租约 TTL 最长 3600 秒;续期签发新租约,旧租约不再处于活动状态。
|
|
22
|
+
|
|
23
|
+
## 依赖等待
|
|
24
|
+
|
|
25
|
+
等待前调用 `dependency-wait`,明确 `waiter`、`waitFor`、结构化 `event`、`expectedWithinMs`、到达/超时策略和 `refetchPaths`。活动等待会阻断同 chain 的 Aimlock 预算读取。
|
|
26
|
+
|
|
27
|
+
- 事件到达:5 秒目标窗口内路由唤醒包,内容含事件与重取清单。
|
|
28
|
+
- 对方失败或被回收:立即以 `dependency-terminated` 唤醒,不等超时。
|
|
29
|
+
- 超时:按声明处置;`escalate-need-human` 和连续第二次超时生成高风险 Confirm Protocol 请求。
|
|
30
|
+
- 成环:登记时或 `tick` 检出后立即打断全部相关等待,并生成真人裁决请求。
|
|
31
|
+
- 未登记等待:`task-status=waiting` 返回 `undeclaredWait=true` 和补登提示。
|
|
32
|
+
|
|
33
|
+
等待必须由事件到达、终止通知、超时处置或死锁打断结束,不允许无限期静默挂起。
|
package/skill/skill.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Swarm 用智能体大脑调度创建 N 个子智能体,把企业级组织架构写成协作规则,围绕一份项目 JSON
|
|
2
|
+
"description": "Swarm 用智能体大脑调度创建 N 个子智能体,把企业级组织架构写成协作规则,围绕一份项目 JSON 完成任务派单、认领与回传。每个任务带红绿灯状态,进度与错误必须显式汇报,禁止把卡住写成仍在进行。固定运维智能体负责心跳检测、回收超时或卡死的智能体,并派新智能体继承未完成任务;固定安全守卫负责异常警报与恶意注入检测;固定协调智能体通过 .coord 台账执行冲突扫描、文件与构建锁、基线握手、依赖等待、超时升级和死锁打断。协调状态禁止依赖对话上下文,等待必须声明并在事件到达、任务终止、超时或死锁处置中结束。调用方先通过对话确认组织层级、角色权限、任务粒度和汇报节奏,再进入编排。平台不在技能描述中展示外部网址;密钥与运行时接入若需要,一律在对话里向用户提问后由用户自行填写。编排过程可观测、可接管、可追责,失败必须落到明确责任人与可恢复动作,而不是静默消失。派单不得跳过认领,回传必须带证据;心跳超时即回收,禁止幽灵智能体占用任务。安全守卫发现注入或越权时立即阻断并告警,不得降级为继续执行。任务继承必须携带原上下文。本技能面向真实交付:每一步都有输入、规则与失败面,禁止把加载中、超时或未知状态当成空成功。用户可见说明只讲能力与对话配置方式,不出现外链。调用前必须先走 capabilities,再按 nextStep 前进;必填项未回答不得进入下一操作。日志只保存必要元数据,密钥不得写入公开页面。",
|
|
3
3
|
"displayName": "Swarm",
|
|
4
4
|
"endpoint": "https://cli.tax/zj7fTPVh4p",
|
|
5
5
|
"method": "POST",
|
|
6
6
|
"name": "swarm",
|
|
7
7
|
"schemaVersion": "swarm.skill.request/1.0",
|
|
8
8
|
"type": "Skill",
|
|
9
|
-
"version": "v7.0.
|
|
9
|
+
"version": "v7.0.28"
|
|
10
10
|
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
appendFile,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
unlink,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises'
|
|
13
|
+
import { dirname, isAbsolute, posix, resolve, sep } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const COORD_ROOT = '.coord'
|
|
16
|
+
const COORD_SCHEMA = 'swarm.coord-state/1.0'
|
|
17
|
+
const LEASE_SCHEMA = 'swarm.coord-lease/1.0'
|
|
18
|
+
const LOCK_WAIT_MS = 25
|
|
19
|
+
const LOCK_ATTEMPTS = 200
|
|
20
|
+
const STALE_STATE_LOCK_MS = 30_000
|
|
21
|
+
|
|
22
|
+
function coordinatorError(code, message) {
|
|
23
|
+
const error = new Error(message)
|
|
24
|
+
error.code = code
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function identifier(value, label) {
|
|
29
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
|
30
|
+
coordinatorError('SWARM_COORD_IDENTIFIER_INVALID', `${label} is invalid`)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function relativePath(value, label = 'path') {
|
|
36
|
+
if (typeof value !== 'string' || !value.trim() || isAbsolute(value) || value.includes('\\')) {
|
|
37
|
+
coordinatorError('SWARM_COORD_PATH_INVALID', `${label} must be a repository-relative POSIX path`)
|
|
38
|
+
}
|
|
39
|
+
const normalized = posix.normalize(value.trim()).replace(/^\.\//, '').replace(/\/$/, '')
|
|
40
|
+
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
|
41
|
+
coordinatorError('SWARM_COORD_PATH_INVALID', `${label} escapes the repository`)
|
|
42
|
+
}
|
|
43
|
+
return normalized
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function exists(path) {
|
|
47
|
+
try {
|
|
48
|
+
await lstat(path)
|
|
49
|
+
return true
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof Error && error.code === 'ENOENT') return false
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function repositoryRoot(value) {
|
|
57
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
58
|
+
coordinatorError('SWARM_COORD_ROOT_REQUIRED', 'repositoryRoot is required')
|
|
59
|
+
}
|
|
60
|
+
const root = await realpath(resolve(value))
|
|
61
|
+
const status = await lstat(root)
|
|
62
|
+
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
63
|
+
coordinatorError('SWARM_COORD_ROOT_INVALID', 'repositoryRoot must be a real directory')
|
|
64
|
+
}
|
|
65
|
+
return root
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function ensureDirectory(path) {
|
|
69
|
+
await mkdir(path, { recursive: true, mode: 0o700 })
|
|
70
|
+
const status = await lstat(path)
|
|
71
|
+
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
72
|
+
coordinatorError('SWARM_COORD_STORAGE_INVALID', `${path} must be a real directory`)
|
|
73
|
+
}
|
|
74
|
+
return path
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function ensureCoordDirectory(root, ...parts) {
|
|
78
|
+
let current = resolve(root, COORD_ROOT)
|
|
79
|
+
await ensureDirectory(current)
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
identifier(part, 'storage segment')
|
|
82
|
+
current = resolve(current, part)
|
|
83
|
+
await ensureDirectory(current)
|
|
84
|
+
}
|
|
85
|
+
return current
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function emptyState(now) {
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: COORD_SCHEMA,
|
|
91
|
+
revision: 0,
|
|
92
|
+
tasks: [],
|
|
93
|
+
locks: [],
|
|
94
|
+
queue: [],
|
|
95
|
+
waits: [],
|
|
96
|
+
messages: [],
|
|
97
|
+
events: [],
|
|
98
|
+
decisions: [],
|
|
99
|
+
timeoutCounts: [],
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateState(state) {
|
|
105
|
+
const arrays = ['tasks', 'locks', 'queue', 'waits', 'messages', 'events', 'decisions', 'timeoutCounts']
|
|
106
|
+
if (!state || state.schemaVersion !== COORD_SCHEMA || !Number.isSafeInteger(state.revision)
|
|
107
|
+
|| arrays.some((key) => !Array.isArray(state[key]))) {
|
|
108
|
+
coordinatorError('SWARM_COORD_STATE_INVALID', 'coordination state is invalid')
|
|
109
|
+
}
|
|
110
|
+
return state
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function atomicJson(path, value) {
|
|
114
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
115
|
+
await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 })
|
|
116
|
+
await rename(temporary, path)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function acquireStateLock(root) {
|
|
120
|
+
const directory = await ensureCoordDirectory(root)
|
|
121
|
+
const lockPath = resolve(directory, 'state.lock')
|
|
122
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
123
|
+
try {
|
|
124
|
+
const handle = await open(lockPath, 'wx', 0o600)
|
|
125
|
+
await handle.writeFile(`${JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() })}\n`)
|
|
126
|
+
await handle.close()
|
|
127
|
+
return lockPath
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!(error instanceof Error && error.code === 'EEXIST')) throw error
|
|
130
|
+
let status
|
|
131
|
+
try {
|
|
132
|
+
status = await lstat(lockPath)
|
|
133
|
+
} catch (statusError) {
|
|
134
|
+
if (statusError instanceof Error && statusError.code === 'ENOENT') continue
|
|
135
|
+
throw statusError
|
|
136
|
+
}
|
|
137
|
+
if (Date.now() - status.mtimeMs > STALE_STATE_LOCK_MS) {
|
|
138
|
+
try {
|
|
139
|
+
await unlink(lockPath)
|
|
140
|
+
} catch (unlinkError) {
|
|
141
|
+
if (!(unlinkError instanceof Error && unlinkError.code === 'ENOENT')) throw unlinkError
|
|
142
|
+
}
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
await new Promise((accept) => setTimeout(accept, LOCK_WAIT_MS))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
coordinatorError('SWARM_COORD_STATE_BUSY', 'coordination state lock timed out')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadState(root, initialize) {
|
|
152
|
+
const statePath = resolve(root, COORD_ROOT, 'state.json')
|
|
153
|
+
try {
|
|
154
|
+
return { path: statePath, state: validateState(JSON.parse(await readFile(statePath, 'utf8'))) }
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!(error instanceof Error && error.code === 'ENOENT')) throw error
|
|
157
|
+
if (!initialize) coordinatorError('SWARM_COORD_NOT_INITIALIZED', 'coordination state is not initialized')
|
|
158
|
+
return { path: statePath, state: emptyState(new Date().toISOString()) }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function appendAudit(root, records) {
|
|
163
|
+
if (!records.length) return
|
|
164
|
+
const directory = await ensureCoordDirectory(root)
|
|
165
|
+
const lines = records.map((record) => JSON.stringify({ ...record, recordedAt: new Date().toISOString() }))
|
|
166
|
+
await appendFile(resolve(directory, 'audit.jsonl'), `${lines.join('\n')}\n`, { mode: 0o600 })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function withCoordinationState(repositoryRootValue, mutate) {
|
|
170
|
+
const root = await repositoryRoot(repositoryRootValue)
|
|
171
|
+
const lockPath = await acquireStateLock(root)
|
|
172
|
+
try {
|
|
173
|
+
const authority = await loadState(root, true)
|
|
174
|
+
const result = await mutate(authority.state, root)
|
|
175
|
+
const state = validateState(result.state)
|
|
176
|
+
state.revision += 1
|
|
177
|
+
state.updatedAt = new Date().toISOString()
|
|
178
|
+
await atomicJson(authority.path, state)
|
|
179
|
+
await appendAudit(root, result.audit ?? [])
|
|
180
|
+
return { ...result.output, revision: state.revision }
|
|
181
|
+
} finally {
|
|
182
|
+
await unlink(lockPath)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readCoordinationState(repositoryRootValue) {
|
|
187
|
+
const root = await repositoryRoot(repositoryRootValue)
|
|
188
|
+
return (await loadState(root, false)).state
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function withCoordinationReadLock(repositoryRootValue, inspect) {
|
|
192
|
+
const root = await repositoryRoot(repositoryRootValue)
|
|
193
|
+
const lockPath = await acquireStateLock(root)
|
|
194
|
+
try {
|
|
195
|
+
return await inspect((await loadState(root, false)).state, root)
|
|
196
|
+
} finally {
|
|
197
|
+
await unlink(lockPath)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function leasePayload(lease) {
|
|
202
|
+
return {
|
|
203
|
+
schemaVersion: lease.schemaVersion,
|
|
204
|
+
leaseId: lease.leaseId,
|
|
205
|
+
lockId: lease.lockId,
|
|
206
|
+
chainId: lease.chainId,
|
|
207
|
+
agentId: lease.agentId,
|
|
208
|
+
lockType: lease.lockType,
|
|
209
|
+
resource: lease.resource,
|
|
210
|
+
paths: lease.paths,
|
|
211
|
+
issuedAt: lease.issuedAt,
|
|
212
|
+
expiresAt: lease.expiresAt,
|
|
213
|
+
authorityKeyId: lease.authorityKeyId,
|
|
214
|
+
nonce: lease.nonce,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function authorityKeys(root) {
|
|
219
|
+
const directory = await ensureCoordDirectory(root, 'authority')
|
|
220
|
+
const privatePath = resolve(directory, 'private.pem')
|
|
221
|
+
const publicPath = resolve(directory, 'public.pem')
|
|
222
|
+
const privateExists = await exists(privatePath)
|
|
223
|
+
const publicExists = await exists(publicPath)
|
|
224
|
+
if (privateExists !== publicExists) {
|
|
225
|
+
coordinatorError('SWARM_COORD_AUTHORITY_INVALID', 'coordination authority is incomplete')
|
|
226
|
+
}
|
|
227
|
+
if (!privateExists) {
|
|
228
|
+
const pair = generateKeyPairSync('ed25519', {
|
|
229
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
230
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
231
|
+
})
|
|
232
|
+
await writeFile(privatePath, pair.privateKey, { flag: 'wx', mode: 0o600 })
|
|
233
|
+
await writeFile(publicPath, pair.publicKey, { flag: 'wx', mode: 0o600 })
|
|
234
|
+
}
|
|
235
|
+
for (const path of [privatePath, publicPath]) {
|
|
236
|
+
const status = await lstat(path)
|
|
237
|
+
if (!status.isFile() || status.isSymbolicLink()) {
|
|
238
|
+
coordinatorError('SWARM_COORD_AUTHORITY_INVALID', 'coordination keys must be regular files')
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { privateKey: await readFile(privatePath, 'utf8'), publicKey: await readFile(publicPath, 'utf8') }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function writeSignedLease(root, unsignedLease, sha256) {
|
|
245
|
+
const authority = await authorityKeys(root)
|
|
246
|
+
const lease = { ...unsignedLease, schemaVersion: LEASE_SCHEMA, authorityKeyId: sha256(authority.publicKey) }
|
|
247
|
+
const signature = sign(null, Buffer.from(JSON.stringify(leasePayload(lease))), authority.privateKey)
|
|
248
|
+
.toString('base64url')
|
|
249
|
+
const signedLease = { ...lease, signature }
|
|
250
|
+
const directory = await ensureCoordDirectory(root, 'leases')
|
|
251
|
+
const leasePath = resolve(directory, `${lease.leaseId}.json`)
|
|
252
|
+
await writeFile(leasePath, `${JSON.stringify(signedLease)}\n`, { flag: 'wx', mode: 0o600 })
|
|
253
|
+
return { signedLease, leasePath, relativeLeasePath: `${COORD_ROOT}/leases/${lease.leaseId}.json` }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function insideRepository(root, target) {
|
|
257
|
+
const prefix = root.endsWith(sep) ? root : `${root}${sep}`
|
|
258
|
+
return target === root || target.startsWith(prefix)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export {
|
|
262
|
+
COORD_ROOT,
|
|
263
|
+
COORD_SCHEMA,
|
|
264
|
+
LEASE_SCHEMA,
|
|
265
|
+
coordinatorError,
|
|
266
|
+
identifier,
|
|
267
|
+
insideRepository,
|
|
268
|
+
leasePayload,
|
|
269
|
+
readCoordinationState,
|
|
270
|
+
relativePath,
|
|
271
|
+
repositoryRoot,
|
|
272
|
+
withCoordinationState,
|
|
273
|
+
withCoordinationReadLock,
|
|
274
|
+
writeSignedLease,
|
|
275
|
+
}
|