@sakki_chin/dsh-codex-orchestrate 1.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/client/api.js +50 -0
- package/client/components/Composer.jsx +179 -0
- package/client/components/ConversationThread.jsx +396 -0
- package/client/components/Icons.jsx +47 -0
- package/client/components/OrchestrateWorkbench.jsx +68 -0
- package/client/components/Toast.jsx +26 -0
- package/client/components/WorkflowRail.jsx +414 -0
- package/client/hooks/useOrchestrateData.jsx +122 -0
- package/client/index.jsx +103 -0
- package/client/styles.js +296 -0
- package/cordis.patch.yml +11 -0
- package/esbuild.client.mjs +43 -0
- package/lib/client.js +8974 -0
- package/package.json +63 -0
- package/schema/workflow.example.yaml +32 -0
- package/schema/workflow.schema.json +75 -0
- package/schema/workflow.schema.md +112 -0
- package/scripts/verify-edge-routing.mjs +214 -0
- package/server/codex-runner.js +129 -0
- package/server/defaults.js +83 -0
- package/server/orchestrator.js +570 -0
- package/server/orchestrator.test.cjs +423 -0
- package/server/persistence.js +124 -0
- package/server/persistence.test.cjs +175 -0
- package/server/plugin.mjs +312 -0
- package/server/plugin.test.cjs +181 -0
- package/server/turn-reduce.js +77 -0
- package/server/turn-reduce.test.cjs +96 -0
- package/server/validate.js +263 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sakki_chin/dsh-codex-orchestrate",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 插件:把 Codex 任务按 YAML schema 编排成 DAG 工作流,并在 DSH 右侧边栏以原生 React tab 呈现 DAG 进度与逐节点会话。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"codex",
|
|
10
|
+
"orchestrate",
|
|
11
|
+
"workflow",
|
|
12
|
+
"dag"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"main": "server/plugin.mjs",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./server/plugin.mjs",
|
|
19
|
+
"./client": "./lib/client.js",
|
|
20
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"server",
|
|
36
|
+
"schema",
|
|
37
|
+
"lib",
|
|
38
|
+
"client",
|
|
39
|
+
"scripts",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"esbuild.client.mjs"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build:client": "node esbuild.client.mjs",
|
|
45
|
+
"verify:edges": "node scripts/verify-edge-routing.mjs",
|
|
46
|
+
"prepublishOnly": "npm run build:client && npm run verify:edges"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@openai/codex-sdk": "0.154.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@ant-design/x-markdown": "2.9.0",
|
|
59
|
+
"esbuild": "0.28.2",
|
|
60
|
+
"react": "19.3.0",
|
|
61
|
+
"react-dom": "19.3.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
apiVersion: codex.dsh/v1
|
|
2
|
+
kind: workflow
|
|
3
|
+
title: 原型仓发布准备
|
|
4
|
+
goal: 校验测试、构建并起草变更说明,产出可发布的工件清单
|
|
5
|
+
concurrency: 3
|
|
6
|
+
nodes:
|
|
7
|
+
- id: verify
|
|
8
|
+
title: 跑通测试与 lint
|
|
9
|
+
prompt: |
|
|
10
|
+
在仓库根目录运行单元测试与 lint,汇总失败项与修复建议。
|
|
11
|
+
输出:失败清单(文件:行号:规则)+ 一句话修复建议。
|
|
12
|
+
model: gpt-5.6-sol
|
|
13
|
+
sandboxMode: read-only
|
|
14
|
+
timeoutS: 900
|
|
15
|
+
|
|
16
|
+
- id: build
|
|
17
|
+
title: 构建产物并校验体积
|
|
18
|
+
prompt: 执行生产构建,报告产物体积与明显的体积回归(阈值 +10%)。
|
|
19
|
+
dependsOn: [verify]
|
|
20
|
+
sandboxMode: workspace-write
|
|
21
|
+
|
|
22
|
+
- id: docs
|
|
23
|
+
title: 起草变更说明
|
|
24
|
+
prompt: 基于 git log 最近 20 条提交起草用户可读的变更说明(中文,分组列出)。
|
|
25
|
+
dependsOn: [verify]
|
|
26
|
+
reasoningEffort: low
|
|
27
|
+
|
|
28
|
+
- id: release
|
|
29
|
+
title: 汇总发布清单
|
|
30
|
+
prompt: 汇总以上产物,输出发布检查单(Markdown 表格)。
|
|
31
|
+
dependsOn: [build, docs]
|
|
32
|
+
onFailure: fail
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://dsh.local/schemas/codex-orchestrate/workflow.v1.json",
|
|
4
|
+
"title": "DSH Codex Orchestrate Workflow",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["apiVersion", "kind"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"apiVersion": { "const": "codex.dsh/v1" },
|
|
10
|
+
"kind": { "enum": ["workflow", "nodePatch", "workflowQuery"] },
|
|
11
|
+
"workflowId": { "type": "string", "pattern": "^wf_[a-z0-9]+$" },
|
|
12
|
+
"title": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
13
|
+
"goal": { "type": "string", "maxLength": 2000 },
|
|
14
|
+
"concurrency": { "type": "integer", "minimum": 1, "maximum": 16 },
|
|
15
|
+
"nodes": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"maxItems": 64,
|
|
19
|
+
"items": { "$ref": "#/$defs/nodeMutation" }
|
|
20
|
+
},
|
|
21
|
+
"remove": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"minItems": 1,
|
|
24
|
+
"maxItems": 64,
|
|
25
|
+
"uniqueItems": true,
|
|
26
|
+
"items": { "$ref": "#/$defs/nodeId" }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"allOf": [
|
|
30
|
+
{
|
|
31
|
+
"if": { "properties": { "kind": { "const": "workflow" } } },
|
|
32
|
+
"then": {
|
|
33
|
+
"required": ["title", "nodes"],
|
|
34
|
+
"properties": {
|
|
35
|
+
"nodes": { "items": { "$ref": "#/$defs/node" } }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"if": { "properties": { "kind": { "const": "nodePatch" } } },
|
|
41
|
+
"then": { "required": ["workflowId"] }
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"$defs": {
|
|
45
|
+
"nodeId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" },
|
|
46
|
+
"nodeMutation": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"required": ["id"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"id": { "$ref": "#/$defs/nodeId" },
|
|
52
|
+
"title": { "type": "string", "minLength": 1, "maxLength": 120 },
|
|
53
|
+
"prompt": { "type": "string", "minLength": 1, "maxLength": 32000 },
|
|
54
|
+
"model": { "type": "string", "maxLength": 120 },
|
|
55
|
+
"cwd": { "type": "string", "pattern": "^/" },
|
|
56
|
+
"dependsOn": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": { "$ref": "#/$defs/nodeId" },
|
|
59
|
+
"uniqueItems": true,
|
|
60
|
+
"maxItems": 16
|
|
61
|
+
},
|
|
62
|
+
"sandboxMode": { "enum": ["read-only", "workspace-write", "danger-full-access"], "default": "danger-full-access" },
|
|
63
|
+
"approvalPolicy": { "enum": ["never", "on-request", "on-failure", "untrusted"] },
|
|
64
|
+
"reasoningEffort": { "enum": ["minimal", "low", "medium", "high"] },
|
|
65
|
+
"timeoutS": { "type": "integer", "minimum": 30, "maximum": 7200 },
|
|
66
|
+
"onFailure": { "enum": ["fail", "continue"] }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"node": {
|
|
70
|
+
"$ref": "#/$defs/nodeMutation",
|
|
71
|
+
"required": ["id", "prompt"],
|
|
72
|
+
"properties": {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# DSH Codex Orchestrate · Workflow YAML Schema (v1)
|
|
2
|
+
|
|
3
|
+
`codex_orchestrate` tool 的唯一入参格式。DSH 按此 schema 创建、查询和动态修改 codex 工作流;
|
|
4
|
+
插件负责原子校验、调度依赖、并发执行、持久化与进度回传。
|
|
5
|
+
|
|
6
|
+
## 设计原则
|
|
7
|
+
|
|
8
|
+
1. **一份文档一个意图**:`kind: workflow` 创建工作流;`kind: workflowQuery` 查询;
|
|
9
|
+
`kind: nodePatch` 对已有工作流的未来节点做原子化**新增 / 修改 / 删除**。
|
|
10
|
+
2. **节点 = 一个独立 codex session**:每个节点拥有自己的 Thread、消息历史与产物。
|
|
11
|
+
3. **依赖是声明式的**:`dependsOn` 指向同工作流内节点 id;环依赖、悬空引用在校验期拒绝。
|
|
12
|
+
4. **并发受控**:无依赖(或依赖已满足)的节点并发执行,受顶层 `concurrency` 上限约束。
|
|
13
|
+
|
|
14
|
+
## 顶层字段
|
|
15
|
+
|
|
16
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| `apiVersion` | `codex.dsh/v1` | ✓ | 固定值 |
|
|
19
|
+
| `kind` | `workflow` \| `nodePatch` \| `workflowQuery` | ✓ | 创建、动态修改或查询 |
|
|
20
|
+
| `workflowId` | string | nodePatch 必填 | 目标工作流;query 省略时返回工作流清单 |
|
|
21
|
+
| `title` | string | workflow 必填 | 人可读标题 |
|
|
22
|
+
| `goal` | string | — | 总目标描述(展示用) |
|
|
23
|
+
| `concurrency` | int 1–16 | — | 并发节点上限,默认 3 |
|
|
24
|
+
| `nodes` | Node[] | workflow 必填 | 创建节点;nodePatch 中为新增/局部更新 |
|
|
25
|
+
| `remove` | string[] | — | nodePatch 中真正删除的节点 id |
|
|
26
|
+
|
|
27
|
+
## Node 字段
|
|
28
|
+
|
|
29
|
+
| 字段 | 类型 | 必填 | 说明 |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `id` | `[a-z0-9][a-z0-9-]{0,63}` | ✓ | 工作流内唯一 |
|
|
32
|
+
| `title` | string ≤120 | — | 缺省取 id |
|
|
33
|
+
| `prompt` | string 1–32000 | 新增时必填 | 发给 codex 的任务指令;更新已有节点时可省略 |
|
|
34
|
+
| `model` | string | — | 缺省 `gpt-5.6-sol`(可用环境变量 `DSH_CODEX_MODEL` 覆盖)。必须是当前登录方式可用的 slug:`gpt-5-codex` 在 **ChatGPT 订阅登录**下会被服务端拒绝(`not supported when using Codex with a ChatGPT account`);订阅登录可用 `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna` / `gpt-5.5` |
|
|
35
|
+
| `cwd` | string | — | codex 工作目录(绝对路径);省略时继承发起 DSH 会话的工作目录 |
|
|
36
|
+
| `dependsOn` | string[] | — | 前置节点 id;不得自引用;直接依赖的终态结果会自动注入该节点首轮 prompt |
|
|
37
|
+
| `sandboxMode` | `read-only` \| `workspace-write` \| `danger-full-access` | — | 缺省 `danger-full-access`;DSH 可在 YAML 中覆盖 |
|
|
38
|
+
| `approvalPolicy` | `never` \| `on-request` \| `on-failure` \| `untrusted` | — | 缺省 `never`(原型非交互) |
|
|
39
|
+
| `reasoningEffort` | `minimal` \| `low` \| `medium` \| `high` | — | 缺省 `medium` |
|
|
40
|
+
| `timeoutS` | int 30–7200 | — | 单节点超时,缺省 600 |
|
|
41
|
+
| `onFailure` | `fail` \| `continue` | — | `fail`(缺省):下游阻塞;`continue`:下游照常调度 |
|
|
42
|
+
|
|
43
|
+
## nodePatch 事务语义
|
|
44
|
+
|
|
45
|
+
- `nodes` 按 `id` 匹配:不存在则**新增**;存在且未开始则**覆盖传入的可编辑字段**
|
|
46
|
+
(title/prompt/model/cwd/dependsOn/sandboxMode/approvalPolicy/reasoningEffort/timeoutS/onFailure)。
|
|
47
|
+
- `remove` 真正移除节点;被保留节点仍引用它时,必须在同一补丁中修改 `dependsOn` 完成重连。
|
|
48
|
+
- 只有 `pending` / `blocked` / `queued` 节点可修改或删除;`running`、有活动对话的节点以及
|
|
49
|
+
`completed` / `failed` / `cancelled` 节点不可变。
|
|
50
|
+
- 整个补丁以最终 DAG 为单位校验:目标状态、悬空依赖、自依赖或环任一失败,整包不提交。
|
|
51
|
+
- 删除会写入 append-only 日志墓碑,进程重启后不会复活。
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
apiVersion: codex.dsh/v1
|
|
55
|
+
kind: nodePatch
|
|
56
|
+
workflowId: wf_abc123
|
|
57
|
+
title: 调整后的工作流
|
|
58
|
+
remove: [old-review]
|
|
59
|
+
nodes:
|
|
60
|
+
- id: publish
|
|
61
|
+
dependsOn: [build] # 局部更新:既有节点不必重复 prompt
|
|
62
|
+
- id: smoke-test
|
|
63
|
+
prompt: 执行冒烟测试
|
|
64
|
+
dependsOn: [build]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 查询语义
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
# 查询单个工作流完整快照
|
|
71
|
+
apiVersion: codex.dsh/v1
|
|
72
|
+
kind: workflowQuery
|
|
73
|
+
workflowId: wf_abc123
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
省略 `workflowId` 时返回工作流清单。查询不会初始化 Codex SDK,也不要求启动新任务。
|
|
77
|
+
|
|
78
|
+
## 示例
|
|
79
|
+
|
|
80
|
+
见 `workflow.example.yaml`。规范真源:`workflow.schema.json`(JSON Schema 2020-12)。
|
|
81
|
+
|
|
82
|
+
## 回传契约(tool 返回)
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"workflowId": "wf_abc123",
|
|
87
|
+
"created": ["verify", "build"],
|
|
88
|
+
"updated": [],
|
|
89
|
+
"deleted": [],
|
|
90
|
+
"rejected": [],
|
|
91
|
+
"nodes": [{ "id": "verify", "status": "queued", "dependsOn": [], "model": "gpt-5.6-sol" }],
|
|
92
|
+
"defaultModel": "gpt-5.6-sol",
|
|
93
|
+
"auth": { "mode": "chatgpt-subscription", "authFile": "/Users/me/.codex/auth.json", "present": true },
|
|
94
|
+
"progress": "/codex-orchestrate/api/state?workflowId=wf_abc123"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
进度查询返回各节点状态 + codex session 内 item 快照(形状对齐
|
|
99
|
+
`@openai/codex-sdk` 的 ThreadItem 聚合语义:`item.updated` 为全量快照覆盖)。
|
|
100
|
+
|
|
101
|
+
## 认证(模型请求为什么会生效 / 不生效)
|
|
102
|
+
|
|
103
|
+
节点由**本机 codex**执行,认证完全复用 codex 自己的登录态(`~/.codex/auth.json`,
|
|
104
|
+
可用 `CODEX_HOME` 改位置),插件不代管、不刷新、不转发任何 token;
|
|
105
|
+
|
|
106
|
+
- **ChatGPT 订阅登录**:终端执行 `codex login`(官方 OAuth 流程)写入订阅 token,
|
|
107
|
+
之后无需 API key;SDK 原样使用该登录态。此路径下 `gpt-5-codex` 不被服务端接受,
|
|
108
|
+
用订阅可用的 slug(缺省 `gpt-5.6-sol`)。
|
|
109
|
+
- **API key 登录**:设置 `OPENAI_API_KEY`(或 `codex login --api-key`)。
|
|
110
|
+
- 两种登录态都没有时,`createRunner()` 会在派发前直接报错,不会烧掉一个必然失败的 turn。
|
|
111
|
+
- 只读探测结果见 `GET /codex-orchestrate/api/status`(`defaultModel` + `auth.mode`),
|
|
112
|
+
以及 tool 返回里的 `defaultModel` / `auth` 字段。
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* 几何自证:证明 workflow DAG 的所有连线都不会覆盖任何一张节点卡片。
|
|
3
|
+
*
|
|
4
|
+
* 做法:用 esbuild 把 client/components/WorkflowRail.jsx 转成 CJS(react 置为 external),
|
|
5
|
+
* 直接调用其中真实的 layoutWorkflow,拿到每条边的路径;再把每条路径按 M/H/V/Q 密集采样,
|
|
6
|
+
* 逐点与所有卡片矩形做命中判定。
|
|
7
|
+
*
|
|
8
|
+
* node scripts/verify-edge-routing.mjs
|
|
9
|
+
*
|
|
10
|
+
* 退出码 0 = 全部通过;1 = 存在压线。
|
|
11
|
+
*/
|
|
12
|
+
import { build } from 'esbuild'
|
|
13
|
+
import { createRequire } from 'node:module'
|
|
14
|
+
import { rmSync } from 'node:fs'
|
|
15
|
+
import { join, dirname } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const root = join(here, '..')
|
|
20
|
+
|
|
21
|
+
/* ---------------- 取真实实现 ---------------- */
|
|
22
|
+
/* 产物必须落在仓库内:这样 bundle 里的 require('react') 才能从本包 node_modules 解析到 */
|
|
23
|
+
const outfile = join(root, '.rail-geom.cjs')
|
|
24
|
+
rmSync(outfile, { force: true })
|
|
25
|
+
await build({
|
|
26
|
+
entryPoints: [join(root, 'client/components/WorkflowRail.jsx')],
|
|
27
|
+
outfile,
|
|
28
|
+
bundle: true,
|
|
29
|
+
format: 'cjs',
|
|
30
|
+
platform: 'node',
|
|
31
|
+
target: ['node20'],
|
|
32
|
+
external: ['react'],
|
|
33
|
+
jsx: 'automatic',
|
|
34
|
+
logLevel: 'error',
|
|
35
|
+
})
|
|
36
|
+
const require_ = createRequire(import.meta.url)
|
|
37
|
+
const { layoutWorkflow } = require_(outfile)
|
|
38
|
+
|
|
39
|
+
/* ---------------- 路径采样 ---------------- */
|
|
40
|
+
/* 只支持本组件实际会生成的命令:M / H / V / Q。出现别的命令直接报错,
|
|
41
|
+
避免"采样器不认识所以跳过"这种假通过。 */
|
|
42
|
+
function samplePath(d, step = 0.4) {
|
|
43
|
+
/* 组件生成的路径里命令字母与数字是相连的(如 "M190 88"),
|
|
44
|
+
所以按「命令字母」或「数字」逐个切,而不是只按空白切。 */
|
|
45
|
+
const tokens = d.match(/[MHQV]|-?\d*\.?\d+/g) || []
|
|
46
|
+
const points = []
|
|
47
|
+
let x = 0
|
|
48
|
+
let y = 0
|
|
49
|
+
let i = 0
|
|
50
|
+
while (i < tokens.length) {
|
|
51
|
+
const command = tokens[i++]
|
|
52
|
+
if (command === 'M') {
|
|
53
|
+
x = Number(tokens[i++]); y = Number(tokens[i++])
|
|
54
|
+
points.push([x, y])
|
|
55
|
+
} else if (command === 'H') {
|
|
56
|
+
const nx = Number(tokens[i++]); emitLine(points, x, y, nx, y, step); x = nx
|
|
57
|
+
} else if (command === 'V') {
|
|
58
|
+
const ny = Number(tokens[i++]); emitLine(points, x, y, x, ny, step); y = ny
|
|
59
|
+
} else if (command === 'Q') {
|
|
60
|
+
const qx = Number(tokens[i++]); const qy = Number(tokens[i++])
|
|
61
|
+
const nx = Number(tokens[i++]); const ny = Number(tokens[i++])
|
|
62
|
+
const segments = 48
|
|
63
|
+
for (let k = 0; k <= segments; k++) {
|
|
64
|
+
const u = k / segments
|
|
65
|
+
const v = 1 - u
|
|
66
|
+
points.push([
|
|
67
|
+
v * v * x + 2 * v * u * qx + u * u * nx,
|
|
68
|
+
v * v * y + 2 * v * u * qy + u * u * ny,
|
|
69
|
+
])
|
|
70
|
+
}
|
|
71
|
+
x = nx; y = ny
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error(`采样器不认识路径命令 "${command}"(path=${d})`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return points
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emitLine(points, x0, y0, x1, y1, step) {
|
|
80
|
+
const length = Math.hypot(x1 - x0, y1 - y0)
|
|
81
|
+
const count = Math.max(1, Math.ceil(length / step))
|
|
82
|
+
for (let k = 1; k <= count; k++) {
|
|
83
|
+
points.push([x0 + (x1 - x0) * k / count, y0 + (y1 - y0) * k / count])
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ---------------- 判定 ---------------- */
|
|
88
|
+
const INSET = 1 /* 允许贴边(箭头必须停在卡片左边框上),但不允许真正进入卡片内部 */
|
|
89
|
+
const STROKE_HALF = 2 /* 最宽的描边是 3.2px 流动虚线,半宽 1.6px */
|
|
90
|
+
|
|
91
|
+
function cardRects(layout) {
|
|
92
|
+
return [...layout.positions.entries()].map(([id, p]) => ({
|
|
93
|
+
id, x: p.x, y: p.y, w: p.width, h: p.height,
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function verify(name, nodes) {
|
|
98
|
+
const layout = layoutWorkflow(nodes)
|
|
99
|
+
const rects = cardRects(layout)
|
|
100
|
+
const failures = []
|
|
101
|
+
const tight = []
|
|
102
|
+
let sampled = 0
|
|
103
|
+
let bodyClearance = Infinity
|
|
104
|
+
|
|
105
|
+
for (const edge of layout.edges) {
|
|
106
|
+
const points = samplePath(edge.d)
|
|
107
|
+
sampled += points.length
|
|
108
|
+
/* 形状判据(硬约束之外的"不难看"约束):
|
|
109
|
+
本组件所有走线都是「右出 → 最多一次竖直转折 → 左进」,
|
|
110
|
+
所以 x 必须单调不减、y 的方向变化不得超过 1 次。
|
|
111
|
+
历史 bug:通道方向符号写反时会出现"越过目标再折返"——
|
|
112
|
+
折返段落在列空隙里,不压卡片,几何判据抓不到,但肉眼一看就是错的。 */
|
|
113
|
+
for (let i = 1; i < points.length; i++) {
|
|
114
|
+
if (points[i][0] < points[i - 1][0] - 0.01) {
|
|
115
|
+
failures.push(`${edge.key}: 第 ${i} 段 x 回退(${points[i - 1][0].toFixed(1)} → ${points[i][0].toFixed(1)})`)
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let previousDirection = 0
|
|
120
|
+
let reversals = 0
|
|
121
|
+
for (let i = 1; i < points.length; i++) {
|
|
122
|
+
const delta = points[i][1] - points[i - 1][1]
|
|
123
|
+
if (Math.abs(delta) < 0.01) continue
|
|
124
|
+
const direction = delta > 0 ? 1 : -1
|
|
125
|
+
if (previousDirection !== 0 && direction !== previousDirection) reversals += 1
|
|
126
|
+
previousDirection = direction
|
|
127
|
+
}
|
|
128
|
+
if (reversals > 1) {
|
|
129
|
+
failures.push(`${edge.key}: y 方向折返 ${reversals} 次(>1,线在通道处越过目标又折回)`)
|
|
130
|
+
}
|
|
131
|
+
/* 硬判据:任何采样点都不得进入任何卡片内部(含本条边自己的两端)。
|
|
132
|
+
间距指标只对「非本条边两端」的卡片计算——线本来就要贴到自己的
|
|
133
|
+
源/目标卡片边框上,把它们算进来指标会退化成排除阈值,自我满足。 */
|
|
134
|
+
for (const [px, py] of points) {
|
|
135
|
+
for (const r of rects) {
|
|
136
|
+
const insideX = px > r.x + INSET && px < r.x + r.w - INSET
|
|
137
|
+
const insideY = py > r.y + INSET && py < r.y + r.h - INSET
|
|
138
|
+
if (insideX && insideY) {
|
|
139
|
+
failures.push(`${edge.key}: (${px.toFixed(1)}, ${py.toFixed(1)}) 落在卡片 [${r.x},${r.y} ${r.w}x${r.h}] 内部`)
|
|
140
|
+
}
|
|
141
|
+
if (r.id === edge.sourceId || r.id === edge.targetId) continue
|
|
142
|
+
const dx = Math.max(r.x - px, 0, px - (r.x + r.w))
|
|
143
|
+
const dy = Math.max(r.y - py, 0, py - (r.y + r.h))
|
|
144
|
+
const clearance = Math.hypot(dx, dy)
|
|
145
|
+
if (clearance < bodyClearance) bodyClearance = clearance
|
|
146
|
+
if (clearance < STROKE_HALF) {
|
|
147
|
+
tight.push(`${edge.key}: (${px.toFixed(1)}, ${py.toFixed(1)}) 距「其它」卡片仅 ${clearance.toFixed(1)}px(线宽包络 ${STROKE_HALF}px)`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ok = failures.length === 0 && tight.length === 0
|
|
154
|
+
console.log(
|
|
155
|
+
`${ok ? '✓' : '✗'} ${name}\n` +
|
|
156
|
+
` 节点 ${nodes.length} 边 ${layout.edges.length} 行间通道 ${layout.channelCount} 图高 ${layout.height}px 采样点 ${sampled}` +
|
|
157
|
+
`${Number.isFinite(bodyClearance) ? ` 中段最小间距 ${bodyClearance.toFixed(1)}px` : ''}`,
|
|
158
|
+
)
|
|
159
|
+
for (const failure of [...failures, ...tight].slice(0, 6)) console.log(` ! ${failure}`)
|
|
160
|
+
const extra = failures.length + tight.length - 6
|
|
161
|
+
if (extra > 0) console.log(` … 另有 ${extra} 处`)
|
|
162
|
+
return ok
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ---------------- 用例 ---------------- */
|
|
166
|
+
const node = (id, dependsOn = [], status = 'completed') => ({
|
|
167
|
+
id, title: id, prompt: '', status, dependsOn,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/* 真实跑过的工作流 wf_0f3d1ebb:host-contract→port-ui 是跨 2 列的长边 */
|
|
171
|
+
const realOne = [
|
|
172
|
+
node('host-contract'),
|
|
173
|
+
node('workbench-inventory'),
|
|
174
|
+
node('scaffold', ['host-contract']),
|
|
175
|
+
node('port-ui', ['host-contract', 'workbench-inventory', 'scaffold']),
|
|
176
|
+
node('verify', ['scaffold', 'port-ui']),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
/* 真实跑过的工作流 wf_4254276f */
|
|
180
|
+
const realTwo = [
|
|
181
|
+
node('shanghai-weather'),
|
|
182
|
+
node('beijing-weather'),
|
|
183
|
+
node('typhoon-china'),
|
|
184
|
+
node('report', ['shanghai-weather', 'beijing-weather', 'typhoon-china']),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
/* 压力用例:6 列 x 3 行,大量跨列长边 */
|
|
188
|
+
const stress = [
|
|
189
|
+
node('a'), node('b'), node('c'),
|
|
190
|
+
node('d', ['a']), node('e', ['b']), node('f', ['c']),
|
|
191
|
+
node('g', ['a', 'd']), node('h', ['b', 'e']), node('i', ['c', 'f']),
|
|
192
|
+
node('j', ['a', 'g']), node('k', ['b', 'h']), node('l', ['c', 'i']),
|
|
193
|
+
node('m', ['a', 'j']), node('n', ['b', 'k']), node('o', ['c', 'l']),
|
|
194
|
+
node('p', ['a', 'm']), node('q', ['b', 'n']), node('r', ['c', 'o']),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
const cases = [
|
|
198
|
+
['真实工作流 wf_0f3d1ebb(含跨 2 列长边)', realOne],
|
|
199
|
+
['真实工作流 wf_4254276f(三路汇合)', realTwo],
|
|
200
|
+
['单节点(无边)', [node('solo')]],
|
|
201
|
+
['相邻两列同排(短边退化直线)', [node('a'), node('b', ['a'])]],
|
|
202
|
+
['相邻两列错排(短边 S 形)', [node('a'), node('b'), node('c', ['a'])]],
|
|
203
|
+
['压力用例(6 列 x 3 行,大量长边)', stress],
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
let allOk = true
|
|
207
|
+
for (const [name, nodes] of cases) allOk = verify(name, nodes) && allOk
|
|
208
|
+
|
|
209
|
+
rmSync(outfile, { force: true })
|
|
210
|
+
|
|
211
|
+
console.log(allOk
|
|
212
|
+
? '\n全部通过:所有连线均未覆盖任何节点卡片。'
|
|
213
|
+
: '\n存在压线,见上方明细。')
|
|
214
|
+
process.exit(allOk ? 0 : 1)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* codex-runner.js — codex 执行适配器(真实链路)
|
|
3
|
+
* - @openai/codex-sdk(Codex → startThread → runStreamed)
|
|
4
|
+
* ThreadItem 快照直接透传(reasoning / command_execution / file_change /
|
|
5
|
+
* agent_message / error / mcp_tool_call / web_search / todo_list)
|
|
6
|
+
* - 认证:完全复用本机 codex 登录态(~/.codex/auth.json)。订阅登录由
|
|
7
|
+
* `codex login` 走官方 OAuth 写入,SDK 原样使用;这里只做只读探测,
|
|
8
|
+
* 不代管 token、不刷新、不转发。
|
|
9
|
+
* - 缺省模型来自 defaults.js(gpt-5.6-sol);订阅登录下服务端拒绝的
|
|
10
|
+
* slug 会翻译成可操作的报错,而不是让节点静默 failed。
|
|
11
|
+
* ============================================================ */
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const { DEFAULT_MODEL, authState, authGuidance } = require('./defaults.js');
|
|
15
|
+
const { createTurnState, reduceEvent, finalizeTurn } = require('./turn-reduce.js');
|
|
16
|
+
const { join, dirname } = require('node:path');
|
|
17
|
+
const { existsSync } = require('node:fs');
|
|
18
|
+
|
|
19
|
+
/** 把 codex 的原始报错翻译成「下一步做什么」。 */
|
|
20
|
+
function explainFailure(message, model) {
|
|
21
|
+
const text = String(message || '');
|
|
22
|
+
if (/not supported when using Codex with a ChatGPT account/i.test(text)) {
|
|
23
|
+
return `${text}\n模型 "${model}" 与当前 ChatGPT 订阅登录不兼容;`
|
|
24
|
+
+ `改用订阅可用的 slug(缺省 ${DEFAULT_MODEL}),或改用 API key 登录(OPENAI_API_KEY)。`;
|
|
25
|
+
}
|
|
26
|
+
if (/\b401\b|unauthorized|invalid_grant|token.*expired/i.test(text)) {
|
|
27
|
+
return `${text}\ncodex 登录态可能已失效:重新执行 \`codex login\`。`;
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* 从本文件所在目录逐层上溯,找第一个含 @openai/codex-sdk 的 node_modules。
|
|
33
|
+
* 不能写死 <插件>/node_modules/…:安装进 DSH profile 后依赖会被 pnpm hoisted
|
|
34
|
+
* 提升到 profile 根,插件目录下的 node_modules 并不存在,写死就会「装完即坏」。
|
|
35
|
+
* 包是 ESM-only(exports 只有 import 条件),require.resolve 用不上,所以按已知入口探测。 */
|
|
36
|
+
function findCodexSdk() {
|
|
37
|
+
const tail = ['@openai', 'codex-sdk', 'dist', 'index.js'];
|
|
38
|
+
const roots = [];
|
|
39
|
+
if (process.env.DSH_CODEX_MODULES) roots.push(process.env.DSH_CODEX_MODULES);
|
|
40
|
+
let dir = __dirname;
|
|
41
|
+
for (;;) {
|
|
42
|
+
roots.push(dir);
|
|
43
|
+
const parent = dirname(dir);
|
|
44
|
+
if (parent === dir) break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
/* 两种布局都要覆盖:
|
|
49
|
+
* root 是普通目录 → 包在 <root>/node_modules/…
|
|
50
|
+
* root 本身是 node_modules(上溯途中会路过它)→ 包直接在 <root>/… */
|
|
51
|
+
for (const base of [join(root, 'node_modules'), root]) {
|
|
52
|
+
const candidate = join(base, ...tail);
|
|
53
|
+
if (existsSync(candidate)) return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function createRunner() {
|
|
60
|
+
const { pathToFileURL } = await import('node:url');
|
|
61
|
+
const sdkPath = findCodexSdk();
|
|
62
|
+
if (!sdkPath) {
|
|
63
|
+
throw new Error('未找到 @openai/codex-sdk:请先安装本插件依赖,或设置 DSH_CODEX_MODULES 指向含该包的 node_modules');
|
|
64
|
+
}
|
|
65
|
+
const sdk = await import(pathToFileURL(sdkPath).href);
|
|
66
|
+
const { Codex } = sdk;
|
|
67
|
+
|
|
68
|
+
/* 优先使用 SDK 自带 CLI 二进制;平台包缺失时回退本机 codex CLI(codexPathOverride) */
|
|
69
|
+
let codex;
|
|
70
|
+
try {
|
|
71
|
+
codex = new Codex();
|
|
72
|
+
} catch {
|
|
73
|
+
const { access, constants } = await import('node:fs/promises');
|
|
74
|
+
const localCli = process.env.DSH_CODEX_CLI || '/opt/homebrew/bin/codex';
|
|
75
|
+
try { await access(localCli, constants.X_OK); } catch {
|
|
76
|
+
throw new Error('SDK 平台二进制不可用,且未找到可执行的本机 codex CLI(可设置 DSH_CODEX_CLI)');
|
|
77
|
+
}
|
|
78
|
+
codex = new Codex({ codexPathOverride: localCli });
|
|
79
|
+
}
|
|
80
|
+
/* 派发前预检登录态:没有登录态就不必烧一个必然失败的 turn */
|
|
81
|
+
const guidance = authGuidance();
|
|
82
|
+
if (guidance) throw new Error(guidance);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
mode: 'real',
|
|
86
|
+
defaultModel: DEFAULT_MODEL,
|
|
87
|
+
auth: authState(),
|
|
88
|
+
async run(def, callbacks, signal, { sessionId = null, prompt = def.prompt } = {}) {
|
|
89
|
+
const model = def.model || DEFAULT_MODEL;
|
|
90
|
+
const threadOptions = {
|
|
91
|
+
model,
|
|
92
|
+
workingDirectory: def.cwd,
|
|
93
|
+
sandboxMode: def.sandboxMode,
|
|
94
|
+
approvalPolicy: def.approvalPolicy,
|
|
95
|
+
modelReasoningEffort: def.reasoningEffort,
|
|
96
|
+
skipGitRepoCheck: true,
|
|
97
|
+
};
|
|
98
|
+
const thread = sessionId
|
|
99
|
+
? codex.resumeThread(sessionId, threadOptions)
|
|
100
|
+
: codex.startThread(threadOptions);
|
|
101
|
+
/* 新 thread 的 id 只有 thread.started 之后才存在;resume 的 id 已知。 */
|
|
102
|
+
if (sessionId) callbacks.onSessionId(sessionId);
|
|
103
|
+
/* 终局判定在 turn-reduce.js(纯函数,可单测):已自愈的传输恢复不算失败 */
|
|
104
|
+
let state = createTurnState();
|
|
105
|
+
let errorSeq = 0;
|
|
106
|
+
try {
|
|
107
|
+
const { events } = await thread.runStreamed(prompt, { signal });
|
|
108
|
+
for await (const event of events) {
|
|
109
|
+
if (process.env.DSH_CODEX_DEBUG_EVENTS) {
|
|
110
|
+
console.error('[codex-event]', JSON.stringify({ type: event.type, item: event.item?.type || null }));
|
|
111
|
+
}
|
|
112
|
+
if (event.type === 'thread.started' && event.thread_id) callbacks.onSessionId(event.thread_id);
|
|
113
|
+
state = reduceEvent(state, event, message => explainFailure(message, model));
|
|
114
|
+
if ((event.type === 'turn.failed' || event.type === 'error') && state.turnError) {
|
|
115
|
+
callbacks.onItem({ type: 'error', id: `item_error_${++errorSeq}`, message: state.turnError });
|
|
116
|
+
} else if (event.item) {
|
|
117
|
+
callbacks.onItem({ ...event.item, live: event.type !== 'item.completed' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (signal?.aborted) return { status: 'cancelled', finalMessage: null, usage: null };
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
return finalizeTurn(state);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { createRunner, explainFailure };
|