@coda-console/protocol 1.6.1 → 1.8.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,256 @@
1
+ // Fixed BPMN topology checks. Local constraints and vocabulary live in structure.json; Coda's event names live in
2
+ // dialect.json. No engine compilation or expression evaluation takes place here. Maps keep arbitrary BPMN ids safe.
3
+ import { ISOInterval } from '@0dep/piso';
4
+ import structure from './structure.json' with { type: 'json' };
5
+ import dialect from './dialect.json' with { type: 'json' };
6
+
7
+ export const CODA_STRUCTURE = structure;
8
+ const extras = (node, type) => (node.extensionElements?.values ?? []).filter(v => v.$type === type);
9
+ const kinds = node => (node.eventDefinitions ?? []).map(d => d.$type.slice(5).replace(/EventDefinition$/, ''));
10
+ const hasEvent = (node, kind) => kinds(node).includes(kind);
11
+ const own = (table, key) => Object.hasOwn(table, key) ? table[key] : undefined;
12
+ const linkName = node => node.eventDefinitions?.find(d => d.$type === 'bpmn:LinkEventDefinition')?.name?.trim();
13
+ const check = (ok, message) => { if (!ok) throw new StructureProblem(message); };
14
+ class StructureProblem extends Error {}
15
+
16
+ // Only scalar values and derived facts go into Ajv, never moddle's cyclic references or a nested subtree.
17
+ function facts(n, process) {
18
+ const data = Object.fromEntries(Object.entries(n).filter(([, v]) => v !== undefined && (v === null || typeof v !== 'object')));
19
+ const event = n.eventDefinitions?.[0], loop = n.loopCharacteristics;
20
+ const condition = n.conditionExpression ?? event?.condition;
21
+ return { ...data, type: n.$type, top: n.$parent === process, parentType: n.$parent?.$type,
22
+ subprocess: !!n.$instanceOf('bpmn:SubProcess'), triggeredByEvent: !!n.triggeredByEvent,
23
+ isInterrupting: n.isInterrupting !== false, attachedType: n.attachedToRef?.$type ?? '',
24
+ position: n.$type !== 'bpmn:StartEvent' ? n.$type : n.$parent === process ? 'start' : n.$parent.triggeredByEvent ? 'eventStart' : 'innerStart',
25
+ eventKind: kinds(n)[0] ?? '', messageNamed: !!(event?.messageRef?.name ?? n.messageRef?.name),
26
+ signalReferenced: !!event?.signalRef, escalationReferenced: !!event?.escalationRef, linkNamed: !!linkName(n),
27
+ hasCondition: !!condition, condition: condition?.body, conditionLanguage: condition?.language,
28
+ timeDuration: event?.timeDuration?.body, timeDate: event?.timeDate?.body, timeCycle: event?.timeCycle?.body, hasTimeCycle: !!event?.timeCycle,
29
+ loopType: loop?.$type, collection: loop?.inputCollection, cardinality: loop?.loopCardinality?.body?.trim(),
30
+ loopCompletion: loop?.completionCondition?.body, completion: n.completionCondition?.body,
31
+ hasStartOrEnd: (n.flowElements ?? []).some(e => ['bpmn:StartEvent', 'bpmn:EndEvent'].includes(e.$type)),
32
+ hasScriptExtension: extras(n, 'coda:Script').length > 0, hasOutputs: extras(n, 'coda:Output').length > 0,
33
+ outputHasValue: extras(n, 'coda:Output').some(o => o.value !== undefined),
34
+ documentation: n.$type === 'bpmn:UserTask' ? (n.documentation ?? []).map(d => d.text ?? '').join('\n').trim() : undefined,
35
+ hasInputFields: extras(n, 'coda:Fields').length > 0 || extras(n, 'coda:Field').some(f => structure.inputFields.includes(f.type)),
36
+ };
37
+ }
38
+
39
+ // Shell variable boundaries are lexical, not a shell parser: preserve Coda's treatment of escapes, $$, comments and quotes.
40
+ function scriptProblem(script, where) {
41
+ for (let i = 0; i < script.length; i++) {
42
+ if (script[i] === '\\') { i++; continue; }
43
+ if (script[i] !== '$') continue;
44
+ if (script[i + 1] === '$') { i++; continue; }
45
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(script.slice(i + 1))?.[0];
46
+ if (!name) continue;
47
+ const after = i + 1 + name.length;
48
+ if (script.codePointAt(after) > 0x7f) {
49
+ const next = String.fromCodePoint(script.codePointAt(after));
50
+ return `${where} 里的 \`$${name}\` 后面紧跟「${next}」,macOS 的 bash 会把它读进变量名而展开为空:` +
51
+ `要展开变量写成 \`\${${name}}${next}\`,要原样输出这几个字写成 \`\\$${name}${next}\`(不放在单引号里);这条检查不区分引号与注释`;
52
+ }
53
+ i = after - 1;
54
+ }
55
+ return null;
56
+ }
57
+ function scheduleCycle(text, where) {
58
+ check(typeof text === 'string' && text.trim() && text.length <= structure.schedule.maxLength, `${where} 须为 ISO 8601 重复间隔,如 R/PT6H`);
59
+ let interval;
60
+ try { interval = new ISOInterval(text.trim()).parse(); }
61
+ catch { check(false, `${where} 不是 ISO 8601 重复间隔:${text}(如 R/PT6H)`); }
62
+ check(interval.repeat === -1 && interval.duration && !interval.end,
63
+ `${where} 只接受不限次数、不写结束时间的重复间隔:R/<时长> 或 R/<开始时刻>/<时长>,如 R/PT6H;现为 ${text}`);
64
+ check(interval.duration.toMilliseconds(new Date()) >= structure.schedule.minMilliseconds, `${where} 的间隔至少 5 分钟:${text}`);
65
+ }
66
+
67
+ export function structureChecker(ajv, { feelProblem, isExpression }) {
68
+ const rules = structure.rules.map(rule => ({ ...rule, validate: ajv.compile(rule.schema), applies: rule.when && ajv.compile(rule.when) }));
69
+ const idPattern = new RegExp(structure.id.pattern), settingsPattern = new RegExp(structure.schedule.settingsPattern);
70
+ return (root, file) => {
71
+ try { validate(root, file); return null; }
72
+ catch (error) { if (error instanceof StructureProblem) return error.message; throw error; }
73
+ };
74
+
75
+ function validate(root, file) {
76
+ const roots = root.rootElements ?? [], processes = roots.filter(e => e.$type === 'bpmn:Process');
77
+ check(processes.length === 1, `${file}.bpmn 须恰好有一个 process,现有 ${processes.length} 个`);
78
+ const [process] = processes;
79
+ check(process.isExecutable === true, `${file}.bpmn 的 process 须写明可执行(isExecutable="true")`);
80
+ const others = roots.filter(e => !structure.topLevel.includes(e.$type));
81
+ check(!others.length, `${file}.bpmn 里有 Coda 不支持的顶层元素:${others.map(e => e.$type).join('、')}`);
82
+ const nodes = new Map(), flows = [], associations = [], entries = [];
83
+ // Stack frames retain the scope rather than constructing every ancestor path up front (quadratic in nesting depth).
84
+ const stack = [{ container: process, index: 0 }];
85
+ while (stack.length) {
86
+ const frame = stack.at(-1), children = frame.container.flowElements ?? [];
87
+ if (frame.index === 0) for (const a of frame.container.artifacts ?? []) if (a.$type === 'bpmn:Association') associations.push(a);
88
+ if (frame.index === children.length) { stack.pop(); continue; }
89
+ const n = children[frame.index++];
90
+ entries.push(n);
91
+ if (n.$type === 'bpmn:SequenceFlow') flows.push(n); else nodes.set(n.id, n);
92
+ if (n.$instanceOf('bpmn:SubProcess')) stack.push({ container: n, index: 0 });
93
+ }
94
+ const path = n => { const parts = []; for (let at = n; at && at !== root; at = at.$parent) parts.push(at.id); return parts.reverse().join('/'); };
95
+ const names = Object.create(null);
96
+ for (const name of dialect.variables) names[name] = null;
97
+ for (const n of roots) if (n.$type === 'bpmn:Message') names[n.id] = null;
98
+ for (const n of entries) names[n.id] = null;
99
+ const ids = new Set(Object.keys(names));
100
+ for (const n of entries) if (n.loopCharacteristics) {
101
+ if (n.loopCharacteristics.inputElement !== undefined) names[n.loopCharacteristics.inputElement] = null;
102
+ names.loopCounter = null;
103
+ }
104
+ const readable = (value, where) => { const problem = feelProblem(value, names); check(!problem, `${where}:${problem}`); };
105
+ for (const n of entries) {
106
+ if (!idPattern.test(n.id ?? '')) check(false, `${path(n)}:${structure.id.description}`);
107
+ if (dialect.variables.includes(n.id)) check(false, `${path(n)}:id 不能是 ${dialect.variables.join('、')}:表达式按这些名字读运行自带的变量,任务的结果按 id 读`);
108
+ const unsupported = own(structure.unsupported, n.$type);
109
+ if (unsupported) check(false, `${path(n)}:Coda 不运行 ${n.$type.slice(5)}:${unsupported}`);
110
+ const data = facts(n, process), allowed = own(structure.events, data.position);
111
+ if (allowed) {
112
+ const definitions = kinds(n), listed = allowed.map(k => structure.kindNames[k]).join('、');
113
+ if (definitions.length > 1) check(false, `${path(n)}:一个事件只带一个事件定义;要等几个事件里先来的一个,在它们前面放一个事件网关(eventBasedGateway)`);
114
+ for (const kind of definitions) {
115
+ if (kind === 'Message' && structure.throwing.includes(data.position)) check(false, `${path(n)}:${structure.unsupported['bpmn:SendTask']}`);
116
+ if (!allowed.includes(kind)) check(false, `${path(n)}:${structure.eventNames[data.position]}${allowed.length ? `只能是${listed}事件` : '不带事件定义'}${own(structure.eventSuffix, data.position) ?? ''}`);
117
+ }
118
+ if (structure.requiredEvents.includes(data.position) && definitions.length !== 1) check(false, `${path(n)}:${structure.eventNames[data.position]}要带一个事件定义(${listed})`);
119
+ }
120
+ for (const rule of rules) {
121
+ if (rule.on && !rule.on.includes(n.$type) || rule.applies && !rule.applies(data)) continue;
122
+ if (!rule.validate(data)) check(false, `${rule.at === 'path' ? path(n) : n.id}:${rule.description}`);
123
+ }
124
+ for (const spec of structure.readable) if (data[spec.property] !== undefined)
125
+ readable(data[spec.property], spec.where.replace(/^(path|id)/, kind => kind === 'path' ? path(n) : n.id));
126
+ const loopName = n.loopCharacteristics?.inputElement;
127
+ if (loopName !== undefined && ids.has(loopName)) check(false, `${path(n)}:coda:inputElement 不能是节点、消息的 id`);
128
+ if (n.$type === 'bpmn:ScriptTask') { const problem = scriptProblem(n.script, `${n.id} 的脚本`); check(!problem, problem); }
129
+ if (n.$type === 'bpmn:EndEvent') for (const output of extras(n, 'coda:Output'))
130
+ check(output.value !== undefined, `${n.id} 的 coda:output ${output.name} 需要 value(不超过 4000 字)`);
131
+ }
132
+ const list = [...nodes.values()], startEvents = list.filter(n => n.$type === 'bpmn:StartEvent' && n.$parent === process);
133
+ const incoming = new Map(), outgoing = new Map(), attached = new Map();
134
+ const add = (map, key, value) => { if (!map.has(key)) map.set(key, []); map.get(key).push(value); };
135
+ for (const f of flows) {
136
+ if (!nodes.has(f.sourceRef?.id) || !nodes.has(f.targetRef?.id)) check(false, `${path(f)}:顺序流须引用流程中的起点与终点`);
137
+ add(outgoing, f.sourceRef, f); add(incoming, f.targetRef, f);
138
+ }
139
+ for (const n of list) if (n.$type === 'bpmn:BoundaryEvent') {
140
+ if (!nodes.has(n.attachedToRef?.id)) check(false, `${path(n)}:边界事件须引用挂靠的活动(attachedToRef)`);
141
+ add(attached, n.attachedToRef, n);
142
+ }
143
+ for (const n of list) if (n.$type === 'bpmn:SubProcess' && n.triggeredByEvent) {
144
+ check((n.flowElements ?? []).filter(e => e.$type === 'bpmn:StartEvent').length === 1, `${n.id}:事件子流程恰好一个开始事件,由它的事件启动`);
145
+ check(!outgoing.has(n) && !incoming.has(n), `${n.id}:事件子流程不连顺序流,由它开始事件的事件启动`);
146
+ check(!attached.has(n), `${n.id}:事件子流程上不挂边界事件`);
147
+ }
148
+ const catchesByScope = new Map(), links = [];
149
+ for (const n of list) if (n.$type === 'bpmn:IntermediateCatchEvent' && linkName(n)) {
150
+ if (!catchesByScope.has(n.$parent)) catchesByScope.set(n.$parent, new Map());
151
+ const scope = catchesByScope.get(n.$parent), name = linkName(n);
152
+ check(!scope.has(name), `${n.id}:同一层里只能有一个名为「${name}」的连接捕获事件`);
153
+ scope.set(name, n);
154
+ }
155
+ for (const n of list) if (n.$type === 'bpmn:IntermediateThrowEvent' && linkName(n)) {
156
+ const to = catchesByScope.get(n.$parent)?.get(linkName(n));
157
+ check(to, `${n.id}:同一层里没有名为「${linkName(n)}」的连接捕获事件`);
158
+ links.push([n, to]);
159
+ }
160
+ for (const n of list) if (n.$type === 'bpmn:EventBasedGateway') {
161
+ const out = outgoing.get(n) ?? [];
162
+ check(out.length >= 2, `${n.id}:事件网关后面至少接两个等待的事件`);
163
+ check(out.every(f => !f.conditionExpression), `${n.id}:事件网关的出口不写条件,走哪条由先来的事件决定`);
164
+ check(out.every(f => f.targetRef.$type === 'bpmn:ReceiveTask' || f.targetRef.$type === 'bpmn:IntermediateCatchEvent' && !linkName(f.targetRef)),
165
+ `${n.id}:事件网关的出口只接中间捕获事件(消息、计时器、信号、条件)或 receiveTask`);
166
+ }
167
+ check(startEvents.length, `${file}.bpmn 需要一个开始事件`);
168
+ const handlers = new Map(), compensations = [], compensated = new Set();
169
+ for (const a of associations) add(handlers, a.sourceRef, a.targetRef);
170
+ for (const n of list) if (n.$type === 'bpmn:BoundaryEvent' && hasEvent(n, 'Compensate')) {
171
+ check(!outgoing.has(n), `${n.id}:补偿边界事件不连顺序流,用关联(association)连到做补偿的节点`);
172
+ const to = handlers.get(n) ?? [];
173
+ check(to.length === 1 && to[0]?.isForCompensation, `${n.id}:补偿边界事件用一条关联(association)连到一个标了 isForCompensation="true" 的节点`);
174
+ compensations.push([n, to[0]]); compensated.add(to[0]);
175
+ }
176
+ for (const n of list) if (n.isForCompensation) {
177
+ check(!outgoing.has(n) && !incoming.has(n), `${n.id}:做补偿的节点(isForCompensation)不连顺序流,由补偿边界事件的关联引到`);
178
+ check(compensated.has(n), `${n.id}:没有补偿边界事件用关联引到这个做补偿的节点`);
179
+ }
180
+ const scripts = list.filter(n => n.$type === 'bpmn:ScriptTask'), services = list.filter(n => n.$type === 'bpmn:ServiceTask');
181
+ check(scripts.length <= structure.maxScripts, `${file}.bpmn 最多 ${structure.maxScripts} 个 scriptTask`);
182
+ check(list.some(n => structure.steps.includes(n.$type)), `${file}.bpmn 至少要有一个 scriptTask、serviceTask、userTask 或 callActivity`);
183
+ // Reachability includes boundary, link, compensation and scope entry/exit edges. Explicit worklists terminate on cycles.
184
+ const next = new Map();
185
+ for (const f of flows) add(next, f.sourceRef, f.targetRef);
186
+ for (const [n, boundaries] of attached) for (const b of boundaries) add(next, n, b);
187
+ for (const [from, to] of links) add(next, from, to);
188
+ for (const n of list) if (n.$instanceOf('bpmn:SubProcess')) {
189
+ const adHoc = n.$type === 'bpmn:AdHocSubProcess';
190
+ for (const inner of n.flowElements ?? []) {
191
+ const flowing = !['bpmn:BoundaryEvent', 'bpmn:SequenceFlow'].includes(inner.$type) && !inner.isForCompensation;
192
+ if (adHoc ? flowing && !incoming.has(inner) : inner.$type === 'bpmn:StartEvent') add(next, n, inner);
193
+ if (adHoc ? flowing && !outgoing.has(inner) : inner.$type === 'bpmn:EndEvent')
194
+ for (const to of next.get(n) ?? []) if (to.$parent !== n) add(next, inner, to);
195
+ }
196
+ }
197
+ for (const [from, to] of compensations) add(next, from, to);
198
+ const seen = new Set(), todo = list.filter(n => n.$type === 'bpmn:StartEvent' && (n.$parent === process || n.$parent.triggeredByEvent));
199
+ while (todo.length) { const n = todo.pop(); if (seen.has(n)) continue; seen.add(n); for (const to of next.get(n) ?? []) todo.push(to); }
200
+ const unreachable = scripts.concat(services).filter(n => !seen.has(n));
201
+ check(!unreachable.length, `从开始事件走不到这些步骤:${unreachable.map(n => n.id).join('、')}`);
202
+ const unreachableTasks = list.filter(n => n.$type === 'bpmn:UserTask' && !seen.has(n));
203
+ check(!unreachableTasks.length, `从开始事件走不到这些任务:${unreachableTasks.map(n => n.id).join('、')}`);
204
+ const timed = n => hasEvent(n, 'Timer'), timers = startEvents.filter(timed);
205
+ check(timers.length <= 1, `定时开始只能有一个,现有 ${timers.map(n => n.id).join('、')}`);
206
+ if (timers.length) {
207
+ const [timer] = timers, timing = timer.eventDefinitions[0];
208
+ check(!timing.timeDuration && !timing.timeDate && timing.timeCycle?.body?.trim(), `${timer.id}:定时开始写 timeCycle(ISO 8601 重复间隔,如 R/PT6H),不写 timeDuration、timeDate`);
209
+ const cycle = timing.timeCycle.body.trim();
210
+ if (isExpression(cycle)) check(settingsPattern.test(cycle), `${timer.id}:timeCycle 的表达式只能读插件的设置,如 =vars.maintenance.schedule`);
211
+ else scheduleCycle(cycle, `${timer.id} 的 timeCycle`);
212
+ }
213
+ const starts = startEvents.flatMap(n => (n.eventDefinitions ?? []).filter(d => d.$type === 'bpmn:MessageEventDefinition').map(d => d.messageRef.name));
214
+ const roles = dialect.eventRoles;
215
+ check(!(starts.length || timers.length) || startEvents.every(n => n.eventDefinitions?.length), `由事件或定时启动的流程,开始事件都要带消息或计时器;要手动发起,订阅 ${roles.dispatch}`);
216
+ if (starts.includes(roles.lifecycleStart)) {
217
+ const asks = list.filter(n => n.$type === 'bpmn:UserTask');
218
+ check(!asks.length, `跟随会话的流程(订阅 ${roles.lifecycleStart})不能有人工任务:${asks.map(n => n.id).join('、')}。它不属于任何会话,任务没有人可交`);
219
+ const hears = n => n.messageRef?.name === roles.lifecycleFacts || (n.eventDefinitions ?? []).some(d => d.messageRef?.name === roles.lifecycleFacts);
220
+ const listeningScopes = new Set(list.filter(n => n.$type === 'bpmn:StartEvent' && n.$parent.triggeredByEvent && hears(n)).map(n => n.$parent.$parent));
221
+ const heard = new Map([[process, false]]);
222
+ // Entries are in parent-before-child order, so each ancestor's answer is reused once.
223
+ for (const n of list) heard.set(n, heard.get(n.$parent) || listeningScopes.has(n.$parent) || (attached.get(n) ?? []).some(hears));
224
+ const waits = list.filter(n => structure.lifecycle.waitTypes.includes(n.$type) || n.$type === 'bpmn:ServiceTask' && structure.lifecycle.waitActions.includes(n.action) ||
225
+ !n.$instanceOf('bpmn:SubProcess') && (attached.get(n) ?? []).some(timed));
226
+ const deaf = waits.filter(n => !hears(n) && !heard.get(n));
227
+ check(!deaf.length, `聋状态:流程在 ${deaf.map(n => n.id).join('、')} 等待时收不到 ${roles.lifecycleFacts},会话的事实变了没有人回应。` +
228
+ `跟随会话的流程(订阅 ${roles.lifecycleStart})每个等待节点都要能收 ${roles.lifecycleFacts}:自己等它,或挂一个等它的消息边界事件(挂在外层子流程上也算)`);
229
+ }
230
+ check(process.agents === undefined || starts.includes(roles.lifecycleStart), `coda:agents 只写在生命周期流程上(开始事件订阅 ${roles.lifecycleStart})`);
231
+ check(process.key === undefined || !starts.includes(roles.lifecycleStart), '生命周期流程按会话唯一,不能声明 coda:key');
232
+ check(process.dispatch !== false || starts.length || timers.length, 'coda:dispatch="false" 的流程须由事件或定时启动(开始事件带消息或计时器),否则无法发起');
233
+ const messageNames = new Set();
234
+ for (const m of roots.filter(e => e.$type === 'bpmn:Message')) {
235
+ if (m.name) {
236
+ check(!messageNames.has(m.name), `<message name="${m.name}"> 写了不止一个:一个事件只写一个 <message>,开始事件与等它的节点都引用它`);
237
+ messageNames.add(m.name);
238
+ }
239
+ check(!dialect.variables.includes(m.id), `<message id="${m.id}">:id 不能是 ${dialect.variables.join('、')}:表达式按这些名字读运行自带的变量,接到的消息按 id 读`);
240
+ }
241
+ const caught = new Map();
242
+ for (const n of list) if (n.$type !== 'bpmn:StartEvent' || n.$parent.triggeredByEvent) {
243
+ const names = (n.eventDefinitions ?? []).filter(d => d.$type === 'bpmn:MessageEventDefinition').map(d => d.messageRef.name);
244
+ if (n.messageRef?.name) names.push(n.messageRef.name);
245
+ for (const name of names) add(caught, name, n);
246
+ }
247
+ const userMessage = new RegExp(dialect.userMessage);
248
+ check(!starts.some(name => userMessage.test(name)), '用户发出的消息(user.…)不能启动流程,只能在运行中等它');
249
+ check(!starts.includes(roles.cancel), `${roles.cancel} 是要停下运行,不能启动流程;在运行中等它,接到后走自己的收尾`);
250
+ for (const [name, waiting] of caught) if (name.startsWith(structure.userMessages.prefix)) {
251
+ check(userMessage.test(name), `<message name="${name}">:用户发出的消息名为 user. 加小写字母、数字和「-」`);
252
+ const label = waiting.find(n => n.name?.trim())?.name.trim() ?? name;
253
+ check(label.length <= structure.userMessages.maxLabelLength, `等 ${name} 的节点名须不超过 40 个字(它是运行页上的按钮)`);
254
+ }
255
+ }
256
+ }
@@ -171,10 +171,77 @@
171
171
  "UserTask.children": {
172
172
  "type": "object",
173
173
  "properties": {
174
- "choice": {"type": "array", "maxItems": 6, "x-unique-by": [{"key": "id", "nested": "choices"}],
175
- "contains": {"type": "object", "properties": {"primary": {"const": true}}, "required": ["primary"]}, "minContains": 0, "maxContains": 1,
176
- "description": "最多 6 个 coda:choice,最多一个主按钮;所有答复的 id(包括按住出现的)不重复"}
177
- }
174
+ "choice": {
175
+ "type": "array",
176
+ "maxItems": 6,
177
+ "x-unique-by": [{"key": "id", "nested": "choices"}],
178
+ "contains": {"type": "object", "properties": {"primary": {"const": true}}, "required": ["primary"]},
179
+ "minContains": 0,
180
+ "maxContains": 1,
181
+ "description": "最多 6 个 coda:choice,最多一个主按钮;所有答复的 id(包括按住出现的)不重复"
182
+ },
183
+ "field": {"type": "array", "x-unique-by": ["id"], "description": "coda:fields 须为字段的 JSON 数组", "allOf": [{"maxItems": 12, "description": "最多 12 个字段"}]}
184
+ },
185
+ "allOf": [
186
+ {
187
+ "if": {"not": {"properties": {"fields": {"minItems": 1}}, "required": ["fields"]}},
188
+ "then": {
189
+ "properties": {
190
+ "choice": {
191
+ "items": {
192
+ "allOf": [
193
+ {
194
+ "properties": {
195
+ "fields": {
196
+ "type": "string",
197
+ "x-ref": {
198
+ "pointer": "/field",
199
+ "key": "id",
200
+ "split": "[\\s,]+",
201
+ "match": {"type": "object", "required": ["type"], "properties": {"type": {"enum": ["text", "secret"]}}}
202
+ },
203
+ "description": "fields 里的 {value} 不是这个任务的输入字段"
204
+ }
205
+ }
206
+ },
207
+ {
208
+ "properties": {
209
+ "choices": {
210
+ "items": {
211
+ "properties": {
212
+ "fields": {
213
+ "type": "string",
214
+ "x-ref": {
215
+ "pointer": "/field",
216
+ "key": "id",
217
+ "split": "[\\s,]+",
218
+ "match": {"type": "object", "required": ["type"], "properties": {"type": {"enum": ["text", "secret"]}}}
219
+ },
220
+ "description": "fields 里的 {value} 不是这个任务的输入字段"
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ ]
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+ ]
234
+ },
235
+ "Field": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json"},
236
+ "Fields": {
237
+ "type": "object",
238
+ "properties": {
239
+ "value": {"type": "string", "pattern": "^\\s*=", "x-feel": true, "description": "coda:fields 须用 value 写一个「=」开头的表达式(字段的列表)"},
240
+ "host": {"type": "string", "x-feel": true, "description": "host 是展开后 secret 字段未另写 host 时的备用值"},
241
+ "dir": {"type": "string", "x-feel": true, "description": "dir 是展开后非 keep=\"use\" 的 secret 字段未另写 dir 时的备用值"}
242
+ },
243
+ "required": ["value"],
244
+ "description": "coda:fields 须用 value 写一个「=」开头的表达式(字段的列表)"
178
245
  }
179
246
  }
180
247
  }
package/workflow.mjs CHANGED
@@ -3,14 +3,16 @@
3
3
  // ask of Coda itself, each with the parameters it takes (actions.json); and the names workflows share (dialect.json):
4
4
  // their kinds, the events Coda sends, the error code of a failed step, the run's variables an expression reads by name,
5
5
  // the types a run's inputs take. Coda reads a workflow with them, and coda-plugins' contract check does where a plugin is
6
- // written. readWorkflow refuses what the dialect does not have and a value of it its schema does not take; how the
7
- // process itself is put together is still Coda's to check as it reads the workflow.
8
- // What JSON Schema cannot say of a value it says with three keywords of its own, besides the manifests' (validate.mjs):
6
+ // written. readWorkflow checks values and structure: structure.json describes local constraints and structure.mjs
7
+ // checks BPMN topology, leaving only compilation to Coda.
8
+ // What JSON Schema cannot say of a value it says with these keywords, besides the manifests' (validate.mjs):
9
9
  // - `x-feel`: true, or { names } — the value is FEEL: an expression when it starts with `=`, else text whose every
10
10
  // `{{ … }}` holds one, each reading by name what the workflow has (its nodes and messages, the run's variables, a loop's
11
11
  // element), or only `names`;
12
12
  // - `x-duration`: true — the value is an ISO 8601 duration, not negative;
13
13
  // - `x-number`: true — the text, read as a number, is a finite one (`1e999` reads as none).
14
+ // workflow/keywords.mjs adds x-deferred (check FEEL now, the result later), x-length (UTF-16 text length, optionally
15
+ // limited by a sibling), x-json (a JSON value or its text, with a byte budget), and absolute-path / https-url formats.
14
16
  import { parseDuration } from '@0dep/piso';
15
17
  import { BpmnModdle } from 'bpmn-moddle';
16
18
  import { parseExpression } from 'feelin';
@@ -19,10 +21,15 @@ import moddle from './workflow/moddle.json' with { type: 'json' };
19
21
  import values from './workflow/values.json' with { type: 'json' };
20
22
  import actions from './workflow/actions.json' with { type: 'json' };
21
23
  import dialect from './workflow/dialect.json' with { type: 'json' };
24
+ import fields from './workflow/fields.json' with { type: 'json' };
25
+ import { valueKeywords } from './workflow/keywords.mjs';
26
+ import { structureChecker } from './workflow/structure.mjs';
27
+ export { CODA_STRUCTURE } from './workflow/structure.mjs';
22
28
 
23
29
  export const CODA_MODDLE = moddle;
24
30
  export const CODA_NAMESPACE = moddle.uri;
25
31
  export const CODA_VALUES = values;
32
+ export const CODA_FIELDS = fields;
26
33
  // Each action by name, with the parameters it takes (`coda:param`).
27
34
  export const CODA_ACTIONS = Object.fromEntries(Object.entries(actions.actions).map(([name, action]) => [name, action.params]));
28
35
  export const WORKFLOW_KINDS = Object.keys(dialect.kinds);
@@ -30,7 +37,7 @@ export const WORKFLOW_KINDS = Object.keys(dialect.kinds);
30
37
  export const DEPLOYING_KINDS = dialect.deploying;
31
38
  // The events Coda sends (dialect.json says what each is).
32
39
  export const CODA_EVENTS = Object.keys(dialect.events);
33
- export const SESSION_OPENED = 'session.opened', SESSION_FACTS = 'session.facts', DISPATCH_EVENT = 'workflow.dispatch', CANCEL_EVENT = 'workflow.cancel';
40
+ export const { lifecycleStart: SESSION_OPENED, lifecycleFacts: SESSION_FACTS, dispatch: DISPATCH_EVENT, cancel: CANCEL_EVENT } = dialect.eventRoles;
34
41
  // A message the user sends from a run's page (`user.<what>`), while the run waits on it.
35
42
  export const USER_MESSAGE = new RegExp(dialect.userMessage);
36
43
  export const STEP_FAILED = dialect.stepFailed;
@@ -72,6 +79,7 @@ export function isDuration(text) {
72
79
  }
73
80
 
74
81
  const ajv = newAjv();
82
+ valueKeywords(ajv, { feelProblem, isExpression });
75
83
  ajv.addKeyword({
76
84
  keyword: 'x-feel', type: 'string', schemaType: ['boolean', 'object'], errors: true,
77
85
  metaSchema: { anyOf: [{ type: 'boolean' }, { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } } }, required: ['names'], additionalProperties: false }] },
@@ -86,11 +94,13 @@ ajv.addKeyword({
86
94
  });
87
95
  ajv.addKeyword({ keyword: 'x-duration', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || isDuration(value) });
88
96
  ajv.addKeyword({ keyword: 'x-number', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || Number.isFinite(Number(value)) });
97
+ ajv.addSchema(fields);
89
98
  ajv.addSchema(values);
99
+ const structureProblem = structureChecker(ajv, { feelProblem, isExpression });
90
100
  const checks = new Map();
91
101
  // The validator of a definition of values.json, by its name; none for a type it says nothing of.
92
102
  const check = name => {
93
- if (!checks.has(name)) checks.set(name, values.$defs[name] ? ajv.getSchema(`${values.$id}#/$defs/${name}`) : null);
103
+ if (!checks.has(name)) checks.set(name, Object.hasOwn(values.$defs, name) ? ajv.getSchema(`${values.$id}#/$defs/${name}`) : null);
94
104
  return checks.get(name);
95
105
  };
96
106
 
@@ -98,6 +108,12 @@ const SIZE_LIMIT = 1_000_000;
98
108
  // bpmn-moddle rewrites a descriptor as it takes it in (its names get their prefix), and whoever imports CODA_MODDLE may hand
99
109
  // it to one: what is read here is a copy taken as this loads, and each parse is given a copy of its own.
100
110
  const DESCRIPTOR = structuredClone(moddle);
111
+ // moddle's Boolean/Integer coercion accepts `no` as false and `1.5` as 1. Keep field attributes as text until their
112
+ // complete spelling is known; valid ones are restored to the descriptor's types before returning the parsed workflow.
113
+ const FIELD_SCALARS = DESCRIPTOR.types.find(type => type.name === 'Field').properties.filter(p => ['Boolean', 'Integer'].includes(p.type));
114
+ const PARSER_DESCRIPTOR = structuredClone(DESCRIPTOR);
115
+ for (const property of PARSER_DESCRIPTOR.types.find(type => type.name === 'Field').properties)
116
+ if (FIELD_SCALARS.some(p => p.name === property.name)) property.type = 'String';
101
117
  const children = element => Object.values(element).flatMap(v => (Array.isArray(v) ? v : [v])).filter(v => v?.$type && v.$parent === element);
102
118
  // Everything in the definition under `element`, depth first, by a stack of its own, pushed one at a time: a definition may
103
119
  // nest deeper than the call stack goes, and hold more in one place than a call takes arguments.
@@ -130,7 +146,40 @@ function said(error, at, attribute) {
130
146
  return `${at} 需要 ${attribute ? 'coda:' : ''}${error.params.missingProperty}${own ? `:${own}` : ''}`;
131
147
  }
132
148
  if (error.keyword === 'x-unique-by') return `${at}:${error.message}`;
133
- return `${at}:${described(error, { schemas: [values] }) ?? error.message}`;
149
+ return `${at}:${description(error)}`;
150
+ }
151
+ const description = (error, object = {}) => (described(error, { schemas: [values, fields] }) ?? error.message)
152
+ .replace(/\{(\w+)\}/g, (whole, key) => Object.hasOwn(error.params, key) ? String(error.params[key])
153
+ : object && Object.hasOwn(object, key) ? String(object[key]) : whole);
154
+ // Required values and allowed attributes come from the same schema as validation; none of the field types is named here.
155
+ function fieldSaid(error, at, field) {
156
+ if (error.keyword === 'additionalProperties') {
157
+ const allowed = Object.keys(error.parentSchema.properties), type = error.parentSchema.properties.type.const;
158
+ const extra = Object.keys(error.data).filter(key => !Object.hasOwn(error.parentSchema.properties, key));
159
+ return `${at}:${type} 字段没有 ${extra.join('、')},可写 ${allowed.join('、')}`;
160
+ }
161
+ return `${at}:${error.keyword === 'x-feel' ? error.message : description(error, field)}`;
162
+ }
163
+ // A resolved field, at task opening or when accepting an answer. No expression is evaluated and no default is inserted.
164
+ export function fieldProblem(field, where = '字段') {
165
+ const validate = ajv.getSchema(fields.$id);
166
+ return validate(field) ? null : fieldSaid(validate.errors[0], where, field);
167
+ }
168
+ // The expanded fields of one task (after reading coda:fields JSON and applying host/dir), checked together as in a
169
+ // definition. Choice references still need the task's choices; Coda checks those after expansion.
170
+ let fieldsBound;
171
+ export function fieldsProblem(list, where = '任务') {
172
+ const schema = values.$defs['UserTask.children'].properties.field;
173
+ // Check the bound before visiting items, and individual values before uniqueness can inspect their ids.
174
+ const { 'x-unique-by': unique, ...shape } = schema;
175
+ const bound = fieldsBound ??= ajv.compile(shape);
176
+ if (!bound(list)) return `${where}:${description(bound.errors[0])}`;
177
+ for (const [index, field] of list.entries()) {
178
+ const problem = fieldProblem(field, `${where} 的字段 ${typeof field?.id === 'string' ? field.id : index + 1}`);
179
+ if (problem) return problem;
180
+ }
181
+ const validate = ajv.getSchema(`${values.$id}#/$defs/UserTask.children/properties/field`);
182
+ return validate(list) ? null : `${where}:字段 id 重复:${validate.errors[0].message.slice('id 不能重复:'.length)}`;
134
183
  }
135
184
  // What of the coda: values in the definition its schemas do not take, or null: each element in order, its coda:
136
185
  // attributes by each type of Coda's extending what it is, the coda: elements it holds by kind, and each coda: element.
@@ -141,12 +190,13 @@ function valuesProblem(root) {
141
190
  const loops = elements.filter(e => e.$type === 'bpmn:MultiInstanceLoopCharacteristics');
142
191
  const names = Object.fromEntries([...RUN_VARIABLES, ...elements.filter(e => e.$type === 'bpmn:Message' || e.$instanceOf?.('bpmn:FlowElement')).map(e => e.id),
143
192
  ...loops.map(loop => loop.inputElement).filter(Boolean), ...(loops.length ? ['loopCounter'] : [])].filter(Boolean).map(name => [name, null]));
144
- const failed = (validate, object, at, attribute) => validate && !validate.call({ names }, object) ? said(validate.errors[0], at, attribute) : null;
193
+ const failed = (validate, object, at, attribute, field = false) => validate && !validate.call({ names, definition: true }, object)
194
+ ? field ? fieldSaid(validate.errors[0], at, object) : said(validate.errors[0], at, attribute) : null;
145
195
  for (const element of elements) {
146
196
  const type = codaType(element);
147
197
  if (type) {
148
198
  const name = element.id ?? element.name, kind = type.name === 'Choice' && element.$parent?.$type === 'coda:Choice' ? 'NestedChoice' : type.name;
149
- const problem = failed(check(kind), codaObject(element), `${owner(element)?.id} 的 coda:${lower(type.name)}${name ? ` ${name}` : ''}`, false);
199
+ const problem = failed(check(kind), codaObject(element), `${owner(element)?.id} 的 coda:${lower(type.name)}${name ? ` ${name}` : ''}`, false, ['Field', 'Fields'].includes(kind));
150
200
  if (problem) return problem;
151
201
  continue;
152
202
  }
@@ -160,7 +210,16 @@ function valuesProblem(root) {
160
210
  if (!held.length) continue;
161
211
  const grouped = {};
162
212
  for (const child of held) (grouped[lower(codaType(child).name)] ??= []).push(codaObject(child));
163
- const problem = failed(check(`${element.$type.slice(5)}.children`), grouped, at, false);
213
+ const validate = check(`${element.$type.slice(5)}.children`);
214
+ let problem = failed(validate, grouped, at, false);
215
+ if (problem && validate.errors[0].keyword === 'x-ref') {
216
+ const error = validate.errors[0], path = error.instancePath.split('/').slice(1);
217
+ let parent = grouped;
218
+ for (const key of path.slice(0, -1)) parent = parent[key];
219
+ problem = `${at} 的 coda:choice ${parent.id}:${description(error)}`;
220
+ }
221
+ if (problem && validate.errors[0].keyword === 'x-unique-by' && validate.errors[0].instancePath === '/field')
222
+ problem = `${at}:字段 id 重复:${validate.errors[0].message.slice('id 不能重复:'.length)}`;
164
223
  if (problem) return problem;
165
224
  }
166
225
  return null;
@@ -169,21 +228,28 @@ function valuesProblem(root) {
169
228
  // A workflow's definition (`file`, its name without `.bpmn`, for what is said) read with Coda's dialect: `{ root }`, the
170
229
  // parsed definitions, or `{ problem }`, what of it the dialect does not have — not BPMN, a coda: attribute or element it
171
230
  // does not know, an action it does not have or a parameter that action does not take — or a value of it its schema does
172
- // not take.
231
+ // not take, or a structure the workflow cannot run with.
173
232
  export async function readWorkflow(source, file = 'workflow') {
174
233
  if (typeof source !== 'string' || source.length > SIZE_LIMIT) return { problem: `${file}.bpmn 过大` };
234
+ const old = OLD.exec(source);
235
+ if (old) return { problem: `${file}.bpmn 第 ${source.slice(0, old.index).split('\n').length} 行:${feelProblem(old[0])}` };
175
236
  let parsed;
176
- try { parsed = await new BpmnModdle({ coda: structuredClone(DESCRIPTOR) }).fromXML(source); }
237
+ try { parsed = await new BpmnModdle({ coda: structuredClone(PARSER_DESCRIPTOR) }).fromXML(source); }
177
238
  catch (e) { return { problem: `${file}.bpmn 不是有效的 BPMN 2.0:${e.message}` }; }
178
239
  const { rootElement: root, warnings } = parsed;
179
240
  if (warnings.length) return { problem: `${file}.bpmn 有无法识别的内容:${warnings.slice(0, 3).map(w => w.message).join(';')}` };
241
+ for (const field of all(root).filter(e => e.$type === 'coda:Field')) for (const { name, type } of FIELD_SCALARS) {
242
+ const value = field[name];
243
+ if (type === 'Boolean' && ['true', 'false'].includes(value)) field[name] = value === 'true';
244
+ if (type === 'Integer' && typeof value === 'string' && /^[+-]?\d+$/.test(value)) field[name] = Number(value);
245
+ }
180
246
  for (const task of all(root).filter(e => e.$type === 'bpmn:ServiceTask' && e.action !== undefined)) {
181
247
  if (!Object.hasOwn(CODA_ACTIONS, task.action)) return { problem: `${task.id}:coda:action 只能是 ${Object.keys(CODA_ACTIONS).join('、')}` };
182
248
  const params = CODA_ACTIONS[task.action];
183
249
  const given = (task.extensionElements?.values ?? []).filter(v => v.$type === 'coda:Param');
184
250
  if (given.some(p => !params.includes(p.name))) return { problem: `${task.id}:coda:action ${task.action} 只接受参数 ${params.join('、')}` };
185
251
  }
186
- const problem = valuesProblem(root);
252
+ const problem = valuesProblem(root) ?? structureProblem(root, file);
187
253
  return problem ? { problem } : { root };
188
254
  }
189
255
  // What of a workflow's definition the dialect does not have, or null.