@coda-console/protocol 1.7.0 → 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.
- package/README.md +8 -2
- package/package.json +1 -1
- package/workflow/dialect.json +1 -0
- package/workflow/structure.json +920 -0
- package/workflow/structure.mjs +256 -0
- package/workflow.mjs +10 -5
package/README.md
CHANGED
|
@@ -4,9 +4,15 @@ Coda 与插件之间的约定,Coda 和插件都引用它,独立演进:
|
|
|
4
4
|
|
|
5
5
|
- **插件清单的类型**(`schemas/plugin/`):插件目录 `package.json` 里 `contributes` 的每个键是一种类型,各有一份 JSON Schema(draft 2020-12,`$id` 为 `https://coda.pub/schemas/plugin/v1/<文件>`)。`plugin.json` 是整份清单,`setting.json` 是一项设置的声明。插件写的合乎它用到的类型就对;写给别的插件扩展点的键由那个插件声明,扩展点可以带自己的 schema(`extensionPoints.<名>.schema`,声明它的插件目录里的一个文件),写给它的东西合乎那份 schema 才对。
|
|
6
6
|
- **通用校验器**(`validate.mjs`):用 Ajv 对清单做 schema 校验。JSON Schema 说不了的只用五个通用关键字(`x-unique-by`、`x-max-contains`、`x-ref`、`x-placeholders`、`x-file`,照 Kubernetes 的 `x-kubernetes-*`),每个在哪里写都是同一个意思,写法见文件开头。校验器不随功能变长:类型加字段就是改 schema。
|
|
7
|
-
- **流程方言**(`workflow/`):项目和插件的流程(`.coda/workflows/*.bpmn`,BPMN 2.0)在 Coda 的命名空间 `https://coda.pub/schema/bpmn/1.0` 里能写什么。`moddle.json` 是 `coda:` 属性与元素(bpmn-moddle 的描述格式),`values.json` 是每个值能写什么(每个 `coda:` 类型的属性组成的对象的 JSON Schema,一个元素里同种 `coda:` 元素成组的 schema),`actions.json` 是 `coda:action` 的各个动作和它们接受的参数,`dialect.json` 是流程种类、Coda 自己发的事件、步骤失败的错误码、表达式能读的运行变量和运行的输入能是哪些类型。`workflow.mjs` 用它们读一份流程:方言里没有的、值不合 schema 的都拒绝,说法是「位置:schema 对那里的说明」。值的 schema 用清单的那几个关键字,另加三个:`x-feel`(是 FEEL:「=」开头是表达式,否则文字里的 `{{ … }}` 是,按这份流程里的名字读得通)、`x-duration`(ISO 8601 时长,不为负)、`x-number
|
|
7
|
+
- **流程方言**(`workflow/`):项目和插件的流程(`.coda/workflows/*.bpmn`,BPMN 2.0)在 Coda 的命名空间 `https://coda.pub/schema/bpmn/1.0` 里能写什么。`moddle.json` 是 `coda:` 属性与元素(bpmn-moddle 的描述格式),`values.json` 是每个值能写什么(每个 `coda:` 类型的属性组成的对象的 JSON Schema,一个元素里同种 `coda:` 元素成组的 schema),`actions.json` 是 `coda:action` 的各个动作和它们接受的参数,`dialect.json` 是流程种类、Coda 自己发的事件、步骤失败的错误码、表达式能读的运行变量和运行的输入能是哪些类型。`workflow.mjs` 用它们读一份流程:方言里没有的、值不合 schema 的都拒绝,说法是「位置:schema 对那里的说明」。值的 schema 用清单的那几个关键字,另加三个:`x-feel`(是 FEEL:「=」开头是表达式,否则文字里的 `{{ … }}` 是,按这份流程里的名字读得通)、`x-duration`(ISO 8601 时长,不为负)、`x-number`(文字换算成数字是个有限的数)。`structure.json` 声明流程结构规则,`structure.mjs` 负责跨节点检查。
|
|
8
8
|
- **Agent 接入协议**(`docs/agent-protocol.md`):Coda 怎样用 ACP 接 Agent,以及 Coda 加的扩展(会话指令、插话、`_coda/wait`、执行失败、工具调用的审核)。
|
|
9
9
|
|
|
10
|
+
流程方言的 **结构规则**从 1.8.0 起也由 `readWorkflow` / `workflowProblem` 检查。`workflow/structure.json` 声明支持的顶层元素与不支持的流程元素、各位置的事件定义、`coda:` 属性的位置限制、任务与多实例的局部写法、脚本数量、定时间隔和生命周期等待类型。`rules` 使用 JSON Schema 检查节点的无环投影(原始标量属性与父级、事件种类等固定事实),复用 `newAjv()` 和已有关键字;`moddle.json` 的 `extends` 仍声明属性可附着的基础类型。`CODA_STRUCTURE` 导出同一份表。新增局部规则改数据,不增加校验器分支。
|
|
11
|
+
|
|
12
|
+
`structure.mjs` 的固定检查处理数据表无法单独表达的关系:事件子流程与顺序流、连接事件按作用域配对、事件网关的出口、补偿关联、经过子流程与边界事件的可达性、事件启动以及生命周期的「聋状态」。Coda 的事件名通过 `dialect.json` 的 `eventRoles` 读取;等待动作与节点类型也来自数据表。ISO 8601 重复间隔用 `@0dep/piso` 解析,脚本变量边界用固定的词法扫描检查,条件和 documentation 用同一份 FEEL 检查。图和嵌套树使用显式栈与 Map/Set,不递归、不把大数组展开成函数参数。报错尽量保留 Coda 的原位置与原话。
|
|
13
|
+
|
|
14
|
+
读取定义时原有的 Coda 结构规则已全部纳入协议。Coda 接入 1.8.0 后,`parseBpmn` 只需调用 `readWorkflow`,再把 `root` 编译成引擎、任务、调度和图所需的结构;运行时才能知道的表达式结果、动态字段引用、调度设置实际值及能力是否存在,仍由宿主在运行时检查。
|
|
15
|
+
|
|
10
16
|
流程方言的 **卡片字段**由 `workflow/fields.json` 定义:七种字段各能写哪些属性、必填项、文字长度与单行限制、路径与网址、视图参数、输入框与密钥选项;`values.json` 的 `Field` 引用它,`Fields` 声明读出 JSON 字段列表的表达式,`UserTask.children` 限定最多 12 个静态字段、id 不重复,以及全部字段已知时答复的 `fields` 只能引用输入字段(含按住出现的答复)。
|
|
11
17
|
|
|
12
18
|
定义和运行时共用这一份 schema。`x-deferred` 包住一个 schema:读定义时,文字先检查 FEEL,表达式或含 `{{ … }}` 的模板留到打开任务后查;运行时只按包住的 schema 严格检查,不再把结果当表达式。只有内容属性使用它,`type`、`id`、布尔值、枚举和 `maxLength` 不放宽。另有 `x-length`(沿用 Coda 的 UTF-16 长度,可用 `{ "property": "maxLength" }` 读取同一对象的上限)、`x-json`(解析 JSON 文字或检查 JSON 值,按序列化后的 UTF-8 字节数限制大小);`absolute-path`、`https-url` 两个 format 按本机路径与 URL 解析器检查。已有的 `x-ref` 增加 `key`(按数组元素的属性引用)和 `split`(逐个检查分隔后的名字)。这些操作都不改写输入。
|
|
@@ -59,7 +65,7 @@ fieldsProblem(JSON.parse(fieldsJSON), 'ask');
|
|
|
59
65
|
|
|
60
66
|
## 谁在用
|
|
61
67
|
|
|
62
|
-
- **Coda**:读插件时用它校验清单,再整理成自己的结构;只另看插件身份(ID、设置名在 ID
|
|
68
|
+
- **Coda**:读插件时用它校验清单,再整理成自己的结构;只另看插件身份(ID、设置名在 ID 之下),以及装到一台机器上才知道的(来源、已装插件的冲突)。读流程时用 `readWorkflow` 同时校验方言、值与结构,再编译成引擎需要的数据。
|
|
63
69
|
- **coda-plugins 的契约检查包**(`@coda-console/plugin-check`):在写插件的地方用同一个校验器判清单。
|
|
64
70
|
|
|
65
71
|
## 版本
|
package/package.json
CHANGED
package/workflow/dialect.json
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"workflow.dispatch": "手动发起(workflow_run、客户端的按钮):由事件启动的流程也订阅它,才能手动发起",
|
|
15
15
|
"workflow.cancel": "要它停下:在等它的运行被告知,走到自己的结束,以取消结束"
|
|
16
16
|
},
|
|
17
|
+
"eventRoles": {"lifecycleStart": "session.opened", "lifecycleFacts": "session.facts", "dispatch": "workflow.dispatch", "cancel": "workflow.cancel"},
|
|
17
18
|
"userMessage": "^user\\.[a-z][a-z0-9-]{0,39}$",
|
|
18
19
|
"stepFailed": "STEP_FAILED",
|
|
19
20
|
"variables": ["inputs", "run", "github", "vars"],
|
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "流程结构:支持的元素、事件位置、局部节点规则与属性位置。rules 对 structure.mjs 的无环节点投影运行 JSON Schema;跨节点引用、配对、可达性与生命周期等待由固定图检查负责。",
|
|
3
|
+
"unsupported": {
|
|
4
|
+
"bpmn:SendTask": "Coda 的消息是外部来的事件,流程只收不发;流程里互相通知用信号(signal)",
|
|
5
|
+
"bpmn:BusinessRuleTask": "Coda 不带决策表:判断写成网关的条件,或一个 scriptTask",
|
|
6
|
+
"bpmn:ComplexGateway": "引擎不运行复杂网关:写成包容网关,各出口带条件",
|
|
7
|
+
"bpmn:DataObject": "数据按任务结果与变量传递(见「表达式」),不写数据对象",
|
|
8
|
+
"bpmn:DataObjectReference": "数据按任务结果与变量传递(见「表达式」),不写数据对象",
|
|
9
|
+
"bpmn:DataStoreReference": "数据按任务结果与变量传递(见「表达式」),不写数据对象",
|
|
10
|
+
"bpmn:ImplicitThrowEvent": "它不是流程里的元素",
|
|
11
|
+
"bpmn:ChoreographyTask": "协作图的元素不在流程里运行",
|
|
12
|
+
"bpmn:SubChoreography": "协作图的元素不在流程里运行",
|
|
13
|
+
"bpmn:CallChoreography": "协作图的元素不在流程里运行"
|
|
14
|
+
},
|
|
15
|
+
"events": {
|
|
16
|
+
"start": [
|
|
17
|
+
"Message",
|
|
18
|
+
"Timer"
|
|
19
|
+
],
|
|
20
|
+
"eventStart": [
|
|
21
|
+
"Message",
|
|
22
|
+
"Timer",
|
|
23
|
+
"Signal",
|
|
24
|
+
"Conditional",
|
|
25
|
+
"Error",
|
|
26
|
+
"Escalation"
|
|
27
|
+
],
|
|
28
|
+
"innerStart": [],
|
|
29
|
+
"bpmn:EndEvent": [
|
|
30
|
+
"Error",
|
|
31
|
+
"Terminate",
|
|
32
|
+
"Signal",
|
|
33
|
+
"Escalation",
|
|
34
|
+
"Compensate",
|
|
35
|
+
"Cancel"
|
|
36
|
+
],
|
|
37
|
+
"bpmn:IntermediateCatchEvent": [
|
|
38
|
+
"Timer",
|
|
39
|
+
"Message",
|
|
40
|
+
"Signal",
|
|
41
|
+
"Conditional",
|
|
42
|
+
"Link"
|
|
43
|
+
],
|
|
44
|
+
"bpmn:IntermediateThrowEvent": [
|
|
45
|
+
"Signal",
|
|
46
|
+
"Escalation",
|
|
47
|
+
"Link",
|
|
48
|
+
"Compensate"
|
|
49
|
+
],
|
|
50
|
+
"bpmn:BoundaryEvent": [
|
|
51
|
+
"Error",
|
|
52
|
+
"Timer",
|
|
53
|
+
"Message",
|
|
54
|
+
"Signal",
|
|
55
|
+
"Escalation",
|
|
56
|
+
"Conditional",
|
|
57
|
+
"Compensate",
|
|
58
|
+
"Cancel"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"eventNames": {
|
|
62
|
+
"start": "流程的开始事件",
|
|
63
|
+
"eventStart": "事件子流程的开始事件",
|
|
64
|
+
"innerStart": "子流程的开始事件",
|
|
65
|
+
"bpmn:EndEvent": "结束事件",
|
|
66
|
+
"bpmn:IntermediateCatchEvent": "中间捕获事件",
|
|
67
|
+
"bpmn:IntermediateThrowEvent": "中间抛出事件",
|
|
68
|
+
"bpmn:BoundaryEvent": "边界事件"
|
|
69
|
+
},
|
|
70
|
+
"kindNames": {
|
|
71
|
+
"Message": "消息",
|
|
72
|
+
"Timer": "计时器",
|
|
73
|
+
"Signal": "信号",
|
|
74
|
+
"Conditional": "条件",
|
|
75
|
+
"Error": "出错",
|
|
76
|
+
"Escalation": "升级",
|
|
77
|
+
"Terminate": "终止",
|
|
78
|
+
"Link": "连接",
|
|
79
|
+
"Compensate": "补偿",
|
|
80
|
+
"Cancel": "取消"
|
|
81
|
+
},
|
|
82
|
+
"topLevel": [
|
|
83
|
+
"bpmn:Process",
|
|
84
|
+
"bpmn:Error",
|
|
85
|
+
"bpmn:Signal",
|
|
86
|
+
"bpmn:Escalation",
|
|
87
|
+
"bpmn:Message"
|
|
88
|
+
],
|
|
89
|
+
"id": {
|
|
90
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}$",
|
|
91
|
+
"description": "id 须为字母开头的字母、数字、「_」「-」"
|
|
92
|
+
},
|
|
93
|
+
"requiredEvents": [
|
|
94
|
+
"eventStart",
|
|
95
|
+
"bpmn:IntermediateCatchEvent",
|
|
96
|
+
"bpmn:BoundaryEvent"
|
|
97
|
+
],
|
|
98
|
+
"eventSuffix": {
|
|
99
|
+
"start": ";运行里发生的信号、条件、出错或升级,交给事件子流程(triggeredByEvent)",
|
|
100
|
+
"innerStart": ";只有最外层的开始事件能带计时器或消息,子流程里要等事件,用中间捕获事件或事件子流程"
|
|
101
|
+
},
|
|
102
|
+
"throwing": [
|
|
103
|
+
"bpmn:IntermediateThrowEvent",
|
|
104
|
+
"bpmn:EndEvent"
|
|
105
|
+
],
|
|
106
|
+
"steps": [
|
|
107
|
+
"bpmn:ScriptTask",
|
|
108
|
+
"bpmn:ServiceTask",
|
|
109
|
+
"bpmn:UserTask",
|
|
110
|
+
"bpmn:CallActivity"
|
|
111
|
+
],
|
|
112
|
+
"maxScripts": 50,
|
|
113
|
+
"schedule": {
|
|
114
|
+
"maxLength": 200,
|
|
115
|
+
"minMilliseconds": 300000,
|
|
116
|
+
"settingsPattern": "^\\s*=\\s*vars(\\.[A-Za-z0-9_-]+)+\\s*$",
|
|
117
|
+
"description": "定时开始只接受无限重复、无结束时间、间隔至少 5 分钟;表达式只读插件设置。"
|
|
118
|
+
},
|
|
119
|
+
"lifecycle": {
|
|
120
|
+
"waitTypes": [
|
|
121
|
+
"bpmn:ReceiveTask",
|
|
122
|
+
"bpmn:IntermediateCatchEvent",
|
|
123
|
+
"bpmn:CallActivity",
|
|
124
|
+
"bpmn:ScriptTask"
|
|
125
|
+
],
|
|
126
|
+
"waitActions": [
|
|
127
|
+
"run-workflows"
|
|
128
|
+
],
|
|
129
|
+
"description": "这些节点会等待;其余带计时边界的非子流程节点也会等待。生命周期流程的等待必须能收会话事实。"
|
|
130
|
+
},
|
|
131
|
+
"userMessages": {
|
|
132
|
+
"prefix": "user.",
|
|
133
|
+
"maxLabelLength": 40
|
|
134
|
+
},
|
|
135
|
+
"inputFields": [
|
|
136
|
+
"text",
|
|
137
|
+
"secret"
|
|
138
|
+
],
|
|
139
|
+
"rules": [
|
|
140
|
+
{
|
|
141
|
+
"description": "coda:expect 只写在子流程上",
|
|
142
|
+
"schema": {
|
|
143
|
+
"properties": {
|
|
144
|
+
"subprocess": {
|
|
145
|
+
"const": true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"when": {
|
|
150
|
+
"required": [
|
|
151
|
+
"expect"
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"description": "coda:optional 只写在最外层的子流程上(事件子流程不是阶段)",
|
|
157
|
+
"schema": {
|
|
158
|
+
"allOf": [
|
|
159
|
+
{
|
|
160
|
+
"properties": {
|
|
161
|
+
"subprocess": {
|
|
162
|
+
"const": true
|
|
163
|
+
},
|
|
164
|
+
"top": {
|
|
165
|
+
"const": true
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"not": {
|
|
171
|
+
"properties": {
|
|
172
|
+
"triggeredByEvent": {
|
|
173
|
+
"const": true
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
"when": {
|
|
181
|
+
"required": [
|
|
182
|
+
"optional"
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"description": "coda:notify 只写在流程最外层的结束事件上",
|
|
188
|
+
"schema": {
|
|
189
|
+
"properties": {
|
|
190
|
+
"type": {
|
|
191
|
+
"const": "bpmn:EndEvent"
|
|
192
|
+
},
|
|
193
|
+
"top": {
|
|
194
|
+
"const": true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"when": {
|
|
199
|
+
"required": [
|
|
200
|
+
"notify"
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"description": "coda:reportTo、coda:reportOn、coda:scope 只写在定时开始事件上(由会话发起的运行交给发起它的会话)",
|
|
206
|
+
"schema": {
|
|
207
|
+
"properties": {
|
|
208
|
+
"position": {
|
|
209
|
+
"const": "start"
|
|
210
|
+
},
|
|
211
|
+
"eventKind": {
|
|
212
|
+
"const": "Timer"
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"when": {
|
|
217
|
+
"anyOf": [
|
|
218
|
+
{
|
|
219
|
+
"required": [
|
|
220
|
+
"reportTo"
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"required": [
|
|
225
|
+
"reportOn"
|
|
226
|
+
]
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"required": [
|
|
230
|
+
"scope"
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"description": "脚本步骤写成 <scriptTask scriptFormat=\"bash\">,脚本放在 <script> 里;serviceTask 只用于 coda:action",
|
|
238
|
+
"schema": {
|
|
239
|
+
"required": [
|
|
240
|
+
"action"
|
|
241
|
+
]
|
|
242
|
+
},
|
|
243
|
+
"on": [
|
|
244
|
+
"bpmn:ServiceTask"
|
|
245
|
+
],
|
|
246
|
+
"when": {
|
|
247
|
+
"properties": {
|
|
248
|
+
"hasScriptExtension": {
|
|
249
|
+
"const": true
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"description": "serviceTask 是 Coda 自己做的一步,须写 coda:action;脚本步骤写成 scriptTask",
|
|
256
|
+
"schema": {
|
|
257
|
+
"required": [
|
|
258
|
+
"action"
|
|
259
|
+
]
|
|
260
|
+
},
|
|
261
|
+
"on": [
|
|
262
|
+
"bpmn:ServiceTask"
|
|
263
|
+
]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"description": "coda:action 的步骤由 Coda 执行,不写脚本与 coda:audience、coda:group、coda:timeout、coda:checkout、coda:retry 等(要为一段占组用 hold-group)",
|
|
267
|
+
"schema": {
|
|
268
|
+
"allOf": [
|
|
269
|
+
{
|
|
270
|
+
"properties": {
|
|
271
|
+
"hasScriptExtension": {
|
|
272
|
+
"const": false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
"not": {
|
|
278
|
+
"anyOf": [
|
|
279
|
+
{
|
|
280
|
+
"required": [
|
|
281
|
+
"audience"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"required": [
|
|
286
|
+
"group"
|
|
287
|
+
]
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
"required": [
|
|
291
|
+
"timeout"
|
|
292
|
+
]
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"required": [
|
|
296
|
+
"cancelTimeout"
|
|
297
|
+
]
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"required": [
|
|
301
|
+
"workingDirectory"
|
|
302
|
+
]
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"required": [
|
|
306
|
+
"checkout"
|
|
307
|
+
]
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"required": [
|
|
311
|
+
"retry"
|
|
312
|
+
]
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"required": [
|
|
316
|
+
"implementation"
|
|
317
|
+
],
|
|
318
|
+
"properties": {
|
|
319
|
+
"implementation": {
|
|
320
|
+
"minLength": 1
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
]
|
|
328
|
+
},
|
|
329
|
+
"on": [
|
|
330
|
+
"bpmn:ServiceTask"
|
|
331
|
+
]
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"description": "callActivity 的 calledElement 写要调用的能力或流程名(小写字母、数字和「-」,以字母开头)",
|
|
335
|
+
"schema": {
|
|
336
|
+
"required": [
|
|
337
|
+
"calledElement"
|
|
338
|
+
],
|
|
339
|
+
"properties": {
|
|
340
|
+
"calledElement": {
|
|
341
|
+
"type": "string",
|
|
342
|
+
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
"on": [
|
|
347
|
+
"bpmn:CallActivity"
|
|
348
|
+
]
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
"description": "scriptTask 写 scriptFormat=\"bash\"(脚本由 bash -e 执行)",
|
|
352
|
+
"schema": {
|
|
353
|
+
"required": [
|
|
354
|
+
"scriptFormat"
|
|
355
|
+
],
|
|
356
|
+
"properties": {
|
|
357
|
+
"scriptFormat": {
|
|
358
|
+
"const": "bash"
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
"on": [
|
|
363
|
+
"bpmn:ScriptTask"
|
|
364
|
+
]
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"description": "脚本放在 scriptTask 的 <script> 里,不写 coda:script",
|
|
368
|
+
"schema": {
|
|
369
|
+
"properties": {
|
|
370
|
+
"hasScriptExtension": {
|
|
371
|
+
"const": false
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
"on": [
|
|
376
|
+
"bpmn:ScriptTask"
|
|
377
|
+
]
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"description": "scriptTask 需要 <script>",
|
|
381
|
+
"schema": {
|
|
382
|
+
"required": [
|
|
383
|
+
"script"
|
|
384
|
+
],
|
|
385
|
+
"properties": {
|
|
386
|
+
"script": {
|
|
387
|
+
"pattern": "\\S"
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
"on": [
|
|
392
|
+
"bpmn:ScriptTask"
|
|
393
|
+
]
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"description": "脚本过长",
|
|
397
|
+
"schema": {
|
|
398
|
+
"properties": {
|
|
399
|
+
"script": {
|
|
400
|
+
"x-length": 20000
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
"on": [
|
|
405
|
+
"bpmn:ScriptTask"
|
|
406
|
+
]
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
"description": "coda:output 只写在自动步骤与结束事件上",
|
|
410
|
+
"schema": {
|
|
411
|
+
"properties": {
|
|
412
|
+
"type": {
|
|
413
|
+
"enum": [
|
|
414
|
+
"bpmn:ScriptTask",
|
|
415
|
+
"bpmn:EndEvent"
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"when": {
|
|
421
|
+
"properties": {
|
|
422
|
+
"hasOutputs": {
|
|
423
|
+
"const": true
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"description": "脚本步骤的 coda:output 只写 name,值由脚本写进 $GITHUB_OUTPUT",
|
|
430
|
+
"schema": {
|
|
431
|
+
"properties": {
|
|
432
|
+
"outputHasValue": {
|
|
433
|
+
"const": false
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
"on": [
|
|
438
|
+
"bpmn:ScriptTask"
|
|
439
|
+
]
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
"description": "documentation 过长",
|
|
443
|
+
"schema": {
|
|
444
|
+
"properties": {
|
|
445
|
+
"documentation": {
|
|
446
|
+
"x-length": 4000
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
"on": [
|
|
451
|
+
"bpmn:UserTask"
|
|
452
|
+
]
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"description": "有输入字段(text、secret 或 coda:fields)的任务只交给用户,写 coda:assignee=\"user\"",
|
|
456
|
+
"schema": {
|
|
457
|
+
"required": [
|
|
458
|
+
"assignee"
|
|
459
|
+
],
|
|
460
|
+
"properties": {
|
|
461
|
+
"assignee": {
|
|
462
|
+
"const": "user"
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
"on": [
|
|
467
|
+
"bpmn:UserTask"
|
|
468
|
+
],
|
|
469
|
+
"when": {
|
|
470
|
+
"properties": {
|
|
471
|
+
"hasInputFields": {
|
|
472
|
+
"const": true
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"description": "不支持标准循环(standardLoopCharacteristics):重复做一件事写成连回去的连线加网关,按集合或次数做多份写多实例",
|
|
479
|
+
"schema": {
|
|
480
|
+
"properties": {
|
|
481
|
+
"loopType": {
|
|
482
|
+
"const": "bpmn:MultiInstanceLoopCharacteristics"
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
"when": {
|
|
487
|
+
"required": [
|
|
488
|
+
"loopType"
|
|
489
|
+
]
|
|
490
|
+
},
|
|
491
|
+
"at": "path"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"description": "多实例只写在任务、子流程与 callActivity 上",
|
|
495
|
+
"schema": {
|
|
496
|
+
"allOf": [
|
|
497
|
+
{
|
|
498
|
+
"properties": {
|
|
499
|
+
"type": {
|
|
500
|
+
"enum": [
|
|
501
|
+
"bpmn:Task",
|
|
502
|
+
"bpmn:ManualTask",
|
|
503
|
+
"bpmn:ScriptTask",
|
|
504
|
+
"bpmn:ServiceTask",
|
|
505
|
+
"bpmn:UserTask",
|
|
506
|
+
"bpmn:ReceiveTask",
|
|
507
|
+
"bpmn:CallActivity",
|
|
508
|
+
"bpmn:SubProcess",
|
|
509
|
+
"bpmn:Transaction",
|
|
510
|
+
"bpmn:AdHocSubProcess"
|
|
511
|
+
]
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
"not": {
|
|
517
|
+
"properties": {
|
|
518
|
+
"triggeredByEvent": {
|
|
519
|
+
"const": true
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
},
|
|
526
|
+
"when": {
|
|
527
|
+
"required": [
|
|
528
|
+
"loopType"
|
|
529
|
+
]
|
|
530
|
+
},
|
|
531
|
+
"at": "path"
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"description": "多实例要写做几份:coda:inputCollection(按集合,每份一个元素)或 loopCardinality(次数)",
|
|
535
|
+
"schema": {
|
|
536
|
+
"anyOf": [
|
|
537
|
+
{
|
|
538
|
+
"required": [
|
|
539
|
+
"collection"
|
|
540
|
+
]
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
"required": [
|
|
544
|
+
"cardinality"
|
|
545
|
+
]
|
|
546
|
+
}
|
|
547
|
+
]
|
|
548
|
+
},
|
|
549
|
+
"when": {
|
|
550
|
+
"required": [
|
|
551
|
+
"loopType"
|
|
552
|
+
]
|
|
553
|
+
},
|
|
554
|
+
"at": "path"
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
"description": "loopCardinality 写非负整数,或「=」开头的 FEEL 表达式",
|
|
558
|
+
"schema": {
|
|
559
|
+
"properties": {
|
|
560
|
+
"cardinality": {
|
|
561
|
+
"pattern": "^(?:\\s*=|\\d+$)"
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
"when": {
|
|
566
|
+
"required": [
|
|
567
|
+
"cardinality"
|
|
568
|
+
]
|
|
569
|
+
},
|
|
570
|
+
"at": "path"
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
"description": "completionCondition 须为「=」开头的 FEEL 表达式,如 =deploy.ok",
|
|
574
|
+
"schema": {
|
|
575
|
+
"properties": {
|
|
576
|
+
"loopCompletion": {
|
|
577
|
+
"pattern": "^\\s*="
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
"when": {
|
|
582
|
+
"required": [
|
|
583
|
+
"loopCompletion"
|
|
584
|
+
]
|
|
585
|
+
},
|
|
586
|
+
"at": "path"
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
"description": "completionCondition 须为「=」开头的 FEEL 表达式",
|
|
590
|
+
"schema": {
|
|
591
|
+
"properties": {
|
|
592
|
+
"completion": {
|
|
593
|
+
"pattern": "^\\s*="
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
"on": [
|
|
598
|
+
"bpmn:AdHocSubProcess"
|
|
599
|
+
],
|
|
600
|
+
"when": {
|
|
601
|
+
"required": [
|
|
602
|
+
"completion"
|
|
603
|
+
]
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
"description": "自由子流程里不写开始、结束事件:里面没有进线的节点都会开始",
|
|
608
|
+
"schema": {
|
|
609
|
+
"properties": {
|
|
610
|
+
"hasStartOrEnd": {
|
|
611
|
+
"const": false
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
"on": [
|
|
616
|
+
"bpmn:AdHocSubProcess"
|
|
617
|
+
]
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
"description": "出错开始的事件子流程总是打断它所在的范围,不写 isInterrupting=\"false\"",
|
|
621
|
+
"schema": {
|
|
622
|
+
"not": {
|
|
623
|
+
"properties": {
|
|
624
|
+
"isInterrupting": {
|
|
625
|
+
"const": false
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
"when": {
|
|
631
|
+
"properties": {
|
|
632
|
+
"position": {
|
|
633
|
+
"const": "eventStart"
|
|
634
|
+
},
|
|
635
|
+
"eventKind": {
|
|
636
|
+
"const": "Error"
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
"at": "path"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"description": "事件子流程的计时器写 timeDuration 或 timeDate,从它所在的范围开始时算起",
|
|
644
|
+
"schema": {
|
|
645
|
+
"properties": {
|
|
646
|
+
"hasTimeCycle": {
|
|
647
|
+
"const": false
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
"when": {
|
|
652
|
+
"properties": {
|
|
653
|
+
"position": {
|
|
654
|
+
"const": "eventStart"
|
|
655
|
+
},
|
|
656
|
+
"eventKind": {
|
|
657
|
+
"const": "Timer"
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
"at": "path"
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
"description": "消息须引用一个有 name 的 <message>(name 是 Coda 的事件名,如 session.facts)",
|
|
665
|
+
"schema": {
|
|
666
|
+
"properties": {
|
|
667
|
+
"messageNamed": {
|
|
668
|
+
"const": true
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
"when": {
|
|
673
|
+
"properties": {
|
|
674
|
+
"eventKind": {
|
|
675
|
+
"const": "Message"
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
"at": "path"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
"description": "receiveTask 须引用一个有 name 的 <message>(name 是 Coda 的事件名,如 session.facts)",
|
|
683
|
+
"schema": {
|
|
684
|
+
"properties": {
|
|
685
|
+
"messageNamed": {
|
|
686
|
+
"const": true
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
"on": [
|
|
691
|
+
"bpmn:ReceiveTask"
|
|
692
|
+
],
|
|
693
|
+
"at": "path"
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
"description": "信号须引用一个 <signal>,抛出与捕获按它配对",
|
|
697
|
+
"schema": {
|
|
698
|
+
"properties": {
|
|
699
|
+
"signalReferenced": {
|
|
700
|
+
"const": true
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
"when": {
|
|
705
|
+
"properties": {
|
|
706
|
+
"eventKind": {
|
|
707
|
+
"const": "Signal"
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
"at": "path"
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
"description": "抛出的升级须引用一个 <escalation>",
|
|
715
|
+
"schema": {
|
|
716
|
+
"properties": {
|
|
717
|
+
"escalationReferenced": {
|
|
718
|
+
"const": true
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
"on": [
|
|
723
|
+
"bpmn:IntermediateThrowEvent",
|
|
724
|
+
"bpmn:EndEvent"
|
|
725
|
+
],
|
|
726
|
+
"when": {
|
|
727
|
+
"properties": {
|
|
728
|
+
"eventKind": {
|
|
729
|
+
"const": "Escalation"
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
"at": "path"
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
"description": "连接事件须写 name,抛出与捕获按它配对",
|
|
737
|
+
"schema": {
|
|
738
|
+
"properties": {
|
|
739
|
+
"linkNamed": {
|
|
740
|
+
"const": true
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
"when": {
|
|
745
|
+
"properties": {
|
|
746
|
+
"eventKind": {
|
|
747
|
+
"const": "Link"
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
"at": "path"
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
"description": "条件只支持 FEEL 表达式,不运行脚本",
|
|
755
|
+
"schema": {
|
|
756
|
+
"properties": {
|
|
757
|
+
"conditionLanguage": {
|
|
758
|
+
"enum": [
|
|
759
|
+
"",
|
|
760
|
+
"feel"
|
|
761
|
+
]
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
"when": {
|
|
766
|
+
"required": [
|
|
767
|
+
"conditionLanguage"
|
|
768
|
+
]
|
|
769
|
+
},
|
|
770
|
+
"at": "path"
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
"description": "条件事件的条件须为「=」开头的 FEEL 表达式,如 =checks.passed",
|
|
774
|
+
"schema": {
|
|
775
|
+
"required": [
|
|
776
|
+
"condition"
|
|
777
|
+
],
|
|
778
|
+
"properties": {
|
|
779
|
+
"condition": {
|
|
780
|
+
"pattern": "^\\s*="
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
"when": {
|
|
785
|
+
"properties": {
|
|
786
|
+
"eventKind": {
|
|
787
|
+
"const": "Conditional"
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
"at": "path"
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
"description": "条件须为「=」开头的 FEEL 表达式,如 =check.go",
|
|
795
|
+
"schema": {
|
|
796
|
+
"required": [
|
|
797
|
+
"condition"
|
|
798
|
+
],
|
|
799
|
+
"properties": {
|
|
800
|
+
"condition": {
|
|
801
|
+
"pattern": "^\\s*="
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
"on": [
|
|
806
|
+
"bpmn:SequenceFlow"
|
|
807
|
+
],
|
|
808
|
+
"when": {
|
|
809
|
+
"properties": {
|
|
810
|
+
"hasCondition": {
|
|
811
|
+
"const": true
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
"at": "path"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
"description": "取消结束事件只写在事务子流程(transaction)里",
|
|
819
|
+
"schema": {
|
|
820
|
+
"properties": {
|
|
821
|
+
"parentType": {
|
|
822
|
+
"const": "bpmn:Transaction"
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
"on": [
|
|
827
|
+
"bpmn:EndEvent"
|
|
828
|
+
],
|
|
829
|
+
"when": {
|
|
830
|
+
"properties": {
|
|
831
|
+
"eventKind": {
|
|
832
|
+
"const": "Cancel"
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
"description": "取消边界事件只挂在事务子流程(transaction)上",
|
|
839
|
+
"schema": {
|
|
840
|
+
"properties": {
|
|
841
|
+
"attachedType": {
|
|
842
|
+
"const": "bpmn:Transaction"
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
"on": [
|
|
847
|
+
"bpmn:BoundaryEvent"
|
|
848
|
+
],
|
|
849
|
+
"when": {
|
|
850
|
+
"properties": {
|
|
851
|
+
"eventKind": {
|
|
852
|
+
"const": "Cancel"
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
],
|
|
858
|
+
"readable": [
|
|
859
|
+
{
|
|
860
|
+
"property": "condition",
|
|
861
|
+
"where": "path"
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
"property": "cardinality",
|
|
865
|
+
"where": "path 的 loopCardinality"
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
"property": "loopCompletion",
|
|
869
|
+
"where": "path 的 completionCondition"
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
"property": "completion",
|
|
873
|
+
"where": "id 的 completionCondition"
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
"property": "documentation",
|
|
877
|
+
"where": "id 的 documentation"
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
"property": "timeDuration",
|
|
881
|
+
"where": "path 的 timeDuration"
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
"property": "timeDate",
|
|
885
|
+
"where": "path 的 timeDate"
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
"property": "timeCycle",
|
|
889
|
+
"where": "path 的 timeCycle"
|
|
890
|
+
}
|
|
891
|
+
],
|
|
892
|
+
"projection": {
|
|
893
|
+
"description": "rules.schema 和 rules.when 检查节点的无环投影;on 是精确 BPMN 类型列表;at 为 path 时使用 process/子流程/节点,其余用节点 id。未提供的属性为 undefined(Ajv 当作缺失)。原始标量属性按原名保留。",
|
|
894
|
+
"facts": {
|
|
895
|
+
"type": "BPMN 类型",
|
|
896
|
+
"top": "是否直属 process",
|
|
897
|
+
"parentType": "父元素类型",
|
|
898
|
+
"subprocess": "是否属于 SubProcess(含 transaction 与 adHoc)",
|
|
899
|
+
"position": "开始事件按 start / innerStart / eventStart 区分,其余为 BPMN 类型",
|
|
900
|
+
"eventKind": "第一个事件定义的种类;事件数量另按 events 表检查",
|
|
901
|
+
"messageNamed": "引用的 message 是否有 name",
|
|
902
|
+
"signalReferenced": "是否引用 signal",
|
|
903
|
+
"escalationReferenced": "是否引用 escalation",
|
|
904
|
+
"linkNamed": "link 是否有非空 name",
|
|
905
|
+
"attachedType": "边界事件挂靠节点的类型",
|
|
906
|
+
"condition": "条件正文",
|
|
907
|
+
"conditionLanguage": "条件声明的语言",
|
|
908
|
+
"hasCondition": "是否写了条件",
|
|
909
|
+
"timeDuration / timeDate / timeCycle": "第一个事件定义中对应时间的正文",
|
|
910
|
+
"loopType / collection / cardinality / loopCompletion": "多实例类型、集合、次数和完成条件",
|
|
911
|
+
"completion": "自由子流程的完成条件",
|
|
912
|
+
"hasStartOrEnd": "直接子节点是否有开始或结束事件",
|
|
913
|
+
"hasScriptExtension": "是否写 coda:script",
|
|
914
|
+
"hasOutputs / outputHasValue": "是否有 coda:output,以及是否有输出声明 value",
|
|
915
|
+
"documentation": "人工任务 documentation 合并后的文字",
|
|
916
|
+
"hasInputFields": "是否有 inputFields 中类型的字段或动态 coda:fields",
|
|
917
|
+
"hasTimeCycle": "是否声明 timeCycle 元素(包括空元素)"
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
@@ -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
|
|
7
|
-
//
|
|
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
|
|
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.
|