@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,77 @@
1
+ /* ============================================================
2
+ * turn-reduce.js — codex 事件 → 节点终局判定(纯函数)
3
+ * 从 codex-runner 抽出的判定逻辑,唯一目的是可单测:
4
+ * 「已自愈的传输恢复」不能把一个成功 turn 判成失败。
5
+ *
6
+ * 实测事件序列(本机 codex 0.153.4,WebSocket 不可达时):
7
+ * thread.started → turn.started → error("Reconnecting... 2/5 (request timed out)") ×4
8
+ * → item.completed(error: "Falling back from WebSockets to HTTPS transport")
9
+ * → item.completed(agent_message) → turn.completed(usage)
10
+ * 这些 error 是**恢复过程**的通知,不是失败;终局只认 turn.completed / turn.failed,
11
+ * 其余 error 只在 turn 没有正常完成时才充当失败原因。
12
+ * ============================================================ */
13
+ 'use strict';
14
+
15
+ /** 自愈性传输通知(重连/N-of-M 退避)——出现它不等于失败。 */
16
+ const TRANSIENT_TRANSPORT_NOTICE = /^Reconnecting\.\.\.|^Falling back from WebSockets/i;
17
+
18
+ function isTransientTransportNotice(message) {
19
+ return TRANSIENT_TRANSPORT_NOTICE.test(String(message || '').trim());
20
+ }
21
+
22
+ /** 新建一轮归约状态。 */
23
+ function createTurnState() {
24
+ return {
25
+ finalMessage: null, usage: null,
26
+ turnCompleted: false, turnError: null, itemError: null,
27
+ transportNotice: null, // 最近一条自愈性传输通知(诊断用,不构成失败)
28
+ };
29
+ }
30
+
31
+ /**
32
+ * 应用一个 codex 事件。
33
+ * @param {object} state 由 createTurnState() 建立、逐事件传入
34
+ * @param {object} event @openai/codex-sdk 的 streamed event(不修改入参)
35
+ * @param {(message:string)=>string} explain 错误文案翻译(注入以便单测)
36
+ * @returns {object} 新状态(纯函数:不修改传入的 state)
37
+ */
38
+ function reduceEvent(state, event, explain = message => String(message || '')) {
39
+ const next = { ...state };
40
+ if (event.type === 'turn.completed') {
41
+ next.usage = event.usage || null;
42
+ next.turnCompleted = true;
43
+ return next;
44
+ }
45
+ if (event.type === 'turn.failed') {
46
+ next.turnError = explain(event.error?.message || 'turn failed');
47
+ return next;
48
+ }
49
+ if (event.type === 'error') {
50
+ /* 重连中的 error 事件只是传输层通知:记诊断,不记失败 */
51
+ if (isTransientTransportNotice(event.message)) next.transportNotice = event.message;
52
+ else next.turnError = explain(event.message);
53
+ return next;
54
+ }
55
+ if (event.type === 'item.started' || event.type === 'item.updated' || event.type === 'item.completed') {
56
+ if (event.item?.type === 'agent_message') next.finalMessage = event.item.text;
57
+ if (event.item?.type === 'error' && event.item.message) {
58
+ if (isTransientTransportNotice(event.item.message)) next.transportNotice = event.item.message;
59
+ else if (!next.itemError) next.itemError = explain(event.item.message);
60
+ }
61
+ }
62
+ return next;
63
+ }
64
+
65
+ /**
66
+ * 由归约状态得出节点结果。
67
+ * 优先级:turn.failed / 真实 error 事件 > (turn 未完成时的 item 级 error)> 成功。
68
+ * @returns {{status:'completed'|'failed', finalMessage:string|null, usage:object|null, failure?:string}}
69
+ */
70
+ function finalizeTurn(state) {
71
+ const failure = state.turnError || (state.turnCompleted ? null : state.itemError);
72
+ return failure
73
+ ? { status: 'failed', finalMessage: state.finalMessage, usage: state.usage, failure }
74
+ : { status: 'completed', finalMessage: state.finalMessage, usage: state.usage };
75
+ }
76
+
77
+ module.exports = { createTurnState, reduceEvent, finalizeTurn, isTransientTransportNotice };
@@ -0,0 +1,96 @@
1
+ /* eslint-disable no-console */
2
+ /* turn-reduce 单测:codex 事件的终局判定。
3
+ * 重点是真实遇到过的坑:WebSocket 超时→HTTPS 回退会以 error 条目出现,
4
+ * 但 turn 随后正常完成——不能把已自愈的传输恢复判成节点失败。 */
5
+ 'use strict';
6
+ const assert = require('node:assert');
7
+ const { createTurnState, reduceEvent, finalizeTurn } = require('./turn-reduce.js');
8
+ const { explainFailure } = require('./codex-runner.js');
9
+
10
+ /** 按顺序喂事件,返回终局结果。 */
11
+ function run(events, explain) {
12
+ return finalizeTurn(events.reduce((state, event) => reduceEvent(state, event, explain), createTurnState()));
13
+ }
14
+
15
+ (async () => {
16
+ /* 1. 正常完成 */
17
+ {
18
+ const result = run([
19
+ { type: 'item.completed', item: { type: 'agent_message', text: '答案' } },
20
+ { type: 'turn.completed', usage: { input_tokens: 3, output_tokens: 7 } },
21
+ ]);
22
+ assert.deepStrictEqual(result, { status: 'completed', finalMessage: '答案', usage: { input_tokens: 3, output_tokens: 7 } });
23
+ console.log('✓ 正常完成 → completed');
24
+ }
25
+
26
+ /* 2. 已自愈的传输恢复 + 正常完成 → completed(真实回归:实测事件序列) */
27
+ {
28
+ const result = run([
29
+ { type: 'turn.started' },
30
+ { type: 'error', message: 'Reconnecting... 2/5 (request timed out)' },
31
+ { type: 'error', message: 'Reconnecting... 3/5 (request timed out)' },
32
+ { type: 'error', message: 'Reconnecting... 4/5 (request timed out)' },
33
+ { type: 'error', message: 'Reconnecting... 5/5 (request timed out)' },
34
+ { type: 'item.completed', item: { type: 'error', message: 'Falling back from WebSockets to HTTPS transport. request timed out' } },
35
+ { type: 'item.completed', item: { type: 'agent_message', text: '该目录是 Git 仓库,当前分支为 main' } },
36
+ { type: 'turn.completed', usage: { input_tokens: 41183, output_tokens: 190 } },
37
+ ]);
38
+ assert.strictEqual(result.status, 'completed', '自愈的传输恢复不得判成失败');
39
+ assert.strictEqual(result.finalMessage, '该目录是 Git 仓库,当前分支为 main');
40
+ assert.strictEqual(result.usage.output_tokens, 190);
41
+ console.log('✓ 传输恢复(重连 error 事件 + 回退条目)+ turn.completed → completed');
42
+ }
43
+
44
+ /* 2b. 传输通知保留为诊断信息,不污染 error 字段 */
45
+ {
46
+ const state = reduceEvent(
47
+ createTurnState(),
48
+ { type: 'error', message: 'Reconnecting... 5/5 (request timed out)' },
49
+ );
50
+ assert.strictEqual(state.turnError, null);
51
+ assert.match(state.transportNotice, /Reconnecting/);
52
+ console.log('✓ 传输通知记入 transportNotice 而非 turnError');
53
+ }
54
+
55
+ /* 3. turn.failed → failed,并且带原因(订阅登录拒绝 slug 的场景) */
56
+ {
57
+ const result = run([
58
+ { type: 'turn.failed', error: { message: '{"detail":"The \'gpt-5-codex\' model is not supported when using Codex with a ChatGPT account."}' } },
59
+ ], message => explainFailure(message, 'gpt-5-codex'));
60
+ assert.strictEqual(result.status, 'failed');
61
+ assert.match(result.failure, /not supported when using Codex with a ChatGPT account/);
62
+ assert.match(result.failure, /gpt-5\.6-sol/, '报错要给出可操作建议(缺省模型)');
63
+ console.log('✓ turn.failed → failed 且给出可操作建议');
64
+ }
65
+
66
+ /* 4. 只有 item 级 error、turn 从未完成 → failed(不给静默 completed) */
67
+ {
68
+ const result = run([
69
+ { type: 'item.completed', item: { type: 'error', message: 'stream closed unexpectedly' } },
70
+ ]);
71
+ assert.strictEqual(result.status, 'failed');
72
+ assert.match(result.failure, /stream closed unexpectedly/);
73
+ console.log('✓ 无 turn.completed 的 error 条目 → failed');
74
+ }
75
+
76
+ /* 5. 流内 error 事件优先于 item 级 error */
77
+ {
78
+ const result = run([
79
+ { type: 'item.completed', item: { type: 'error', message: 'item 级噪音' } },
80
+ { type: 'error', message: 'stream 级失败' },
81
+ { type: 'turn.completed' },
82
+ ]);
83
+ assert.strictEqual(result.status, 'failed');
84
+ assert.match(result.failure, /stream 级失败/);
85
+ console.log('✓ turn 级 error 覆盖 item 级噪音');
86
+ }
87
+
88
+ /* 6. 认证失效的报错翻译给出 `codex login` */
89
+ {
90
+ const translated = explainFailure('401 unauthorized', 'gpt-5.6-sol');
91
+ assert.match(translated, /codex login/);
92
+ console.log('✓ 认证失效报错指向 codex login');
93
+ }
94
+
95
+ console.log('\nturn 判定测试通过');
96
+ })().catch(error => { console.error(error); process.exit(1); });
@@ -0,0 +1,263 @@
1
+ /* ============================================================
2
+ * validate.js — YAML 解析 + JSON Schema 校验 + 语义校验
3
+ * 依赖策略:优先使用 DSH 自带的 yaml 包(经 DSH_CODEX_MODULES 指向),
4
+ * 不可用时回退到内置的子集解析器(覆盖 workflow schema 用到的语法:
5
+ * 块映射 / 块序列 / 内联数组 / 字面量块 | / 引号与裸标量)。
6
+ * ============================================================ */
7
+ 'use strict';
8
+
9
+ const { createRequire } = require('node:module');
10
+
11
+ function loadYamlParser() {
12
+ const dir = process.env.DSH_CODEX_MODULES;
13
+ if (dir) {
14
+ try {
15
+ const req = createRequire(dir + '/');
16
+ const yaml = req('yaml');
17
+ return text => yaml.parse(text);
18
+ } catch { /* fall through */ }
19
+ }
20
+ return parseYamlSubset;
21
+ }
22
+
23
+ /* ---------------- 内置 YAML 子集解析器 ---------------- */
24
+
25
+ function parseYamlSubset(text) {
26
+ const lines = [];
27
+ let index = 0;
28
+ for (const raw of String(text).split(/\r?\n/)) {
29
+ const line = raw.replace(/\t/g, ' ');
30
+ const stripped = line.replace(/(^|\s)#.*$/, (m, p, offset, s) =>
31
+ /["']/.test(s.slice(0, offset).split('#')[0]) ? m : '');
32
+ if (!stripped.trim()) continue;
33
+ const indent = stripped.match(/^ */)[0].length;
34
+ lines.push({ indent, text: stripped.trim(), n: ++index });
35
+ }
36
+ let pos = 0;
37
+
38
+ function parseBlock(minIndent) {
39
+ if (pos >= lines.length) return null;
40
+ const { indent } = lines[pos];
41
+ if (indent < minIndent) return null;
42
+ if (lines[pos].text.startsWith('- ') || lines[pos].text === '-') return parseSeq(indent);
43
+ return parseMap(indent);
44
+ }
45
+
46
+ function parseSeq(indent) {
47
+ const items = [];
48
+ while (pos < lines.length && lines[pos].indent === indent &&
49
+ (lines[pos].text.startsWith('- ') || lines[pos].text === '-')) {
50
+ const body = lines[pos].text === '-' ? '' : lines[pos].text.slice(2);
51
+ if (!body) { pos++; items.push(parseBlock(indent + 1)); continue; }
52
+ // "- key: value" 就地开启一个映射
53
+ const kv = matchKey(body);
54
+ if (kv) {
55
+ const map = {};
56
+ assignScalar(map, kv.key, kv.rest, lines[pos].n);
57
+ const itemIndent = lines[pos].indent + 2;
58
+ pos++;
59
+ mergeMap(map, itemIndent);
60
+ items.push(map);
61
+ } else {
62
+ items.push(parseScalar(body));
63
+ pos++;
64
+ }
65
+ }
66
+ return items;
67
+ }
68
+
69
+ function parseMap(indent) {
70
+ const map = {};
71
+ mergeMap(map, indent);
72
+ return map;
73
+ }
74
+
75
+ function mergeMap(map, indent) {
76
+ while (pos < lines.length && lines[pos].indent === indent && !lines[pos].text.startsWith('- ')) {
77
+ const kv = matchKey(lines[pos].text);
78
+ if (!kv) throw new SyntaxError(`第 ${lines[pos].n} 行:期望 "key: value",得到 "${lines[pos].text}"`);
79
+ if (kv.rest === '' || kv.rest === '|') {
80
+ pos++;
81
+ if (kv.rest === '|') map[kv.key] = parseLiteralBlock(indent);
82
+ else {
83
+ const child = parseBlock(indent + 1);
84
+ map[kv.key] = child === null ? null : child;
85
+ }
86
+ } else {
87
+ assignScalar(map, kv.key, kv.rest, lines[pos].n);
88
+ pos++;
89
+ }
90
+ }
91
+ }
92
+
93
+ function parseLiteralBlock(indent) {
94
+ const out = [];
95
+ let blockIndent = null;
96
+ while (pos < lines.length && lines[pos].indent > indent) {
97
+ if (blockIndent === null) blockIndent = lines[pos].indent;
98
+ out.push(' '.repeat(Math.max(0, lines[pos].indent - blockIndent)) + lines[pos].text);
99
+ pos++;
100
+ }
101
+ return out.join('\n');
102
+ }
103
+
104
+ function matchKey(text) {
105
+ const m = text.match(/^([A-Za-z_][\w.-]*)\s*:(?:\s+(.*))?$/);
106
+ if (!m) return null;
107
+ return { key: m[1], rest: m[2] === undefined ? '' : m[2].trim() };
108
+ }
109
+
110
+ function assignScalar(map, key, raw, lineNo) {
111
+ if (raw === '|') return; // 由调用方处理
112
+ if (raw.startsWith('|')) throw new SyntaxError(`第 ${lineNo} 行:字面量块 "|" 后不能有内容`);
113
+ map[key] = parseScalar(raw);
114
+ }
115
+
116
+ function parseScalar(raw) {
117
+ raw = raw.trim();
118
+ if (raw.startsWith('[') && raw.endsWith(']')) {
119
+ const inner = raw.slice(1, -1).trim();
120
+ return inner ? inner.split(',').map(part => parseScalar(part)) : [];
121
+ }
122
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
123
+ return raw.slice(1, -1);
124
+ }
125
+ if (raw === 'true') return true;
126
+ if (raw === 'false') return false;
127
+ if (raw === 'null' || raw === '~') return null;
128
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
129
+ if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
130
+ return raw;
131
+ }
132
+
133
+ const doc = parseBlock(0);
134
+ if (pos !== lines.length) throw new SyntaxError(`第 ${lines[pos].n} 行:无法解析的缩进或语法 "${lines[pos].text}"`);
135
+ return doc;
136
+ }
137
+
138
+ /* ---------------- JSON Schema 子集校验器 ---------------- */
139
+ /* 支持:type/const/enum/required/properties/additionalProperties/items/
140
+ minItems/maxItems/uniqueItems/minLength/maxLength/minimum/maximum/pattern/
141
+ allOf/if-then/$ref(仅 #/$defs/...) */
142
+
143
+ function validateAgainstSchema(value, schema, root, path = '') {
144
+ const errors = [];
145
+ const at = p => (p ? p : '(root)');
146
+
147
+ if (schema.$ref) {
148
+ const target = schema.$ref.replace(/^#\/?/, '').split('/').reduce((acc, key) => acc?.[key], root);
149
+ if (target) errors.push(...validateAgainstSchema(value, target, root, path));
150
+ /* JSON Schema 2020-12 允许 $ref 与 required 等关键字并列;引用约束与
151
+ * 本地约束都必须生效。workflow 创建节点借此在 nodeMutation 基础上
152
+ * 追加 prompt 必填,而 nodePatch 的既有节点更新可以只传变化字段。 */
153
+ schema = { ...schema };
154
+ delete schema.$ref;
155
+ }
156
+ if (schema.allOf) for (const sub of schema.allOf) errors.push(...validateAgainstSchema(value, sub, root, path));
157
+ if (schema.if) {
158
+ const ifErrors = validateAgainstSchema(value, schema.if, root, path);
159
+ if (ifErrors.length === 0 && schema.then) errors.push(...validateAgainstSchema(value, schema.then, root, path));
160
+ if (ifErrors.length > 0 && schema.else) errors.push(...validateAgainstSchema(value, schema.else, root, path));
161
+ }
162
+ if (schema.const !== undefined && value !== schema.const) errors.push(`${at(path)}:必须为 ${JSON.stringify(schema.const)}`);
163
+ if (schema.enum && !schema.enum.includes(value)) errors.push(`${at(path)}:必须为 ${schema.enum.map(v => JSON.stringify(v)).join(' / ')} 之一`);
164
+ if (schema.type) {
165
+ const typeOk = {
166
+ object: v => v && typeof v === 'object' && !Array.isArray(v),
167
+ array: Array.isArray,
168
+ string: v => typeof v === 'string',
169
+ integer: v => Number.isInteger(v),
170
+ number: v => typeof v === 'number',
171
+ boolean: v => typeof v === 'boolean',
172
+ null: v => v === null,
173
+ }[schema.type](value);
174
+ if (!typeOk) { errors.push(`${at(path)}:类型必须为 ${schema.type},实际 ${Array.isArray(value) ? 'array' : typeof value}`); return errors; }
175
+ }
176
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
177
+ for (const key of schema.required || []) {
178
+ if (!(key in value)) errors.push(`${at(path)}:缺少必填字段 "${key}"`);
179
+ }
180
+ const props = schema.properties || {};
181
+ for (const key of Object.keys(value)) {
182
+ if (props[key]) errors.push(...validateAgainstSchema(value[key], props[key], root, path ? `${path}.${key}` : key));
183
+ else if (schema.additionalProperties === false) errors.push(`${at(path)}:不允许的未知字段 "${key}"`);
184
+ }
185
+ }
186
+ if (Array.isArray(value)) {
187
+ if (schema.minItems !== undefined && value.length < schema.minItems) errors.push(`${at(path)}:至少 ${schema.minItems} 项`);
188
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${at(path)}:至多 ${schema.maxItems} 项`);
189
+ if (schema.uniqueItems && new Set(value.map(v => JSON.stringify(v))).size !== value.length) errors.push(`${at(path)}:存在重复项`);
190
+ if (schema.items) value.forEach((item, i) => errors.push(...validateAgainstSchema(item, schema.items, root, `${path}[${i}]`)));
191
+ }
192
+ if (typeof value === 'string') {
193
+ if (schema.minLength !== undefined && value.length < schema.minLength) errors.push(`${at(path)}:长度至少 ${schema.minLength}`);
194
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) errors.push(`${at(path)}:长度至多 ${schema.maxLength}`);
195
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) errors.push(`${at(path)}:不匹配模式 ${schema.pattern}`);
196
+ }
197
+ if (typeof value === 'number') {
198
+ if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${at(path)}:最小值 ${schema.minimum}`);
199
+ if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${at(path)}:最大值 ${schema.maximum}`);
200
+ }
201
+ return errors;
202
+ }
203
+
204
+ /* ---------------- 语义校验(schema 之外的业务不变量) ---------------- */
205
+
206
+ function semanticCheck(doc, knownIds = [], { allowExternalDeps = false } = {}) {
207
+ const errors = [];
208
+ const nodes = doc.nodes || [];
209
+ const remove = doc.remove || [];
210
+ if (doc.kind === 'nodePatch') {
211
+ if (!nodes.length && !remove.length) errors.push('nodePatch 至少需要 nodes 或 remove 之一');
212
+ const removed = new Set(remove);
213
+ for (const n of nodes) {
214
+ if (removed.has(n.id)) errors.push(`节点 "${n.id}" 不能在同一补丁中同时修改和删除`);
215
+ if (Object.keys(n).every(key => key === 'id')) errors.push(`节点 "${n.id}" 没有任何需要修改的字段`);
216
+ }
217
+ }
218
+ if (doc.kind === 'workflow' && remove.length) errors.push('workflow 创建时不允许字段 "remove"');
219
+ if (doc.kind === 'workflowQuery') {
220
+ for (const field of ['title', 'goal', 'concurrency', 'nodes', 'remove']) {
221
+ if (doc[field] !== undefined) errors.push(`workflowQuery 不允许字段 "${field}"`);
222
+ }
223
+ }
224
+ const known = new Set([...knownIds, ...nodes.map(n => n.id)]);
225
+ const seen = new Set();
226
+ for (const n of nodes) {
227
+ if (seen.has(n.id)) errors.push(`节点 "${n.id}" 重复定义`);
228
+ seen.add(n.id);
229
+ }
230
+ for (const n of nodes) {
231
+ for (const dep of n.dependsOn || []) {
232
+ if (dep === n.id) errors.push(`节点 "${n.id}" 不能依赖自身`);
233
+ else if (!known.has(dep) && !allowExternalDeps) errors.push(`节点 "${n.id}" 依赖了未定义的 "${dep}"`);
234
+ }
235
+ }
236
+ // 环检测:DFS 三色标记(仅文档内部边;外部引用由 orchestrator 合并图检测)
237
+ const color = new Map(nodes.map(n => [n.id, 0]));
238
+ const graph = new Map(nodes.map(n => [n.id, n.dependsOn || []]));
239
+ const visit = id => {
240
+ if (color.get(id) === 1) { errors.push(`存在依赖环:包含节点 "${id}"`); return; }
241
+ if (color.get(id) === 2) return;
242
+ color.set(id, 1);
243
+ for (const dep of graph.get(id) || []) if (color.has(dep)) visit(dep);
244
+ color.set(id, 2);
245
+ };
246
+ for (const n of nodes) visit(n.id);
247
+ return errors;
248
+ }
249
+
250
+ /* ---------------- 对外入口 ---------------- */
251
+
252
+ function parseWorkflowYaml(text, schema, options = {}) {
253
+ const parse = loadYamlParser();
254
+ const doc = parse(text);
255
+ if (!doc || typeof doc !== 'object') throw new Error('YAML 文档必须是一个映射(对象)');
256
+ const schemaErrors = validateAgainstSchema(doc, schema, schema);
257
+ if (schemaErrors.length) throw new Error('schema 校验失败:\n - ' + schemaErrors.join('\n - '));
258
+ const semanticErrors = semanticCheck(doc, options.knownIds || [], { allowExternalDeps: Boolean(options.allowExternalDeps) });
259
+ if (semanticErrors.length) throw new Error('语义校验失败:\n - ' + semanticErrors.join('\n - '));
260
+ return doc;
261
+ }
262
+
263
+ module.exports = { parseWorkflowYaml, parseYamlSubset, validateAgainstSchema, semanticCheck };