@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.
@@ -0,0 +1,175 @@
1
+ /* eslint-disable no-console */
2
+ 'use strict';
3
+ /* persistence.test.cjs — 落盘与跨重启回读
4
+ *
5
+ * 复现的原始缺陷:workflow 只活在进程内存,进程重启后
6
+ * `/api/state?workflowId=` 返回「workflow 不存在」,调用者无法区分
7
+ * 「没派发过」和「派发过但被重启清掉了」。 */
8
+ const assert = require('node:assert');
9
+ const { mkdtempSync, rmSync, writeFileSync, readFileSync } = require('node:fs');
10
+ const { tmpdir } = require('node:os');
11
+ const { join } = require('node:path');
12
+ const { Orchestrator } = require('./orchestrator.js');
13
+ const { Persistence } = require('./persistence.js');
14
+
15
+ const tmp = () => mkdtempSync(join(tmpdir(), 'codex-persist-'));
16
+ const sleep = ms => new Promise(done => setTimeout(done, ms));
17
+ const YAML = [
18
+ 'apiVersion: codex.dsh/v1', 'kind: workflow', 'title: 落盘验证', 'concurrency: 2',
19
+ 'nodes:', ' - id: a', ' title: A', ' prompt: 正常', ' - id: b', ' title: B',
20
+ ' prompt: 正常', ' dependsOn: [a]', '',
21
+ ].join('\n');
22
+
23
+ function runner() {
24
+ return {
25
+ defaultModel: 'gpt-test',
26
+ async run(def, cb) {
27
+ cb.onSessionId('sess_' + def.id);
28
+ cb.onItem({ type: 'agent_message', id: 'item_0', text: `done ${def.id}` });
29
+ return { status: 'completed', finalMessage: `最终 ${def.id}`, usage: { input_tokens: 7, output_tokens: 3 } };
30
+ },
31
+ };
32
+ }
33
+
34
+ async function waitState(orch, id, want) {
35
+ for (let i = 0; i < 200; i++) {
36
+ const snap = orch.snapshot(id);
37
+ if (snap && snap.state === want) return snap;
38
+ await sleep(10);
39
+ }
40
+ throw new Error(`超时未达到 ${want}`);
41
+ }
42
+
43
+ (async () => {
44
+ const dir = tmp();
45
+ const file = join(dir, 'workflows.jsonl');
46
+ try {
47
+ /* 1. 终态落盘 → 新实例(模拟重启)能查到 */
48
+ {
49
+ const first = new Orchestrator(runner(), { persistFile: file });
50
+ const { workflowId } = first.dispatch(YAML, { defaultCwd: '/workspace/persisted-project' });
51
+ await waitState(first, workflowId, 'completed');
52
+ assert.ok(first.snapshot(workflowId).nodes.every(n => n.status === 'completed'));
53
+
54
+ /* 全新实例 = 进程重启:内存为空,必须靠磁盘回读 */
55
+ const second = new Orchestrator(runner(), { persistFile: file });
56
+ const snap = second.snapshot(workflowId);
57
+ assert.ok(snap, '重启后应能从磁盘查到 workflow');
58
+ assert.strictEqual(snap.title, '落盘验证');
59
+ assert.strictEqual(snap.state, 'completed');
60
+ const a = snap.nodes.find(n => n.id === 'a');
61
+ assert.strictEqual(a.status, 'completed');
62
+ assert.strictEqual(a.finalMessage, '最终 a', 'finalMessage 必须跨重启保留');
63
+ assert.deepStrictEqual(a.usage, { input_tokens: 7, output_tokens: 3 }, 'usage 必须跨重启保留');
64
+ assert.deepStrictEqual(a.dependsOn, [], '扁平回读也要还原 dependsOn');
65
+ assert.strictEqual(a.turns.length, 1, '每轮对话必须跨重启保留');
66
+ assert.strictEqual(a.turns[0].input, '正常');
67
+ assert.strictEqual(a.cwd, '/workspace/persisted-project', '继承的会话 cwd 必须跨重启保留');
68
+ assert.strictEqual(snap.cwd, '/workspace/persisted-project', 'workflow cwd 必须跨重启保留');
69
+ assert.ok(a.turns[0].items.some(item => item.type === 'agent_message'));
70
+ console.log('✓ 终态落盘,重启后可查(含 finalMessage / usage)');
71
+ }
72
+
73
+ /* 2. list() 重启后也能看到历史 */
74
+ {
75
+ const third = new Orchestrator(runner(), { persistFile: file });
76
+ const list = third.list();
77
+ assert.ok(list.length >= 1, 'list() 应包含磁盘历史');
78
+ assert.ok(list.every(w => w.workflowId && typeof w.state === 'string'));
79
+ console.log('✓ list() 重启后包含磁盘历史');
80
+ }
81
+
82
+ /* 3. nodePatch 对「重启前创建的 workflow」仍可用 */
83
+ {
84
+ const fresh = new Orchestrator(runner(), { persistFile: file });
85
+ const known = fresh.list()[0].workflowId;
86
+ const patch = [
87
+ 'apiVersion: codex.dsh/v1', 'kind: nodePatch', `workflowId: ${known}`,
88
+ 'nodes:', ' - id: c', ' title: C', ' prompt: 正常', '',
89
+ ].join('\n');
90
+ const res = fresh.dispatch(patch);
91
+ assert.deepStrictEqual(res.created, ['c'], '重启后仍能向历史 workflow 追加节点');
92
+ console.log('✓ 重启后 nodePatch 仍可用');
93
+ }
94
+
95
+ /* 4. 半行 / 损坏行不让整个历史失效 */
96
+ {
97
+ const broken = join(tmp(), 'broken.jsonl');
98
+ const ok = new Orchestrator(runner(), { persistFile: broken });
99
+ const { workflowId } = ok.dispatch(YAML);
100
+ await waitState(ok, workflowId, 'completed');
101
+ writeFileSync(broken, readFileSync(broken, 'utf8') + '{"type":"node","workflowId":"wf_x",\n');
102
+ const after = new Orchestrator(runner(), { persistFile: broken });
103
+ assert.ok(after.snapshot(workflowId), '截断行不应让已写入的历史不可读');
104
+ console.log('✓ 截断行被跳过,历史仍可读');
105
+ }
106
+
107
+ /* 5. 写入失败不打断编排(目录不可写) */
108
+ {
109
+ const ro = new Orchestrator(runner(), { persistFile: '/proc/definitely/not/writable/x.jsonl' });
110
+ const { workflowId } = ro.dispatch(YAML);
111
+ const snap = await waitState(ro, workflowId, 'completed');
112
+ assert.strictEqual(snap.state, 'completed', '落盘失败也必须正常跑完');
113
+ console.log('✓ 落盘失败不影响工作流执行');
114
+ }
115
+
116
+ /* 6. persist:false 完全不落盘(测试隔离所依赖的行为) */
117
+ {
118
+ const off = new Orchestrator(runner(), { persist: false });
119
+ const { workflowId } = off.dispatch(YAML);
120
+ await waitState(off, workflowId, 'completed');
121
+ assert.strictEqual(off.persistence.enabled, false);
122
+ assert.strictEqual(off.list().length, 1, '关盘时不应读入任何历史');
123
+ console.log('✓ persist:false 完全关盘');
124
+ }
125
+
126
+ /* 7. Persistence 单元:损坏行 / 无 workflow 的 node 行 */
127
+ {
128
+ const p = new Persistence(file);
129
+ const loaded = p.load();
130
+ assert.ok(loaded instanceof Map);
131
+ const lines = readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
132
+ assert.ok(lines.some(l => l.type === 'workflow' && l.workflowId));
133
+ assert.ok(lines.some(l => l.type === 'node' && l.node?.id));
134
+ console.log('✓ Persistence.load() 结构与内容正确');
135
+ }
136
+
137
+ /* 8. 删除节点写入墓碑,重启后不得被更早的 node 记录复活 */
138
+ {
139
+ let releaseRoot;
140
+ const gated = {
141
+ defaultModel: 'gpt-test',
142
+ async run(def, cb) {
143
+ cb.onSessionId('sess_' + def.id);
144
+ if (def.id !== 'root') return { status: 'completed', finalMessage: 'done' };
145
+ return new Promise(resolve => {
146
+ releaseRoot = () => resolve({ status: 'completed', finalMessage: 'done root' });
147
+ });
148
+ },
149
+ };
150
+ const tombstoneFile = join(dir, 'tombstones.jsonl');
151
+ const first = new Orchestrator(gated, { persistFile: tombstoneFile });
152
+ const { workflowId } = first.dispatch([
153
+ 'apiVersion: codex.dsh/v1', 'kind: workflow', 'title: 删除落盘', 'concurrency: 1',
154
+ 'nodes:', ' - id: root', ' prompt: root', ' - id: future', ' prompt: future',
155
+ ' dependsOn: [root]', '',
156
+ ].join('\n'));
157
+ const result = first.dispatch([
158
+ 'apiVersion: codex.dsh/v1', 'kind: nodePatch', `workflowId: ${workflowId}`,
159
+ 'remove: [future]', '',
160
+ ].join('\n'));
161
+ assert.deepStrictEqual(result.deleted, ['future']);
162
+ releaseRoot();
163
+ await waitState(first, workflowId, 'completed');
164
+
165
+ const restarted = new Orchestrator(runner(), { persistFile: tombstoneFile });
166
+ assert.deepStrictEqual(restarted.snapshot(workflowId).nodes.map(n => n.id), ['root']);
167
+ assert.ok(readFileSync(tombstoneFile, 'utf8').includes('nodeDelete'));
168
+ console.log('✓ 节点删除墓碑跨重启生效');
169
+ }
170
+
171
+ console.log('\n落盘测试全部通过');
172
+ } finally {
173
+ rmSync(dir, { recursive: true, force: true });
174
+ }
175
+ })().catch(error => { console.error(error); process.exit(1); });
@@ -0,0 +1,312 @@
1
+ /* ============================================================
2
+ * plugin.mjs — DSH 插件:codex_orchestrate
3
+ * - ctx.tools.register(defineTool(...)):暴露 codex_orchestrate tool
4
+ * (入参 = workflow YAML 文本;增量派发;异步 + 进度可查)
5
+ * - ctx.webServer.register:
6
+ * POST /codex-orchestrate/api/dispatch { yaml } → tool 同款入口
7
+ * GET /codex-orchestrate/api/workflows → 工作流清单(UI 自动发现最新)
8
+ * GET /codex-orchestrate/api/state?workflowId= → 进度快照
9
+ * POST /codex-orchestrate/api/message { workflowId, nodeId, text } → 续接节点会话
10
+ * POST /codex-orchestrate/api/cancel { workflowId, nodeId }
11
+ * GET /codex-orchestrate/api/events?workflowId= → SSE 事件流
12
+ * /codex-orchestrate/* → 本目录静态 UI
13
+ * 无硬依赖:不 import @deepseek-ai/dsh-tools(桌面端 profile 无法解析它),
14
+ * 本地等价构建 defineTool 的 registry-ready 对象(纯 JSON Schema + 校验包装)。
15
+ * 运行模式:真实 @openai/codex-sdk 链路。
16
+ * ============================================================ */
17
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { Orchestrator, SCHEMA } = require('./orchestrator.js');
23
+ const { parseWorkflowYaml } = require('./validate.js');
24
+ const { createRunner } = require('./codex-runner.js');
25
+ const { DEFAULT_MODEL, authState } = require('./defaults.js');
26
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+
28
+ /* 本地 defineTool:与 @deepseek-ai/dsh-tools 的 registry-ready 输出等价 */
29
+ function defineTool({ name, description, parameters, output, execute }) {
30
+ return {
31
+ name,
32
+ description,
33
+ parameters: {
34
+ type: 'object',
35
+ properties: Object.fromEntries(
36
+ Object.entries(parameters).map(([key, spec]) => {
37
+ const { required: _flag, ...schema } = spec;
38
+ return [key, schema];
39
+ })
40
+ ),
41
+ required: Object.entries(parameters).filter(([, spec]) => spec.required).map(([key]) => key),
42
+ additionalProperties: false,
43
+ },
44
+ output: { schema: output.schema, render: output.render },
45
+ async execute(args, exec) {
46
+ if (typeof args?.yaml !== 'string' || !args.yaml.trim()) {
47
+ throw new Error('codex_orchestrate 参数错误:yaml 必须为非空字符串');
48
+ }
49
+ return execute(args, exec);
50
+ },
51
+ };
52
+ }
53
+
54
+ /* ToolRunContext 携带发起会话的权威 cwd;不要回退宿主 Electron 进程目录。 */
55
+ export function executionCwd(exec) {
56
+ const cwd = exec?.agent?.session?.header?.cwd;
57
+ return typeof cwd === 'string' && isAbsolute(cwd) ? cwd : null;
58
+ }
59
+
60
+ export const name = 'codex-orchestrate';
61
+ export const inject = ['tools', 'webServer'];
62
+
63
+ let orchestrator = null;
64
+ let runnerInfo = null; // createRunner() 的能力声明:缺省模型 + 登录态(只读探测)
65
+ const eventLogs = new Map(); // workflowId -> events[](SSE 补发用)
66
+
67
+ async function ensureOrchestrator() {
68
+ if (orchestrator) return orchestrator;
69
+ const runner = await createRunner();
70
+ runnerInfo = { defaultModel: runner.defaultModel, auth: runner.auth };
71
+ orchestrator = new Orchestrator(runner, { persistFile: stateFile() });
72
+ orchestrator.on(event => {
73
+ const log = eventLogs.get(event.workflowId) || [];
74
+ log.push({ ...event, timestamp: new Date().toISOString() });
75
+ eventLogs.set(event.workflowId, log);
76
+ });
77
+ return orchestrator;
78
+ }
79
+
80
+ /* 只读查询用的同步 getter:不构造 runner(那要碰 codex 登录态/依赖),
81
+ * 只建一个空 Orchestrator 以便从磁盘回读历史。
82
+ * 没有它的话,进程刚重启、还没派发过任何东西时 orchestrator 仍是 null,
83
+ * 查询接口会短路成「空列表 / workflow 不存在」——正是要修的那个 bug。 */
84
+ let readonlyOrchestrator = null;
85
+ function queryOrchestrator() {
86
+ if (orchestrator) return orchestrator;
87
+ if (!readonlyOrchestrator) {
88
+ readonlyOrchestrator = new Orchestrator(
89
+ { defaultModel: DEFAULT_MODEL, async run() { throw new Error('只读实例不执行节点'); } },
90
+ { persistFile: stateFile() }
91
+ );
92
+ }
93
+ return readonlyOrchestrator;
94
+ }
95
+
96
+ /* workflow 状态落盘位置:放在 DSH_HOME 隔离目录下,进程重启后历史仍可查。
97
+ * 注意 DSH_HOME 本身已经是 <project>/.runtime/codex-orchestrate
98
+ * (见 start-codex-orchestrate.mjs),所以这里**不再**拼 'codex-orchestrate',
99
+ * 否则会多一层变成 …/codex-orchestrate/codex-orchestrate。 */
100
+ function stateFile() {
101
+ const dir = process.env.DSH_CODEX_STATE_DIR
102
+ || process.env.DSH_HOME
103
+ || join(root, '.runtime', 'codex-orchestrate');
104
+ return join(dir, 'workflows.jsonl');
105
+ }
106
+
107
+ /** 模型 / 登录态事实:派发前就能回答「谁在授权、缺省用哪个模型」。 */
108
+ function runtimeStatus() {
109
+ return {
110
+ mode: 'real',
111
+ defaultModel: runnerInfo?.defaultModel ?? DEFAULT_MODEL,
112
+ auth: runnerInfo?.auth ?? authState(),
113
+ authEnvOverride: Boolean(process.env.DSH_CODEX_MODEL),
114
+ instance: instanceIdentity(),
115
+ };
116
+ }
117
+
118
+ /* 本进程的对外身份:派发结果必须能定位到「哪个实例、哪个端口」。
119
+ * 没有这个字段时,调用者拿到裸路径只能靠猜端口——真实踩过这个坑。 */
120
+ const startedAt = new Date().toISOString();
121
+ const instanceId = `co_${process.pid}_${Math.random().toString(16).slice(2, 10)}`;
122
+
123
+ function listeningOrigin() {
124
+ /* 优先用启动参数里的 --host/--port(webServer 不一定暴露自身地址) */
125
+ const argv = process.argv;
126
+ const portFlag = argv.indexOf('--port');
127
+ const hostFlag = argv.indexOf('--host');
128
+ const port = portFlag >= 0 ? argv[portFlag + 1] : null;
129
+ const host = hostFlag >= 0 ? argv[hostFlag + 1] : '127.0.0.1';
130
+ if (port) return `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
131
+ return process.env.DSH_PUBLIC_ORIGIN || null;
132
+ }
133
+
134
+ function progressUrl(workflowId) {
135
+ const origin = listeningOrigin();
136
+ const path = `/codex-orchestrate/api/state?workflowId=${workflowId}`;
137
+ return origin ? origin + path : path;
138
+ }
139
+
140
+ function instanceIdentity() {
141
+ return {
142
+ instanceId,
143
+ pid: process.pid,
144
+ origin: listeningOrigin(),
145
+ startedAt,
146
+ /* 帮助调用者识别「我查的是不是派发我的那个实例」:
147
+ * 与本实例 startedAt 不符 = 进程重启过,内存态已丢失(磁盘历史仍可查)。
148
+ * 这里直接算路径,不读 orchestrator —— /api/status 会在派发前被调用,
149
+ * 那时 orchestrator 还是 null,之前会错误地回传 stateFile: null。 */
150
+ stateFile: stateFile(),
151
+ };
152
+ }
153
+
154
+ function publicResult(result, orch = orchestrator) {
155
+ if (result.operation === 'query') {
156
+ return {
157
+ ...result,
158
+ mode: 'real',
159
+ defaultModel: runnerInfo?.defaultModel ?? DEFAULT_MODEL,
160
+ auth: runnerInfo?.auth ?? authState(),
161
+ instance: instanceIdentity(),
162
+ };
163
+ }
164
+ const nodes = orch.snapshot(result.workflowId)?.nodes
165
+ .map(({ id, title, status, dependsOn, sessionId, error, model, cwd }) => ({ id, title, status, dependsOn, sessionId, error, model, cwd })) || [];
166
+ return {
167
+ ...result,
168
+ nodes,
169
+ mode: 'real',
170
+ defaultModel: runnerInfo?.defaultModel ?? DEFAULT_MODEL,
171
+ auth: runnerInfo?.auth ?? authState(),
172
+ note: '已异步派发到真实 codex(@openai/codex-sdk),认证复用本机 codex 登录态。',
173
+ /* progressUrl 是**绝对地址**,可直接 GET;progress 保留相对路径兼容旧调用方。 */
174
+ progress: `/codex-orchestrate/api/state?workflowId=${result.workflowId}`,
175
+ progressUrl: progressUrl(result.workflowId),
176
+ instance: instanceIdentity(),
177
+ report: '工作流结束后,通过 progressUrl 获取各节点 finalMessage 与 usage 汇总。',
178
+ };
179
+ }
180
+
181
+ /* query 不需要初始化 Codex SDK;create/update/delete 才需要 runner。
182
+ * 在工具边界只做一次轻量分类,权威校验仍由 Orchestrator.dispatch 完成。 */
183
+ async function executeYaml(yaml, { defaultCwd = null } = {}) {
184
+ const kind = parseWorkflowYaml(yaml, SCHEMA, { allowExternalDeps: true }).kind;
185
+ const orch = kind === 'workflowQuery' ? queryOrchestrator() : await ensureOrchestrator();
186
+ return publicResult(orch.dispatch(yaml, { defaultCwd }), orch);
187
+ }
188
+
189
+ export function apply(ctx) {
190
+ /* ---------------- codex_orchestrate tool ---------------- */
191
+ ctx.tools.register(defineTool({
192
+ name: 'codex_orchestrate',
193
+ description:
194
+ '按 YAML schema 创建、查询和动态修改 codex 工作流。kind=workflow 创建;kind=workflowQuery 查询单个工作流或清单;'
195
+ + 'kind=nodePatch 可在运行中通过 nodes 新增/更新未来节点,并通过 remove 真正删除未来节点。'
196
+ + 'running、completed、failed、cancelled 节点不可修改或删除;补丁会校验最终 DAG,并以原子方式整包提交或拒绝。'
197
+ + '每个节点是一个独立 codex session,按 dependsOn 依赖调度、并发执行。'
198
+ + '下游首轮会自动获得直接依赖节点的终态结果;未写 cwd 的节点继承当前 DSH 会话工作目录;'
199
+ + 'sandboxMode 缺省为 danger-full-access,也可由 DSH 在节点 YAML 中覆盖。'
200
+ + '调用立即返回 workflowId 与进度接口;结果异步推进,可随时查询。'
201
+ + 'YAML 格式见 /codex-orchestrate/schema/workflow.schema.md。',
202
+ parameters: {
203
+ yaml: { type: 'string', required: true, description: 'workflow YAML 文档(apiVersion: codex.dsh/v1)' },
204
+ },
205
+ output: {
206
+ schema: { type: 'object' },
207
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
208
+ },
209
+ async execute(args, exec) {
210
+ try {
211
+ return await executeYaml(args.yaml, { defaultCwd: executionCwd(exec) });
212
+ } catch (error) {
213
+ throw new Error(`codex_orchestrate 操作被拒绝:${error.message}`);
214
+ }
215
+ },
216
+ }));
217
+
218
+ /* ---------------- HTTP ---------------- */
219
+ const sendJson = (res, code, data) => {
220
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
221
+ res.end(JSON.stringify(data));
222
+ };
223
+ const readBody = async (req, limit = 262144) => {
224
+ let body = '';
225
+ for await (const chunk of req) { body += chunk; if (body.length > limit) throw new Error('Request too large'); }
226
+ return JSON.parse(body);
227
+ };
228
+ const sameOrigin = req => !req.headers.origin || new URL(req.headers.origin).host === req.headers.host;
229
+
230
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/dispatch', async handler(req, res) {
231
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'POST required' });
232
+ try {
233
+ if (!sameOrigin(req)) return sendJson(res, 403, { error: 'Same-origin only' });
234
+ const input = await readBody(req);
235
+ if (typeof input.yaml !== 'string' || !input.yaml.trim()) throw new Error('yaml required');
236
+ sendJson(res, 200, await executeYaml(input.yaml));
237
+ } catch (error) { sendJson(res, 400, { error: error.message }); }
238
+ } });
239
+
240
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/workflows', async handler(req, res) {
241
+ sendJson(res, 200, {
242
+ /* 用 queryOrchestrator():进程重启后即便还没派发过,也要能从磁盘列出历史 */
243
+ workflows: queryOrchestrator().list(),
244
+ mode: 'real',
245
+ instance: instanceIdentity(),
246
+ });
247
+ } });
248
+
249
+ /* 只读状态:缺省模型 + 当前 codex 登录态(不触发 codex 构造,未派发也能看) */
250
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/status', async handler(req, res) {
251
+ sendJson(res, 200, runtimeStatus());
252
+ } });
253
+
254
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/state', async handler(req, res) {
255
+ const workflowId = new URL(req.url, 'http://127.0.0.1').searchParams.get('workflowId');
256
+ /* queryOrchestrator() 而非 orchestrator?.:重启后未派发时也要能查到磁盘历史 */
257
+ const orch = queryOrchestrator();
258
+ const snap = orch.snapshot(workflowId);
259
+ /* 404 必须自带可诊断信息:调用者要能分辨「没派发过」和「查错了实例」。
260
+ * 之前只回一句 "workflow 不存在",导致把一个正常的跨实例查询误判成链路故障。 */
261
+ if (!snap) return sendJson(res, 404, {
262
+ error: 'workflow 不存在',
263
+ workflowId: workflowId || null,
264
+ instance: instanceIdentity(),
265
+ known: orch.list().map(w => w.workflowId),
266
+ hint: '确认 host:port 是否为本实例的 origin;历史 workflow 由磁盘回读,若仍缺失说明该实例从未派发过它。',
267
+ });
268
+ sendJson(res, 200, { ...snap, mode: 'real', instance: instanceIdentity(), events: eventLogs.get(workflowId) || [] });
269
+ } });
270
+
271
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/message', async handler(req, res) {
272
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'POST required' });
273
+ try {
274
+ if (!sameOrigin(req)) return sendJson(res, 403, { error: 'Same-origin only' });
275
+ const input = await readBody(req);
276
+ if (typeof input.workflowId !== 'string' || typeof input.nodeId !== 'string') {
277
+ throw new Error('workflowId 与 nodeId 必填');
278
+ }
279
+ if (typeof input.text !== 'string') throw new Error('text 必须为字符串');
280
+ const orch = await ensureOrchestrator();
281
+ const result = orch.sendMessage(input.workflowId, input.nodeId, input.text, {
282
+ clientMessageId: typeof input.clientMessageId === 'string' ? input.clientMessageId : null,
283
+ });
284
+ sendJson(res, result.duplicate ? 200 : 202, result);
285
+ } catch (error) { sendJson(res, error.statusCode || 400, { error: error.message }); }
286
+ } });
287
+
288
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/cancel', async handler(req, res) {
289
+ if (req.method !== 'POST') return sendJson(res, 405, { error: 'POST required' });
290
+ try {
291
+ if (!sameOrigin(req)) return sendJson(res, 403, { error: 'Same-origin only' });
292
+ const input = await readBody(req);
293
+ orchestrator.cancelNode(input.workflowId, input.nodeId);
294
+ sendJson(res, 200, { cancelled: true });
295
+ } catch (error) { sendJson(res, 400, { error: error.message }); }
296
+ } });
297
+
298
+ /* ---------------- SSE 进度流 ---------------- */
299
+ ctx.webServer.register({ kind: 'exact', path: '/codex-orchestrate/api/events', async handler(req, res) {
300
+ const workflowId = new URL(req.url, 'http://127.0.0.1').searchParams.get('workflowId');
301
+ if (!orchestrator || !orchestrator.snapshot(workflowId)) return sendJson(res, 404, { error: 'workflow 不存在' });
302
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
303
+ for (const event of eventLogs.get(workflowId) || []) res.write(`data: ${JSON.stringify(event)}\n\n`);
304
+ const off = orchestrator.on(event => { if (event.workflowId === workflowId) res.write(`data: ${JSON.stringify(event)}\n\n`); });
305
+ const ping = setInterval(() => res.write(': ping\n\n'), 15000);
306
+ req.on('close', () => { clearInterval(ping); off(); });
307
+ } });
308
+
309
+ /* 工作台 UI 已迁移为 dsh.client 原生 React 插件(右侧栏 tab)。
310
+ * 旧的 index.html / app.js / styles.css / native-strip.js 静态页与注入脚本已删除,
311
+ * 因此这里不再注册 /codex-orchestrate 静态路由。 */
312
+ }
@@ -0,0 +1,181 @@
1
+ /* eslint-disable no-console */
2
+ /* plugin.mjs 的接线测试:tool 注册、HTTP 路由表、清单接口的默认响应。
3
+ * 不触发真实 codex(ensureOrchestrator 只在 dispatch 时才构造 runner)。 */
4
+ 'use strict';
5
+ const assert = require('node:assert');
6
+ const { join } = require('node:path');
7
+
8
+ const fakeResponse = () => ({
9
+ status: 0, headers: null, body: '',
10
+ writeHead(status, headers) { this.status = status; this.headers = headers; },
11
+ end(chunk = '') { this.body += chunk; },
12
+ });
13
+
14
+ (async () => {
15
+ const plugin = await import('./plugin.mjs');
16
+
17
+ const tools = [];
18
+ const routes = [];
19
+ const hooks = [];
20
+ plugin.apply({
21
+ tools: { register: tool => tools.push(tool) },
22
+ webServer: { register: route => routes.push(route) },
23
+ on: (name, handler) => hooks.push({ name, handler }),
24
+ });
25
+
26
+ /* 1. tool 定义 */
27
+ {
28
+ const tool = tools.find(t => t.name === 'codex_orchestrate');
29
+ assert.ok(tool, '缺少 codex_orchestrate tool');
30
+ assert.deepStrictEqual(tool.parameters.required, ['yaml']);
31
+ assert.strictEqual(tool.parameters.additionalProperties, false);
32
+ await assert.rejects(() => tool.execute({ yaml: ' ' }), /yaml 必须为非空字符串/);
33
+ assert.strictEqual(
34
+ plugin.executionCwd({ agent: { session: { header: { cwd: '/workspace/from-session' } } } }),
35
+ '/workspace/from-session',
36
+ '工具应读取发起会话 header.cwd'
37
+ );
38
+ assert.strictEqual(plugin.executionCwd({}), null, '缺少会话 cwd 时不得伪造宿主目录');
39
+ assert.match(tool.description, /新增\/更新未来节点/);
40
+ assert.match(tool.description, /remove 真正删除/);
41
+ const queried = await tool.execute({ yaml: 'apiVersion: codex.dsh/v1\nkind: workflowQuery' });
42
+ assert.strictEqual(queried.operation, 'query');
43
+ assert.ok(Array.isArray(queried.workflows));
44
+ console.log('✓ tool 注册与入参校验');
45
+ }
46
+
47
+ /* 2. HTTP 路由表(原生 React 插件的数据源;静态 UI 已随旧工作台删除) */
48
+ {
49
+ for (const path of [
50
+ '/codex-orchestrate/api/dispatch',
51
+ '/codex-orchestrate/api/workflows',
52
+ '/codex-orchestrate/api/status',
53
+ '/codex-orchestrate/api/state',
54
+ '/codex-orchestrate/api/message',
55
+ '/codex-orchestrate/api/cancel',
56
+ '/codex-orchestrate/api/events',
57
+ ]) assert.ok(routes.some(r => r.path === path), `缺少路由 ${path}`);
58
+ assert.ok(
59
+ !routes.some(r => r.path === '/codex-orchestrate'),
60
+ '旧静态 UI 路由应已删除(工作台已迁移为 dsh.client 客户端插件)',
61
+ );
62
+ console.log('✓ HTTP 路由表完整(且旧静态路由已移除)');
63
+ }
64
+
65
+ /* 3. 尚未派发时清单为空(UI 空态而非报错) */
66
+ {
67
+ const route = routes.find(r => r.path === '/codex-orchestrate/api/workflows');
68
+ const res = fakeResponse();
69
+ await route.handler({ method: 'GET', url: '/codex-orchestrate/api/workflows', headers: {} }, res);
70
+ assert.strictEqual(res.status, 200);
71
+ const listBody = JSON.parse(res.body);
72
+ assert.deepStrictEqual(listBody.workflows, []);
73
+ assert.strictEqual(listBody.mode, 'real');
74
+ /* 实例身份必须可回传:调用者要能判断「我查的是不是派发我的那个实例」 */
75
+ assert.ok(listBody.instance?.instanceId, '缺少 instance.instanceId');
76
+ assert.strictEqual(typeof listBody.instance.pid, 'number');
77
+ console.log('✓ 清单接口在无工作流时返回空数组');
78
+ }
79
+
80
+ /* 3b. 未知 workflowId 的 404 必须自带诊断信息(不是光秃秃一句「不存在」) */
81
+ {
82
+ const route = routes.find(r => r.path === '/codex-orchestrate/api/state');
83
+ const res = fakeResponse();
84
+ await route.handler({ method: 'GET', url: '/codex-orchestrate/api/state?workflowId=wf_nope', headers: {} }, res);
85
+ assert.strictEqual(res.status, 404);
86
+ const body = JSON.parse(res.body);
87
+ assert.strictEqual(body.workflowId, 'wf_nope');
88
+ assert.ok(body.instance?.instanceId, '404 应回传实例身份,便于识别跨实例查询');
89
+ assert.ok(Array.isArray(body.known), '404 应回传本实例已知的 workflowId 列表');
90
+ assert.ok(body.hint, '404 应给出可操作的提示');
91
+ console.log('✓ 未知 workflow 的 404 自带实例身份与已知列表');
92
+ }
93
+
94
+ /* 3c. 落盘路径不得重复拼 'codex-orchestrate' 段
95
+ * (真实 bug:DSH_HOME 已经是 …/.runtime/codex-orchestrate,
96
+ * 再拼一次会写成 …/codex-orchestrate/codex-orchestrate/workflows.jsonl) */
97
+ {
98
+ const route = routes.find(r => r.path === '/codex-orchestrate/api/status');
99
+ const prev = process.env.DSH_HOME;
100
+ process.env.DSH_HOME = '/tmp/fake-dsh-home/codex-orchestrate';
101
+ const res = fakeResponse();
102
+ await route.handler({ method: 'GET', url: '/codex-orchestrate/api/status', headers: {} }, res);
103
+ if (prev === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = prev;
104
+ const body = JSON.parse(res.body);
105
+ assert.strictEqual(
106
+ body.instance.stateFile,
107
+ join('/tmp/fake-dsh-home', 'codex-orchestrate', 'workflows.jsonl'),
108
+ 'stateFile 必须直接落在 DSH_HOME 下,不得重复 codex-orchestrate 段'
109
+ );
110
+ console.log('✓ 落盘路径直接基于 DSH_HOME(无重复段)');
111
+ }
112
+
113
+ /* 3d. 回归:进程重启后(orchestrator 尚未构造、还没派发过任何东西)
114
+ * 查询接口必须仍能从磁盘回读历史,而不是短路成空列表 / 404。
115
+ * 这是最初的 bug:读路径写成 `orchestrator ? … : []`,重启后恒为空。 */
116
+ {
117
+ const { mkdtempSync, writeFileSync, rmSync } = require('node:fs');
118
+ const { tmpdir } = require('node:os');
119
+ const dir = mkdtempSync(join(tmpdir(), 'codex-plugin-'));
120
+ const stateFile = join(dir, 'workflows.jsonl');
121
+ /* 手写一份「上个进程留下的」状态:一个已完成的 workflow */
122
+ writeFileSync(stateFile, [
123
+ JSON.stringify({ type: 'workflow', workflowId: 'wf_old', title: '上个进程的', goal: '', concurrency: 1, createdAt: '2026-01-01T00:00:00.000Z', seq: 1 }),
124
+ JSON.stringify({ type: 'node', workflowId: 'wf_old', node: { id: 'p', title: 'P', prompt: 'x', status: 'completed', finalMessage: 'OLD', usage: { input_tokens: 1 }, items: [] } }),
125
+ '',
126
+ ].join('\n'));
127
+
128
+ const prev = process.env.DSH_CODEX_STATE_DIR;
129
+ process.env.DSH_CODEX_STATE_DIR = dir;
130
+ try {
131
+ /* 重新 apply 一个新「进程」(模块级 orchestrator 仍是首次 dispatch 前的状态) */
132
+ const freshPlugin = await import('./plugin.mjs?restart=1');
133
+ const freshRoutes = [];
134
+ freshPlugin.apply({
135
+ tools: { register: () => {} },
136
+ webServer: { register: route => freshRoutes.push(route) },
137
+ on: () => {},
138
+ });
139
+
140
+ const listRoute = freshRoutes.find(r => r.path === '/codex-orchestrate/api/workflows');
141
+ const listRes = fakeResponse();
142
+ await listRoute.handler({ method: 'GET', url: '/codex-orchestrate/api/workflows', headers: {} }, listRes);
143
+ const listed = JSON.parse(listRes.body).workflows.map(w => w.workflowId);
144
+ assert.ok(listed.includes('wf_old'), `重启后 list() 应包含磁盘历史,实际 ${JSON.stringify(listed)}`);
145
+
146
+ const stateRoute = freshRoutes.find(r => r.path === '/codex-orchestrate/api/state');
147
+ const stateRes = fakeResponse();
148
+ await stateRoute.handler({ method: 'GET', url: '/codex-orchestrate/api/state?workflowId=wf_old', headers: {} }, stateRes);
149
+ assert.strictEqual(stateRes.status, 200, '重启后应能从磁盘查到历史 workflow');
150
+ const snap = JSON.parse(stateRes.body);
151
+ assert.strictEqual(snap.nodes[0].finalMessage, 'OLD', 'finalMessage 必须跨重启保留');
152
+ console.log('✓ 重启后(未派发)查询接口仍能回读磁盘历史');
153
+ } finally {
154
+ if (prev === undefined) delete process.env.DSH_CODEX_STATE_DIR; else process.env.DSH_CODEX_STATE_DIR = prev;
155
+ rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ }
158
+
159
+ /* 4. 状态接口:缺省模型 + 登录态(未派发也可读,不构造 runner) */
160
+ {
161
+ const route = routes.find(r => r.path === '/codex-orchestrate/api/status');
162
+ const res = fakeResponse();
163
+ await route.handler({ method: 'GET', url: '/codex-orchestrate/api/status', headers: {} }, res);
164
+ assert.strictEqual(res.status, 200);
165
+ const body = JSON.parse(res.body);
166
+ assert.strictEqual(body.mode, 'real');
167
+ assert.strictEqual(body.defaultModel, require('./defaults.js').DEFAULT_MODEL);
168
+ assert.notStrictEqual(body.defaultModel, 'gpt-5-codex', '缺省模型不得回到 ChatGPT 订阅登录下被拒的 slug');
169
+ assert.ok(typeof body.auth.mode === 'string', '缺少 auth.mode');
170
+ console.log('✓ 状态接口回传缺省模型与登录态');
171
+ }
172
+
173
+ /* 5. 正式入口由 dsh.client 注册,不再注入 native-strip/iframe */
174
+ {
175
+ const inject = hooks.find(h => h.name === 'webserver/index-inject');
176
+ assert.strictEqual(inject, undefined, '不应继续注入 native-strip/iframe');
177
+ console.log('✓ 未注入旧侧边栏 iframe 入口');
178
+ }
179
+
180
+ console.log('\nplugin 接线测试通过');
181
+ })().catch(error => { console.error(error); process.exit(1); });