@twinsearth/dsh-master 0.0.0-stage → 3.2.9
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 +661 -0
- package/README.md +93 -2
- package/client.js +334 -0
- package/cordis.patch.yml +71 -0
- package/dist-host/contract/client.js +82 -0
- package/dist-host/contract/constants.js +70 -0
- package/dist-host/contract/envelope.js +84 -0
- package/dist-host/contract/index.js +17 -0
- package/dist-host/contract/negotiate.js +87 -0
- package/dist-host/contract/server.js +71 -0
- package/dist-host/contract/transports.js +360 -0
- package/dist-host/index.js +260 -0
- package/dist-host/kernel.js +186 -0
- package/dist-host/plugins/cost-optimizer.js +147 -0
- package/dist-host/plugins/dual-manager.js +251 -0
- package/dist-host/plugins/superagent-router.js +499 -0
- package/dist-host/plugins/superagent-worker.js +73 -0
- package/dist-host/superagent/bridge-tokens.js +236 -0
- package/dist-host/superagent/dispatch.js +198 -0
- package/dist-host/superagent/feishu.js +115 -0
- package/dist-host/superagent/github.js +119 -0
- package/dist-host/superagent/pr-delivery.js +351 -0
- package/dist-host/superagent/router.js +219 -0
- package/dist-host/superagent/stream-adapter.js +238 -0
- package/dist-host/superagent/stream-tokens.js +141 -0
- package/dist-host/superagent/ticket-store.js +108 -0
- package/dist-host/superagent/trust.js +145 -0
- package/dist-host/superagent/types.js +144 -0
- package/dist-host/superagent/worker.js +95 -0
- package/dist-host/superagent/writeback.js +162 -0
- package/dist-worker/agent-loop.js +493 -0
- package/dist-worker/errors.js +41 -0
- package/dist-worker/github-pr.js +44 -0
- package/dist-worker/index.js +323 -0
- package/dist-worker/llm-http.js +351 -0
- package/dist-worker/main.js +45 -0
- package/dist-worker/prompt.js +35 -0
- package/dist-worker/sink-http.js +135 -0
- package/dist-worker/tools.js +542 -0
- package/dist-worker/types.js +14 -0
- package/docs/ARCHITECTURE.md +114 -0
- package/docs/CHANGELOG.md +389 -0
- package/docs/INTEROP.md +60 -0
- package/docs/RELEASE-v3.2.8.md +27 -0
- package/docs/RELEASE-v3.2.9.md +29 -0
- package/docs/REVIEW-v3.2.7.md +121 -0
- package/docs/ROADMAP.md +106 -0
- package/docs/SECURITY.md +153 -0
- package/docs/SMOKE-3.2.5.md +77 -0
- package/docs/SMOKE-3.2.7.md +73 -0
- package/docs/SUPERAGENT.md +141 -0
- package/docs/WORKER-AGENT-LOOP.md +139 -0
- package/icon.svg +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/src/contract/client.js +86 -0
- package/src/contract/constants.js +79 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +19 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.ts +265 -0
- package/src/kernel.ts +190 -0
- package/src/plugins/cost-optimizer.ts +155 -0
- package/src/plugins/dual-manager.ts +252 -0
- package/src/plugins/superagent-router.ts +465 -0
- package/src/plugins/superagent-worker.ts +85 -0
- package/src/superagent/bridge-tokens.ts +236 -0
- package/src/superagent/dispatch.ts +197 -0
- package/src/superagent/feishu.ts +104 -0
- package/src/superagent/github.ts +111 -0
- package/src/superagent/pr-delivery.ts +308 -0
- package/src/superagent/router.ts +201 -0
- package/src/superagent/stream-adapter.ts +184 -0
- package/src/superagent/stream-tokens.ts +142 -0
- package/src/superagent/ticket-store.ts +104 -0
- package/src/superagent/trust.ts +139 -0
- package/src/superagent/types.ts +135 -0
- package/src/superagent/worker.ts +100 -0
- package/src/superagent/writeback.ts +154 -0
- package/tsconfig.host.build.json +12 -0
- package/tsconfig.host.json +21 -0
- package/tsconfig.worker.build.json +13 -0
- package/tsconfig.worker.json +17 -0
- package/types/superagent.d.ts +99 -0
- package/worker/agent-loop.ts +524 -0
- package/worker/errors.ts +47 -0
- package/worker/github-pr.ts +65 -0
- package/worker/index.ts +334 -0
- package/worker/llm-http.ts +359 -0
- package/worker/main.ts +45 -0
- package/worker/prompt.ts +39 -0
- package/worker/sink-http.ts +159 -0
- package/worker/tools.ts +531 -0
- package/worker/types.ts +247 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Worker Agent Loop(v3.2.3)
|
|
2
|
+
|
|
3
|
+
> 运行位置:**分身 VM 内**的 `dsh-superagent-worker` preset。
|
|
4
|
+
> 输入:Router 通过 `DSH_MASTER_TICKET` 注入的 Ticket;输出:结构化 WorkerResult(成功报告/失败报告)。
|
|
5
|
+
|
|
6
|
+
## 1. 为什么是 while(true)
|
|
7
|
+
|
|
8
|
+
模型每一轮只回答一件事:**继续调用工具,还是给出最终结论**。Agent Loop 本身不猜业务,
|
|
9
|
+
循环条件不写“任务是否完成”,因为那要由模型判断。`while (true)` 的所有出口都是硬约束:
|
|
10
|
+
|
|
11
|
+
| 出口 | 条件 | 结果 |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| 正常完成 | 模型返回 `tool_calls` 为空 | `phase=done`,finalAnswer = 最后一条 assistant content |
|
|
14
|
+
| 步数熔断 | `stepIndex >= maxSteps`(默认 15) | `error.code=max-steps`,phase=failed |
|
|
15
|
+
| Token 熔断 | 累计 usage.totalTokens > maxTokens(默认 50k) | `error.code=token-budget-exceeded` |
|
|
16
|
+
| 墙钟熔断 | elapsed > maxWallClockMs(默认 15min) | `error.code=wall-clock-exceeded` |
|
|
17
|
+
| 死循环 | 连续相同工具调用 > maxIdenticalToolRepeats(默认 3) | `error.code=dead-loop-detected` |
|
|
18
|
+
| 上下文超长 | messages 序列化 > maxContextChars(默认 400k 字符) | `error.code=context-limit-exceeded`(防 OOM/超长请求) |
|
|
19
|
+
| LLM 失败/超时 | 请求异常或超过 llmTimeoutMs(默认 120s) | `error.code=llm-call-failed / llm-timeout` |
|
|
20
|
+
|
|
21
|
+
## 2. 状态机
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
initializing → thinking ──(tool_calls)──→ executing_tool → thinking → … → reporting → done
|
|
25
|
+
│ │
|
|
26
|
+
└─────────────────(no tool_calls)─────────────────────┘
|
|
27
|
+
任意异常/预算耗尽 ───────────────────────────────────────────→ failed
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
每一步写入 `WorkerStep[]`(`thought/tool_call/tool_result/final_answer/error/phase`),
|
|
31
|
+
并通过 `WorkerSink` 实时推送给宿主/日志;`snapshot()` 可在崩溃后由 Router 读取现场。
|
|
32
|
+
|
|
33
|
+
## 3. 工具与安全边界
|
|
34
|
+
|
|
35
|
+
| 工具 | 限制 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `fs_read_file` | workspace jail;≤256KB |
|
|
38
|
+
| `fs_list_dir` | workspace jail;≤200 项 |
|
|
39
|
+
| `fs_write_file` | workspace jail + **Mandate.scope** 内;≤1MB;realpath 防符号链接逃逸 |
|
|
40
|
+
| `exec_shell_in_vm` | 必须 `mandate.allowShell`;命令黑名单(sudo/curl/wget/ssh/scp/git push/编码执行等);cwd=workspace;超时/输出上限;失败文本回灌 |
|
|
41
|
+
| `create_pr` | 只暴露接口;真实 PR 客户端由宿主注入,默认拒绝(不假装成功) |
|
|
42
|
+
|
|
43
|
+
- 工具错误**不抛穿循环**:返回 `执行报错: …` 给模型自行纠错;只有预算/循环/墙钟终止 Worker;
|
|
44
|
+
- 路径按 workspace 解析并二次校验 Mandate.scope,模型无法用 `../`、绝对路径或 symlink 越狱;
|
|
45
|
+
- 工具执行统一走 `createToolRegistry.execute`,带 AbortSignal + 超时 + 异常隔离。
|
|
46
|
+
|
|
47
|
+
## 4. 流式与可观测
|
|
48
|
+
|
|
49
|
+
`WorkerSink` 提供四类事件:
|
|
50
|
+
|
|
51
|
+
- `phase(phase)`:状态机迁移;
|
|
52
|
+
- `step(step)`:结构化审计记录(可落盘到会话仓,崩溃后恢复);
|
|
53
|
+
- `stream(delta)`:模型文本块/工具名(无真 streaming 时按 240 字符分块模拟,可替换为真 delta);
|
|
54
|
+
- `log(message)`:人类可读日志。
|
|
55
|
+
|
|
56
|
+
Cordis 入口(`worker/index.ts`)把这些事件转发为 `superagent:phase/step/stream/log`,
|
|
57
|
+
完成/失败分别 emit `superagent:worker:done` / `superagent:worker:error`。
|
|
58
|
+
宿主可每 2s 轮询 `getLatestStep()` 推送前端。
|
|
59
|
+
|
|
60
|
+
## 5. 文件结构
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
worker/
|
|
64
|
+
├── types.ts # Ticket/Step/Phase/Limits/LLM/Tool 类型(含默认限制)
|
|
65
|
+
├── errors.ts # 可分类错误 → Router 决定重试/报警
|
|
66
|
+
├── prompt.ts # System/User prompt(含注入防护)
|
|
67
|
+
├── tools.ts # 沙箱工具 + 路径 jail + Shell 黑名单 + 环境脱敏 + 进程树 kill
|
|
68
|
+
├── agent-loop.ts # SuperAgentWorker:状态机/熔断/死循环/审计/脱敏
|
|
69
|
+
├── llm-http.ts # OpenAI 兼容 HTTP LLM 适配器(key 不落日志)
|
|
70
|
+
├── sink-http.ts # Worker → Router 实时事件通道(per-ticket token)
|
|
71
|
+
├── index.ts # Cordis preset 入口(自动跑 + runWorker)
|
|
72
|
+
├── main.ts # CLI:node dist-worker/main.js
|
|
73
|
+
└── tsconfig.worker.build.json → dist-worker/
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 6. 运行方式
|
|
77
|
+
|
|
78
|
+
### 本地直接跑(Node 22.6+ 类型擦除,无需构建)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run build:worker # tsc 编译到 dist-worker/
|
|
82
|
+
node dist-worker/main.js
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
环境变量:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
DSH_MASTER_TICKET Router 注入的 Ticket JSON(必填)
|
|
89
|
+
WORKER_WORKSPACE 工作目录(默认 cwd)
|
|
90
|
+
WORKER_LLM_BASE_URL OpenAI 兼容端点(如 https://api.deepseek.com/v1)
|
|
91
|
+
WORKER_LLM_API_KEY 模型 key(不打印)
|
|
92
|
+
WORKER_LLM_MODEL 模型名(默认 deepseek-chat)
|
|
93
|
+
WORKER_MAX_STEPS / WORKER_MAX_TOKENS / WORKER_MAX_WALL_CLOCK_MS 可选覆盖上限
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
stdout 只输出最终 WorkerResult JSON;日志走 stderr。
|
|
97
|
+
|
|
98
|
+
### 类型检查
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm install
|
|
102
|
+
npm run typecheck:worker # tsc -p tsconfig.worker.json(strict + noEmit)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 作为 Cordis preset
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { apply, runWorker } from './worker/index.ts'
|
|
109
|
+
apply(ctx) // 监听 superagent:worker:start;也可直接 runWorker(ticket, deps)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 7. 工程落地的理由
|
|
113
|
+
|
|
114
|
+
- **安全外壳**:校验权在 Worker 代码,不在模型;越权直接返回系统拒绝文本;
|
|
115
|
+
- **熔断**:步数/Token/墙钟三道硬红线,计费与 CPU 的兜底;
|
|
116
|
+
- **容错**:工具失败作为观察结果回灌,模型可自行 debug;LLM 超时/预算错误分类上报;
|
|
117
|
+
- **可追溯**:每步带 timestamp/tokenUsage,`snapshot()` 支持崩溃恢复与人工复盘;
|
|
118
|
+
- **物理隔离**:以上都在分身 VM 内;Router/宿主不在 Worker 工具可达范围。
|
|
119
|
+
|
|
120
|
+
## 8. 真实流式(3.2.5)
|
|
121
|
+
|
|
122
|
+
- `LLMClient.chatStream?(request, onDelta)` 是可选能力:provider 支持 SSE 时逐 token 回调;
|
|
123
|
+
- `chatStream` 的 delta 直接进 `WorkerSink.stream` → HTTP sink → Router `worker-events` → SSE/宿主 adapter;
|
|
124
|
+
- 未实现 `chatStream` 的 provider 仍走 `chat()` + 分块模拟(行为不变);
|
|
125
|
+
- 同一 step 内不会同时出现「真实 delta」与「模拟分块」(`streamedThisStep` 守卫)。
|
|
126
|
+
|
|
127
|
+
## 9. 回写与边界(3.2.5)
|
|
128
|
+
|
|
129
|
+
- **LLM 工具循环已实现**(本文件第 1-4 节),并由真实 Host smoke 验证;
|
|
130
|
+
- **结果回写**:Worker done → Router(SSE/worker-events)→ webhook / GitHub Issue 评论 / 飞书机器人;
|
|
131
|
+
- **PR**:`create_pr` 工具可接真实 GitHub 客户端(`DSH_MASTER_GITHUB_TOKEN/REPO`),需要先把分支 push 到远端;
|
|
132
|
+
- 回写默认关闭,失败只告警,不改变 Ticket 终态;token 不入日志/状态。
|
|
133
|
+
|
|
134
|
+
- Worker preset 已用 tsc 构建到 `dist-worker/` 并接入 bundle 行 `master-superagent-loop`(fork only);
|
|
135
|
+
- Router 生成的 per-ticket token 通过 HTTP POST 把事件实时回传 Router;前端 SSE/WS 转发待 3.2.4;
|
|
136
|
+
- 回写(webhook / GitHub Issue 评论)默认关闭,按宿主配置启用;失败只告警;
|
|
137
|
+
- 跨进程派发桥需要宿主配置 `bridgeToken`(母体)与 `motherUrl/motherToken`(原身),默认关闭;
|
|
138
|
+
- Shell 的 VM/容器隔离仍是主防线;未证明隔离时不要开启 `allowShell`;
|
|
139
|
+
- PR 创建、浏览器实时 UI、Worker 并发/配额看护仍待 3.2.4+。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="dsh-Master">
|
|
2
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#0B1F33"/>
|
|
3
|
+
<path d="M14 30 L32 15 L50 30" fill="none" stroke="#4CC2FF" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M19 29 L19 47 L45 47 L45 29" fill="none" stroke="#E8F2FF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<circle cx="24" cy="38" r="3" fill="#4CC2FF"/>
|
|
6
|
+
<circle cx="32" cy="38" r="3" fill="#7DE2C3"/>
|
|
7
|
+
<circle cx="40" cy="38" r="3" fill="#FFB454"/>
|
|
8
|
+
<path d="M24 38 L32 38 L40 38" fill="none" stroke="#E8F2FF" stroke-width="1.4" stroke-dasharray="2 2"/>
|
|
9
|
+
</svg>
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-master",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.2.9",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "Dsh-Master(房主):与瓮城 dsh-Fang 并行的主 Agent 插件,托管 SuperAgent Router/Worker、HarnessMM、PM+PJM。",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"dsh",
|
|
12
|
+
"dsh-plugin",
|
|
13
|
+
"dsh-bundle",
|
|
14
|
+
"master",
|
|
15
|
+
"superagent",
|
|
16
|
+
"harnessmm",
|
|
17
|
+
"pm",
|
|
18
|
+
"pjm"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./dist-host/index.js",
|
|
22
|
+
"./superagent-router": "./dist-host/plugins/superagent-router.js",
|
|
23
|
+
"./superagent-worker": "./dist-host/plugins/superagent-worker.js",
|
|
24
|
+
"./superagent-loop": "./dist-worker/index.js",
|
|
25
|
+
"./contract": "./dist-host/contract/index.js",
|
|
26
|
+
"./cost-optimizer": "./dist-host/plugins/cost-optimizer.js",
|
|
27
|
+
"./dual-manager": "./dist-host/plugins/dual-manager.js",
|
|
28
|
+
"./client": {
|
|
29
|
+
"browser": "./client.js",
|
|
30
|
+
"default": "./client.js"
|
|
31
|
+
},
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src/**/*.ts",
|
|
37
|
+
"src/**/*.js",
|
|
38
|
+
"worker/**/*.ts",
|
|
39
|
+
"dist-worker/**/*.js",
|
|
40
|
+
"dist-host/**/*.js",
|
|
41
|
+
"types/*.d.ts",
|
|
42
|
+
"tsconfig.worker.json",
|
|
43
|
+
"tsconfig.worker.build.json",
|
|
44
|
+
"tsconfig.host.json",
|
|
45
|
+
"tsconfig.host.build.json",
|
|
46
|
+
"client.js",
|
|
47
|
+
"cordis.patch.yml",
|
|
48
|
+
"locale/*.json",
|
|
49
|
+
"icon.svg",
|
|
50
|
+
"docs/**/*.md",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "node --test --test-force-exit test/",
|
|
55
|
+
"test:clean": "node --test --test-concurrency=1 test/",
|
|
56
|
+
"test:worker": "node --test --test-force-exit test/worker-agent-loop.test.ts test/worker-tools.test.ts test/worker-llm-http.test.ts test/worker-sink-http.test.ts",
|
|
57
|
+
"typecheck:worker": "tsc -p tsconfig.worker.json",
|
|
58
|
+
"build:worker": "tsc -p tsconfig.worker.build.json",
|
|
59
|
+
"worker": "node dist-worker/main.js",
|
|
60
|
+
"check": "node --check client.js && tsc -p tsconfig.host.json && tsc -p tsconfig.worker.json",
|
|
61
|
+
"typecheck:host": "tsc -p tsconfig.host.json",
|
|
62
|
+
"build:host": "tsc -p tsconfig.host.build.json",
|
|
63
|
+
"build": "npm run build:host && npm run build:worker"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@twinsearth/dsh-fang-contract": "^1.0.0",
|
|
67
|
+
"@types/node": "^24.0.0",
|
|
68
|
+
"typescript": "^5.9.0"
|
|
69
|
+
},
|
|
70
|
+
"dsh": {
|
|
71
|
+
"bundle": {
|
|
72
|
+
"patch": "./cordis.patch.yml"
|
|
73
|
+
},
|
|
74
|
+
"client": {
|
|
75
|
+
"platform": "web",
|
|
76
|
+
"immediately": true,
|
|
77
|
+
"inject": []
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"types": "./types/superagent.d.ts"
|
|
81
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_TRANSPORT_ORDER, METHODS } from './constants.js'
|
|
5
|
+
import {
|
|
6
|
+
createFileTransport,
|
|
7
|
+
createHttpTransport,
|
|
8
|
+
createLegacyTransport,
|
|
9
|
+
createLocalTransport,
|
|
10
|
+
createTransportRouter,
|
|
11
|
+
} from './transports.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 客户端门面:一次配置,按 local → http → file → legacy 自动降级。
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* ctx?: object,
|
|
18
|
+
* config?: {httpBaseUrl?:string, httpToken?:string, fileDir?:string, order?:string[], timeoutMs?:number},
|
|
19
|
+
* fetchImpl?: typeof fetch,
|
|
20
|
+
* logger?: object,
|
|
21
|
+
* instanceId?: string,
|
|
22
|
+
* }} options
|
|
23
|
+
*/
|
|
24
|
+
export function createContractClient(options = {}) {
|
|
25
|
+
const { ctx = null, config = {}, fetchImpl = fetch, logger = null, instanceId = 'contract-client' } = options
|
|
26
|
+
const transports = []
|
|
27
|
+
if (ctx) transports.push(createLocalTransport({ ctx }))
|
|
28
|
+
if (config.httpBaseUrl) transports.push(createHttpTransport({
|
|
29
|
+
baseUrl: config.httpBaseUrl,
|
|
30
|
+
token: config.httpToken,
|
|
31
|
+
headers: config.httpHeaders,
|
|
32
|
+
fetchImpl,
|
|
33
|
+
timeoutMs: config.timeoutMs,
|
|
34
|
+
pathPrefix: config.pathPrefix,
|
|
35
|
+
}))
|
|
36
|
+
if (config.fileDir) transports.push(createFileTransport({
|
|
37
|
+
dir: config.fileDir,
|
|
38
|
+
pollMs: config.filePollMs,
|
|
39
|
+
timeoutMs: config.fileTimeoutMs,
|
|
40
|
+
}))
|
|
41
|
+
if (ctx) transports.push(createLegacyTransport({ ctx }))
|
|
42
|
+
|
|
43
|
+
const order = Array.isArray(config.order) && config.order.length > 0 ? config.order : DEFAULT_TRANSPORT_ORDER
|
|
44
|
+
const router = createTransportRouter({
|
|
45
|
+
transports,
|
|
46
|
+
order,
|
|
47
|
+
localHello: null,
|
|
48
|
+
logger,
|
|
49
|
+
})
|
|
50
|
+
let lastHello = null
|
|
51
|
+
let helloPromise = null
|
|
52
|
+
|
|
53
|
+
const client = {
|
|
54
|
+
instanceId,
|
|
55
|
+
transports: () => router.transports(),
|
|
56
|
+
describe: () => ({
|
|
57
|
+
instanceId,
|
|
58
|
+
routes: router.describe(),
|
|
59
|
+
hello: lastHello,
|
|
60
|
+
}),
|
|
61
|
+
attempts: () => router.attempts(),
|
|
62
|
+
async hello(force = false) {
|
|
63
|
+
if (!force && lastHello) return lastHello
|
|
64
|
+
if (!force && helloPromise) return helloPromise
|
|
65
|
+
helloPromise = (async () => {
|
|
66
|
+
const result = await router.hello()
|
|
67
|
+
lastHello = result
|
|
68
|
+
return result
|
|
69
|
+
})().finally(() => { helloPromise = null })
|
|
70
|
+
return helloPromise
|
|
71
|
+
},
|
|
72
|
+
async invoke(method, params, invokeOptions = {}) {
|
|
73
|
+
const result = await router.invoke(method, params, { timeoutMs: config.timeoutMs, ...invokeOptions })
|
|
74
|
+
return result
|
|
75
|
+
},
|
|
76
|
+
async health() { return await client.invoke(METHODS.HEALTH, null) },
|
|
77
|
+
async spawnFork(params, invokeOptions = {}) { return await client.invoke(METHODS.FORK_SPAWN, params, invokeOptions) },
|
|
78
|
+
async stopFork(params, invokeOptions = {}) { return await client.invoke(METHODS.FORK_STOP, params, invokeOptions) },
|
|
79
|
+
async listForks(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.FORK_LIST, params, invokeOptions) },
|
|
80
|
+
async createDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_CREATE, params, invokeOptions) },
|
|
81
|
+
async resolveDecision(params, invokeOptions = {}) { return await client.invoke(METHODS.DECISION_RESOLVE, params, invokeOptions) },
|
|
82
|
+
async listSessions(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.SESSION_LIST, params, invokeOptions) },
|
|
83
|
+
async orchestratorStatus(params = {}, invokeOptions = {}) { return await client.invoke(METHODS.ORCHESTRATOR_STATUS, params, invokeOptions) },
|
|
84
|
+
}
|
|
85
|
+
return client
|
|
86
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 契约常量与兼容规则(contract SDK 1.x)。
|
|
6
|
+
*
|
|
7
|
+
* 稳定承诺:
|
|
8
|
+
* * MAJOR 相同的版本保证“旧字段可读、旧方法可调、未知字段忽略”;
|
|
9
|
+
* * 新增能力只通过 METHODS/CAPABILITIES 扩展,不改变既有方法语义;
|
|
10
|
+
* * 删除/改义必须升 MAJOR,并保留至少一个 MINOR 周期的双写(method@1 与 method@2)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const CONTRACT_PACKAGE = '@twinsearth/dsh-fang-contract'
|
|
14
|
+
export const CONTRACT_VERSION = '1.0.0'
|
|
15
|
+
export const PROTOCOL = 'fang-master'
|
|
16
|
+
export const PROTOCOL_VERSION = 1
|
|
17
|
+
export const MIN_COMPATIBLE_MAJOR = 1
|
|
18
|
+
|
|
19
|
+
/** 稳定方法名(新方法只能追加)。 */
|
|
20
|
+
export const METHODS = Object.freeze({
|
|
21
|
+
HELLO: 'contract.hello',
|
|
22
|
+
HEALTH: 'contract.health',
|
|
23
|
+
FORK_SPAWN: 'fang.fork.spawn',
|
|
24
|
+
FORK_STOP: 'fang.fork.stop',
|
|
25
|
+
FORK_LIST: 'fang.fork.list',
|
|
26
|
+
DECISION_CREATE: 'fang.decision.create',
|
|
27
|
+
DECISION_RESOLVE: 'fang.decision.resolve',
|
|
28
|
+
SESSION_LIST: 'fang.session.list',
|
|
29
|
+
ORCHESTRATOR_STATUS: 'fang.orchestrator.status',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** 方法 → 契约版本(用于协商后的方法裁剪)。 */
|
|
33
|
+
export const METHOD_VERSION = Object.freeze({
|
|
34
|
+
[METHODS.HELLO]: '1.0',
|
|
35
|
+
[METHODS.HEALTH]: '1.0',
|
|
36
|
+
[METHODS.FORK_SPAWN]: '1.0',
|
|
37
|
+
[METHODS.FORK_STOP]: '1.0',
|
|
38
|
+
[METHODS.FORK_LIST]: '1.0',
|
|
39
|
+
[METHODS.DECISION_CREATE]: '1.0',
|
|
40
|
+
[METHODS.DECISION_RESOLVE]: '1.0',
|
|
41
|
+
[METHODS.SESSION_LIST]: '1.0',
|
|
42
|
+
[METHODS.ORCHESTRATOR_STATUS]: '1.0',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export const CAPABILITIES = Object.freeze([
|
|
46
|
+
'sync', 'async', 'idempotent', 'stream-events', 'file-outbox',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
export const ROLES = Object.freeze(['mother', 'essence', 'fork'])
|
|
50
|
+
|
|
51
|
+
export const ERROR_CODES = Object.freeze({
|
|
52
|
+
INVALID_ENVELOPE: 'invalid-envelope',
|
|
53
|
+
UNSUPPORTED_METHOD: 'unsupported-method',
|
|
54
|
+
UNSUPPORTED_VERSION: 'unsupported-version',
|
|
55
|
+
INCOMPATIBLE: 'contract-incompatible',
|
|
56
|
+
TRANSPORT_UNAVAILABLE: 'transport-unavailable',
|
|
57
|
+
TRANSPORT_TIMEOUT: 'transport-timeout',
|
|
58
|
+
TRANSPORT_HTTP_ERROR: 'transport-http-error',
|
|
59
|
+
REMOTE_ERROR: 'remote-error',
|
|
60
|
+
ALL_TRANSPORTS_FAILED: 'all-transports-failed',
|
|
61
|
+
HANDLER_THREW: 'handler-threw',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 兜底顺序(从快到慢):
|
|
66
|
+
* local —— 同一 Host 的 DSH 服务(零网络、首选)
|
|
67
|
+
* http —— 母体/原身 HTTP 契约端点
|
|
68
|
+
* file —— 同一文件系统的 outbox/inbox(HTTP 不可用/被防火墙拦)
|
|
69
|
+
* legacy —— 直接调用 3.1.x 的旧 DSH 服务键(接口包被替换/新端点不存在时)
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_TRANSPORT_ORDER = Object.freeze(['local', 'http', 'file', 'legacy'])
|
|
72
|
+
|
|
73
|
+
/** 3.1.x 及更早的旧服务键(只读兼容;不新增语义)。 */
|
|
74
|
+
export const LEGACY_SERVICE_KEYS = Object.freeze({
|
|
75
|
+
forker: 'fangForker',
|
|
76
|
+
decisionCenter: 'fangDecisionCenter',
|
|
77
|
+
sessionCatalog: 'fangSessionCatalog',
|
|
78
|
+
orchestrator: 'fang',
|
|
79
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
import { ERROR_CODES, PROTOCOL, PROTOCOL_VERSION } from './constants.js'
|
|
7
|
+
|
|
8
|
+
export function newId(prefix = 'msg') {
|
|
9
|
+
return `${prefix}_${crypto.randomUUID()}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 创建请求信封;未知字段由接收方忽略(兼容规则)。 */
|
|
13
|
+
export function createEnvelope({ method, params = null, id, source = '', instanceId = '', timeoutMs = 30_000, meta = {} } = {}) {
|
|
14
|
+
if (!method || typeof method !== 'string') throw new Error('method is required')
|
|
15
|
+
return {
|
|
16
|
+
protocol: PROTOCOL,
|
|
17
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
18
|
+
id: String(id ?? newId('req')),
|
|
19
|
+
method: String(method),
|
|
20
|
+
params: params ?? null,
|
|
21
|
+
meta: {
|
|
22
|
+
at: Date.now(),
|
|
23
|
+
deadline: Date.now() + Math.max(1, Number(timeoutMs) || 30_000),
|
|
24
|
+
source: String(source ?? ''),
|
|
25
|
+
instanceId: String(instanceId ?? ''),
|
|
26
|
+
...(meta && typeof meta === 'object' ? meta : {}),
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 校验请求/响应信封;返回 {ok, errors, envelope}。 */
|
|
32
|
+
export function validateEnvelope(value, { response = false } = {}) {
|
|
33
|
+
const errors = []
|
|
34
|
+
const raw = value && typeof value === 'object' ? value : {}
|
|
35
|
+
if (raw.protocol !== PROTOCOL) errors.push(`protocol-invalid:${String(raw.protocol)}`)
|
|
36
|
+
if (raw.protocolVersion !== PROTOCOL_VERSION) errors.push(`protocol-version-invalid:${String(raw.protocolVersion)}`)
|
|
37
|
+
if (!raw.id) errors.push('id-required')
|
|
38
|
+
if (response) {
|
|
39
|
+
if (typeof raw.ok !== 'boolean') errors.push('ok-required')
|
|
40
|
+
if (raw.ok === false && !raw.error?.code) errors.push('error-code-required')
|
|
41
|
+
} else if (!raw.method) {
|
|
42
|
+
errors.push('method-required')
|
|
43
|
+
}
|
|
44
|
+
return { ok: errors.length === 0, errors, envelope: raw }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createResponse({ id, result, error, meta = {} } = {}) {
|
|
48
|
+
const base = {
|
|
49
|
+
protocol: PROTOCOL,
|
|
50
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
51
|
+
id: String(id ?? newId('res')),
|
|
52
|
+
ok: !error,
|
|
53
|
+
meta: { at: Date.now(), ...(meta && typeof meta === 'object' ? meta : {}) },
|
|
54
|
+
}
|
|
55
|
+
if (error) return { ...base, error: normalizeError(error) }
|
|
56
|
+
return { ...base, result: result ?? null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeError(error, fallbackCode = ERROR_CODES.REMOTE_ERROR) {
|
|
60
|
+
if (!error) return { code: fallbackCode, message: String(fallbackCode), retryable: false }
|
|
61
|
+
if (typeof error === 'string') return { code: fallbackCode, message: error, retryable: false }
|
|
62
|
+
return {
|
|
63
|
+
code: String(error.code ?? fallbackCode),
|
|
64
|
+
message: String(error.message ?? error.error ?? fallbackCode),
|
|
65
|
+
retryable: error.retryable === true,
|
|
66
|
+
...(error.detail === undefined ? {} : { detail: error.detail }),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ContractTransportError extends Error {
|
|
71
|
+
constructor(code, message, { transport = '', detail = null, retryable = false } = {}) {
|
|
72
|
+
super(String(message ?? code))
|
|
73
|
+
this.name = 'ContractTransportError'
|
|
74
|
+
this.code = String(code)
|
|
75
|
+
this.transport = String(transport)
|
|
76
|
+
this.detail = detail
|
|
77
|
+
this.retryable = retryable === true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isCompatibleResponse(value) {
|
|
82
|
+
return validateEnvelope(value, { response: true }).ok
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @twinsearth/dsh-fang-contract
|
|
6
|
+
*
|
|
7
|
+
* 瓮城(dsh-Fang)↔ 房主(dsh-Master)的独立版本化互联 SDK。
|
|
8
|
+
* 设计目标:
|
|
9
|
+
* 1. 双方独立升级:语义化版本 + hello 协商 + 未知字段忽略 + 方法版本裁剪;
|
|
10
|
+
* 2. 互联互通保底:local → http → file → legacy 四级传输自动降级;
|
|
11
|
+
* 3. 接口包可独立替换:更新本包即升级接口架构,旧方法在 MAJOR 内保持可调用。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export * from './constants.js'
|
|
15
|
+
export * from './envelope.js'
|
|
16
|
+
export * from './negotiate.js'
|
|
17
|
+
export * from './server.js'
|
|
18
|
+
export * from './transports.js'
|
|
19
|
+
export * from './client.js'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CAPABILITIES,
|
|
6
|
+
CONTRACT_VERSION,
|
|
7
|
+
ERROR_CODES,
|
|
8
|
+
METHOD_VERSION,
|
|
9
|
+
METHODS,
|
|
10
|
+
MIN_COMPATIBLE_MAJOR,
|
|
11
|
+
PROTOCOL,
|
|
12
|
+
ROLES,
|
|
13
|
+
} from './constants.js'
|
|
14
|
+
|
|
15
|
+
export function parseVersion(value) {
|
|
16
|
+
const text = String(value ?? '').trim()
|
|
17
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(text)
|
|
18
|
+
if (!match) return null
|
|
19
|
+
return {
|
|
20
|
+
major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3] ?? 0), text,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compareVersions(a, b) {
|
|
25
|
+
const pa = parseVersion(a)
|
|
26
|
+
const pb = parseVersion(b)
|
|
27
|
+
if (!pa || !pb) return 0
|
|
28
|
+
if (pa.major !== pb.major) return pa.major - pb.major
|
|
29
|
+
if (pa.minor !== pb.minor) return pa.minor - pb.minor
|
|
30
|
+
return pa.patch - pb.patch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 标准 hello payload(服务端暴露;客户端据此协商)。 */
|
|
34
|
+
export function helloPayload({
|
|
35
|
+
role = 'mother',
|
|
36
|
+
instanceId = '',
|
|
37
|
+
version = CONTRACT_VERSION,
|
|
38
|
+
methods = Object.values(METHODS),
|
|
39
|
+
capabilities = CAPABILITIES,
|
|
40
|
+
transports = [],
|
|
41
|
+
startedAt = Date.now(),
|
|
42
|
+
} = {}) {
|
|
43
|
+
return {
|
|
44
|
+
protocol: PROTOCOL,
|
|
45
|
+
contractVersion: String(version),
|
|
46
|
+
role: ROLES.includes(String(role)) ? String(role) : 'fork',
|
|
47
|
+
instanceId: String(instanceId ?? ''),
|
|
48
|
+
startedAt: Number(startedAt) || Date.now(),
|
|
49
|
+
methods: [...new Set(methods.map(String))],
|
|
50
|
+
capabilities: [...new Set(capabilities.map(String))],
|
|
51
|
+
transports: [...new Set(transports.map(String))],
|
|
52
|
+
features: {
|
|
53
|
+
idempotency: true,
|
|
54
|
+
envelopes: true,
|
|
55
|
+
fileOutbox: true,
|
|
56
|
+
versionNegotiation: true,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 双向版本协商。
|
|
63
|
+
* @param {object} local 本端 helloPayload
|
|
64
|
+
* @param {object} remote 对端 helloPayload
|
|
65
|
+
* @returns {{ok:boolean, compatible:boolean, fallback:'legacy'|null, reason?:string, version?:string, methods:string[], capabilities:string[], features:object}}
|
|
66
|
+
*/
|
|
67
|
+
export function negotiate(local, remote) {
|
|
68
|
+
const lv = parseVersion(local?.contractVersion)
|
|
69
|
+
const rv = parseVersion(remote?.contractVersion)
|
|
70
|
+
if (local?.protocol !== PROTOCOL || remote?.protocol !== PROTOCOL) {
|
|
71
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'protocol-mismatch', methods: [], capabilities: [], features: {} }
|
|
72
|
+
}
|
|
73
|
+
if (!lv || !rv) {
|
|
74
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'version-invalid', methods: [], capabilities: [], features: {} }
|
|
75
|
+
}
|
|
76
|
+
if (rv.major < MIN_COMPATIBLE_MAJOR) {
|
|
77
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-old', methods: [], capabilities: [], features: {} }
|
|
78
|
+
}
|
|
79
|
+
if (rv.major > lv.major) {
|
|
80
|
+
return { ok: false, compatible: false, fallback: 'legacy', reason: 'remote-too-new', methods: [], capabilities: [], features: {} }
|
|
81
|
+
}
|
|
82
|
+
const localMethods = new Set(Array.isArray(local.methods) ? local.methods : [])
|
|
83
|
+
const methods = (Array.isArray(remote.methods) ? remote.methods : []).filter((method) => localMethods.has(method))
|
|
84
|
+
// 裁剪:方法所需契约 minor 不能高于双方共同支持的 minor。
|
|
85
|
+
const commonMinor = Math.min(lv.minor, rv.minor)
|
|
86
|
+
const supported = methods.filter((method) => {
|
|
87
|
+
const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0')
|
|
88
|
+
return !methodVersion || methodVersion.minor <= commonMinor || methodVersion.major < lv.major
|
|
89
|
+
})
|
|
90
|
+
const localCaps = new Set(Array.isArray(local.capabilities) ? local.capabilities : [])
|
|
91
|
+
const capabilities = (Array.isArray(remote.capabilities) ? remote.capabilities : []).filter((cap) => localCaps.has(cap))
|
|
92
|
+
const version = `${Math.min(lv.major, rv.major)}.${commonMinor}.0`
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
compatible: true,
|
|
96
|
+
fallback: null,
|
|
97
|
+
version,
|
|
98
|
+
methods: supported,
|
|
99
|
+
capabilities,
|
|
100
|
+
features: { ...(local.features ?? {}), ...(remote.features ?? {}) },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function unsupportedMethodError(method) {
|
|
105
|
+
return { code: ERROR_CODES.UNSUPPORTED_METHOD, message: `unsupported method: ${String(method)}`, retryable: false }
|
|
106
|
+
}
|