@coda-console/protocol 1.7.0 → 1.9.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
+ }
package/workflow.mjs CHANGED
@@ -3,8 +3,8 @@
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.
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
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
@@ -23,6 +23,8 @@ import actions from './workflow/actions.json' with { type: 'json' };
23
23
  import dialect from './workflow/dialect.json' with { type: 'json' };
24
24
  import fields from './workflow/fields.json' with { type: 'json' };
25
25
  import { valueKeywords } from './workflow/keywords.mjs';
26
+ import { structureChecker } from './workflow/structure.mjs';
27
+ export { CODA_STRUCTURE } from './workflow/structure.mjs';
26
28
 
27
29
  export const CODA_MODDLE = moddle;
28
30
  export const CODA_NAMESPACE = moddle.uri;
@@ -35,7 +37,7 @@ export const WORKFLOW_KINDS = Object.keys(dialect.kinds);
35
37
  export const DEPLOYING_KINDS = dialect.deploying;
36
38
  // The events Coda sends (dialect.json says what each is).
37
39
  export const CODA_EVENTS = Object.keys(dialect.events);
38
- 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;
39
41
  // A message the user sends from a run's page (`user.<what>`), while the run waits on it.
40
42
  export const USER_MESSAGE = new RegExp(dialect.userMessage);
41
43
  export const STEP_FAILED = dialect.stepFailed;
@@ -94,6 +96,7 @@ ajv.addKeyword({ keyword: 'x-duration', type: 'string', schemaType: 'boolean', c
94
96
  ajv.addKeyword({ keyword: 'x-number', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || Number.isFinite(Number(value)) });
95
97
  ajv.addSchema(fields);
96
98
  ajv.addSchema(values);
99
+ const structureProblem = structureChecker(ajv, { feelProblem, isExpression });
97
100
  const checks = new Map();
98
101
  // The validator of a definition of values.json, by its name; none for a type it says nothing of.
99
102
  const check = name => {
@@ -225,9 +228,11 @@ function valuesProblem(root) {
225
228
  // A workflow's definition (`file`, its name without `.bpmn`, for what is said) read with Coda's dialect: `{ root }`, the
226
229
  // parsed definitions, or `{ problem }`, what of it the dialect does not have — not BPMN, a coda: attribute or element it
227
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
228
- // not take.
231
+ // not take, or a structure the workflow cannot run with.
229
232
  export async function readWorkflow(source, file = 'workflow') {
230
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])}` };
231
236
  let parsed;
232
237
  try { parsed = await new BpmnModdle({ coda: structuredClone(PARSER_DESCRIPTOR) }).fromXML(source); }
233
238
  catch (e) { return { problem: `${file}.bpmn 不是有效的 BPMN 2.0:${e.message}` }; }
@@ -244,7 +249,7 @@ export async function readWorkflow(source, file = 'workflow') {
244
249
  const given = (task.extensionElements?.values ?? []).filter(v => v.$type === 'coda:Param');
245
250
  if (given.some(p => !params.includes(p.name))) return { problem: `${task.id}:coda:action ${task.action} 只接受参数 ${params.join('、')}` };
246
251
  }
247
- const problem = valuesProblem(root);
252
+ const problem = valuesProblem(root) ?? structureProblem(root, file);
248
253
  return problem ? { problem } : { root };
249
254
  }
250
255
  // What of a workflow's definition the dialect does not have, or null.