@coda-console/protocol 1.6.1 → 1.7.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 +11 -1
- package/package.json +1 -1
- package/validate.mjs +17 -8
- package/workflow/fields.json +230 -0
- package/workflow/keywords.mjs +104 -0
- package/workflow/values.json +71 -4
- package/workflow.mjs +68 -7
package/README.md
CHANGED
|
@@ -7,6 +7,12 @@ Coda 与插件之间的约定,Coda 和插件都引用它,独立演进:
|
|
|
7
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`(文字换算成数字是个有限的数)。流程的结构对不对(元素与事件、连接、补偿、走不到的步骤、事件启动),现在仍由 Coda 读流程时检查。
|
|
8
8
|
- **Agent 接入协议**(`docs/agent-protocol.md`):Coda 怎样用 ACP 接 Agent,以及 Coda 加的扩展(会话指令、插话、`_coda/wait`、执行失败、工具调用的审核)。
|
|
9
9
|
|
|
10
|
+
流程方言的 **卡片字段**由 `workflow/fields.json` 定义:七种字段各能写哪些属性、必填项、文字长度与单行限制、路径与网址、视图参数、输入框与密钥选项;`values.json` 的 `Field` 引用它,`Fields` 声明读出 JSON 字段列表的表达式,`UserTask.children` 限定最多 12 个静态字段、id 不重复,以及全部字段已知时答复的 `fields` 只能引用输入字段(含按住出现的答复)。
|
|
11
|
+
|
|
12
|
+
定义和运行时共用这一份 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`(逐个检查分隔后的名字)。这些操作都不改写输入。
|
|
13
|
+
|
|
14
|
+
`fieldProblem(field, where)` 检查一个已求值的字段;`fieldsProblem(fields, where)` 检查展开并应用 `host`/`dir` 后的数组,复用同一份字段 schema、数量和唯一性规则。两者合法时返回 `null`,否则返回「位置:说明」;`where` 是调用方给出的完整位置。schema 的 `default` 仅作标注:默认值填充、去空白、域名转小写、`args` 解析后的保留、表达式求值、动态字段展开和用户答复的处理仍由 Coda 完成。`coda:fields` 的 `host`/`dir` 是可能被覆盖或忽略的备用值,定义时只检查 FEEL,合并后才检查实际字段。动态字段展开后,答复的输入引用仍须由 Coda 检查。
|
|
15
|
+
|
|
10
16
|
## 用法
|
|
11
17
|
|
|
12
18
|
```sh
|
|
@@ -25,12 +31,16 @@ pointProblem(panelsSchema, manifest.contributes.panels, 'contributes.panels', 'p
|
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
```js
|
|
28
|
-
import { workflowProblem, readWorkflow,
|
|
34
|
+
import { workflowProblem, readWorkflow, fieldProblem, fieldsProblem, CODA_FIELDS } from '@coda-console/protocol/workflow';
|
|
29
35
|
|
|
30
36
|
// 一份流程(名字不带 .bpmn,用在说明里):方言里都有就返回 null,否则是一句说明。
|
|
31
37
|
const problem = await workflowProblem(readFileSync('.coda/workflows/deploy.bpmn', 'utf8'), 'deploy');
|
|
32
38
|
// 要接着读它的:{ root } 是解析好的 definitions,或 { problem }。
|
|
33
39
|
const { root } = await readWorkflow(source, 'deploy');
|
|
40
|
+
// 任务打开、接受答复时检查已求值的字段,不填默认值,不执行表达式。
|
|
41
|
+
fieldProblem({ type: 'text', id: 'name', label: '名称' }, 'ask 的字段 name');
|
|
42
|
+
fieldsProblem(JSON.parse(fieldsJSON), 'ask');
|
|
43
|
+
// CODA_FIELDS 是同一份 fields.json,也可从 @coda-console/protocol/workflow/fields.json 导入。
|
|
34
44
|
```
|
|
35
45
|
|
|
36
46
|
扩展点的 schema 和这里的并列:可以 `$ref` 协议的 schema(`common.json#/$defs/line40`),可以用五个关键字;它自己写的 `$id` 不算数,指针只指它校验的那份值里的位置。
|
package/package.json
CHANGED
package/validate.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// items having it, and so is `part`'s first group of each of its texts (a directory's last name), and `key` among the
|
|
11
11
|
// items and those in their `nested` arrays, at every depth (a task's answers and those offered on holding one);
|
|
12
12
|
// - `x-max-contains`: { match, max } — of an object's values, at most `max` satisfy `match` (`maxContains` of an object);
|
|
13
|
-
// - `x-ref`: { pointer, part?, match? } — the value (or `part`'s first group of it)
|
|
13
|
+
// - `x-ref`: { pointer, part?, key?, split?, match? } — the value (or `part`'s first group of it), or each nonempty token
|
|
14
|
+
// separated by `split`, names a key of the object, or an element (with `key`, that property of an element)
|
|
14
15
|
// of the array, at `pointer` in the manifest: absolute (`/contributes/configuration/properties`), or relative to the value
|
|
15
16
|
// (`2/properties`: two levels up, then down); what it names satisfies `match` when that is given;
|
|
16
17
|
// - `x-placeholders`: { names?, config? } — every `${…}` in the text is one of `names`, or `config:<setting>` naming one of
|
|
@@ -56,7 +57,7 @@ const META = {
|
|
|
56
57
|
'x-unique-by': { type: 'array', minItems: 1, items: { anyOf: [{ type: 'string' },
|
|
57
58
|
{ type: 'object', properties: { part: { type: 'string' } }, required: ['part'], additionalProperties: false },
|
|
58
59
|
{ type: 'object', properties: { key: { type: 'string' }, nested: { type: 'string' } }, required: ['key', 'nested'], additionalProperties: false }] } },
|
|
59
|
-
'x-ref': { type: 'object', properties: { pointer: { type: 'string', pattern: '^(?:\\d+(?:/.*)?|/.*)$' }, part: { type: 'string' }, match: SCHEMA_VALUE },
|
|
60
|
+
'x-ref': { type: 'object', properties: { pointer: { type: 'string', pattern: '^(?:\\d+(?:/.*)?|/.*)$' }, part: { type: 'string' }, key: { type: 'string' }, split: { type: 'string' }, match: SCHEMA_VALUE },
|
|
60
61
|
required: ['pointer'], additionalProperties: false },
|
|
61
62
|
'x-placeholders': { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } }, config: SCHEMA_VALUE }, additionalProperties: false },
|
|
62
63
|
'x-file': { type: 'object', properties: { type: { enum: ['file', 'directory'] }, under: { type: 'string' }, suffix: { type: 'string' }, contains: { type: 'string' },
|
|
@@ -124,16 +125,24 @@ function keywords(ajv) {
|
|
|
124
125
|
// A reference: whether `name` is a key (or element) of what `pointer` names, and that satisfies `match`.
|
|
125
126
|
function resolves(self, ref, matches, name, cxt) {
|
|
126
127
|
const { at, path } = locate(ref.pointer, cxt);
|
|
127
|
-
const key = Array.isArray(at) ?
|
|
128
|
+
const key = Array.isArray(at) ? ref.key === undefined ? at.indexOf(name)
|
|
129
|
+
: at.findIndex(item => item !== null && typeof item === 'object' && Object.hasOwn(item, ref.key) && item[ref.key] === name)
|
|
130
|
+
: at !== null && typeof at === 'object' && Object.hasOwn(at, name) ? name : -1;
|
|
128
131
|
return key !== -1 && (!matches || matches(self, at[key], `${path}/${escape(key)}`, cxt));
|
|
129
132
|
}
|
|
130
133
|
ajv.addKeyword({
|
|
131
|
-
keyword: 'x-ref', type: 'string', schemaType: 'object', metaSchema: META['x-ref'],
|
|
132
|
-
compile(ref) {
|
|
134
|
+
keyword: 'x-ref', type: 'string', schemaType: 'object', metaSchema: META['x-ref'], errors: true,
|
|
135
|
+
compile(ref, parentSchema) {
|
|
133
136
|
const matches = ref.match !== undefined ? inner(ajv, ref.match) : null, part = ref.part !== undefined ? new RegExp(ref.part, 'u') : null;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
const split = ref.split === undefined ? null : new RegExp(ref.split, 'u');
|
|
138
|
+
return function reference(value, cxt) {
|
|
139
|
+
for (const token of split ? value.split(split).filter(Boolean) : [value]) {
|
|
140
|
+
const name = part ? part.exec(token)?.[1] : token;
|
|
141
|
+
if (name !== undefined && resolves(this, ref, matches, name, cxt)) continue;
|
|
142
|
+
reference.errors = [{ keyword: 'x-ref', params: { value: token }, parentSchema, message: '引用不存在或类型不符' }];
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
137
146
|
};
|
|
138
147
|
},
|
|
139
148
|
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://coda.pub/schema/bpmn/1.0/fields.json",
|
|
4
|
+
"title": "人工任务卡片的字段",
|
|
5
|
+
"description": "字段须为对象",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{"required": ["type"], "description": "字段的 type 只能是 markdown、code、image、text、secret、link、view"},
|
|
9
|
+
{"properties": {"type": {"enum": ["markdown", "code", "image", "text", "secret", "link", "view"], "description": "字段的 type 只能是 markdown、code、image、text、secret、link、view"}}},
|
|
10
|
+
{"if": {"properties": {"type": {"const": "markdown"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/markdown"}},
|
|
11
|
+
{"if": {"properties": {"type": {"const": "code"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/code"}},
|
|
12
|
+
{"if": {"properties": {"type": {"const": "image"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/image"}},
|
|
13
|
+
{"if": {"properties": {"type": {"const": "text"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/text"}},
|
|
14
|
+
{"if": {"properties": {"type": {"const": "secret"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/secret"}},
|
|
15
|
+
{"if": {"properties": {"type": {"const": "link"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/link"}},
|
|
16
|
+
{"if": {"properties": {"type": {"const": "view"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/view"}}
|
|
17
|
+
],
|
|
18
|
+
"$defs": {
|
|
19
|
+
"markdown": {
|
|
20
|
+
"allOf": [
|
|
21
|
+
{
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"type": {"const": "markdown"},
|
|
25
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
26
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
27
|
+
"text": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 4000, "description": "text 须为1–4000 个字"}}
|
|
28
|
+
},
|
|
29
|
+
"additionalProperties": false
|
|
30
|
+
},
|
|
31
|
+
{"required": ["text"], "description": "须写 text"}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"code": {
|
|
35
|
+
"allOf": [
|
|
36
|
+
{
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"type": {"const": "code"},
|
|
40
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
41
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
42
|
+
"text": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 16384, "description": "text 须为1–16384 个字"}}
|
|
43
|
+
},
|
|
44
|
+
"additionalProperties": false
|
|
45
|
+
},
|
|
46
|
+
{"required": ["text"], "description": "须写 text"}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"image": {
|
|
50
|
+
"allOf": [
|
|
51
|
+
{
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"type": {"const": "image"},
|
|
55
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
56
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
57
|
+
"src": {
|
|
58
|
+
"x-deferred": {
|
|
59
|
+
"allOf": [
|
|
60
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 4096, "description": "src 须为一行、1–4096 个字"},
|
|
61
|
+
{"format": "absolute-path", "description": "src 须为本机文件的绝对路径"}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"additionalProperties": false
|
|
67
|
+
},
|
|
68
|
+
{"required": ["src"], "description": "须写 src"}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"text": {
|
|
72
|
+
"allOf": [
|
|
73
|
+
{
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"type": {"const": "text"},
|
|
77
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
78
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
79
|
+
"required": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/required"},
|
|
80
|
+
"default": {},
|
|
81
|
+
"maxLength": {"type": "integer", "minimum": 1, "maximum": 4000, "description": "maxLength 须为 1–4000 的整数"},
|
|
82
|
+
"multiline": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/multiline"},
|
|
83
|
+
"autofill": {"enum": ["username", "otp"], "description": "autofill 只能是 username、otp"}
|
|
84
|
+
},
|
|
85
|
+
"additionalProperties": false
|
|
86
|
+
},
|
|
87
|
+
{"required": ["id"], "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
|
|
88
|
+
{"required": ["label"], "description": "须写 label"},
|
|
89
|
+
{
|
|
90
|
+
"if": {"properties": {"multiline": {"const": true}}, "required": ["multiline"]},
|
|
91
|
+
"then": {
|
|
92
|
+
"if": {"required": ["maxLength"]},
|
|
93
|
+
"then": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": {"property": "maxLength"}, "description": "default 须为1–{maxLength} 个字"}}}},
|
|
94
|
+
"else": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 4000, "description": "default 须为1–4000 个字"}}}}
|
|
95
|
+
},
|
|
96
|
+
"else": {
|
|
97
|
+
"if": {"required": ["maxLength"]},
|
|
98
|
+
"then": {
|
|
99
|
+
"properties": {
|
|
100
|
+
"default": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": {"property": "maxLength"}, "description": "default 须为一行、1–{maxLength} 个字"}}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"else": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 200, "description": "default 须为一行、1–200 个字"}}}}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"if": {"required": ["multiline"], "properties": {"multiline": {"const": true}}},
|
|
108
|
+
"then": {"properties": {"maxLength": {"default": 4000}}},
|
|
109
|
+
"else": {"properties": {"maxLength": {"default": 200}}}
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"secret": {
|
|
114
|
+
"allOf": [
|
|
115
|
+
{
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {
|
|
118
|
+
"type": {"const": "secret"},
|
|
119
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
120
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
121
|
+
"required": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/required"},
|
|
122
|
+
"maxLength": {"type": "integer", "minimum": 1, "maximum": 65536, "description": "maxLength 须为 1–65536 的整数", "default": 65536},
|
|
123
|
+
"multiline": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/multiline"},
|
|
124
|
+
"masked": {"type": "boolean", "description": "masked 须为 true 或 false"},
|
|
125
|
+
"autofill": {"enum": ["username", "password", "otp"], "description": "autofill 只能是 username、password、otp"},
|
|
126
|
+
"keep": {
|
|
127
|
+
"enum": ["session", "memory", "use"],
|
|
128
|
+
"description": "keep 只能是 session(写进会话的密钥目录,流程拿到路径)、memory(只在后端内存里,本会话的桌面动作可反复用,流程拿到句柄)或 use(只在后端内存里,流程拿到句柄,用一次)",
|
|
129
|
+
"default": "session"
|
|
130
|
+
},
|
|
131
|
+
"host": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/host"},
|
|
132
|
+
"dir": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/dir"}
|
|
133
|
+
},
|
|
134
|
+
"additionalProperties": false
|
|
135
|
+
},
|
|
136
|
+
{"required": ["id"], "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
|
|
137
|
+
{"required": ["label"], "description": "须写 label"},
|
|
138
|
+
{
|
|
139
|
+
"if": {"required": ["keep"], "properties": {"keep": {"const": "use"}}},
|
|
140
|
+
"then": {"not": {"required": ["dir"]}, "description": "dir 只用于 keep=\"session\" 或 keep=\"memory\" 的密文"}
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"if": {"required": ["autofill"], "properties": {"autofill": {"enum": ["username", "otp"]}}},
|
|
144
|
+
"then": {"properties": {"masked": {"default": false}}},
|
|
145
|
+
"else": {"properties": {"masked": {"default": true}}}
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
"link": {
|
|
150
|
+
"allOf": [
|
|
151
|
+
{
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {
|
|
154
|
+
"type": {"const": "link"},
|
|
155
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
156
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
157
|
+
"href": {
|
|
158
|
+
"x-deferred": {
|
|
159
|
+
"allOf": [
|
|
160
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 2000, "description": "href 须为一行、1–2000 个字"},
|
|
161
|
+
{"format": "https-url", "description": "href 须为 https 网址"}
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"additionalProperties": false
|
|
167
|
+
},
|
|
168
|
+
{"required": ["label"], "description": "须写 label"},
|
|
169
|
+
{"required": ["href"], "description": "须写 href"}
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
"view": {
|
|
173
|
+
"allOf": [
|
|
174
|
+
{
|
|
175
|
+
"type": "object",
|
|
176
|
+
"properties": {
|
|
177
|
+
"type": {"const": "view"},
|
|
178
|
+
"id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
|
|
179
|
+
"label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
|
|
180
|
+
"plugin": {
|
|
181
|
+
"x-deferred": {
|
|
182
|
+
"allOf": [
|
|
183
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 64, "description": "plugin 须为一行、1–64 个字"},
|
|
184
|
+
{"pattern": "^\\s*[a-z0-9][a-z0-9-]{0,63}\\s*$", "description": "plugin 须为插件 ID(小写字母、数字和「-」)"}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"view": {
|
|
189
|
+
"x-deferred": {
|
|
190
|
+
"allOf": [
|
|
191
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 40, "description": "view 须为一行、1–40 个字"},
|
|
192
|
+
{"pattern": "^\\s*[a-z][a-z0-9-]{0,39}\\s*$", "description": "view 须为插件声明的视图 ID(小写字母开头的小写字母、数字和「-」)"}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"args": {"x-deferred": {"x-json": {"schema": {"type": "object", "description": "args 须为不超过 4096 字节的 JSON 对象"}, "maxBytes": 4096}, "description": "args 须为 JSON 对象"}},
|
|
197
|
+
"presentation": {"enum": ["window", "inline", "side"], "description": "presentation 只能是 window、inline 或 side"}
|
|
198
|
+
},
|
|
199
|
+
"additionalProperties": false
|
|
200
|
+
},
|
|
201
|
+
{"required": ["label"], "description": "须写 label"},
|
|
202
|
+
{"required": ["plugin"], "description": "须写 plugin"},
|
|
203
|
+
{"required": ["view"], "description": "须写 view"}
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$", "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
|
|
207
|
+
"label": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 40, "description": "label 须为一行、1–40 个字"}},
|
|
208
|
+
"required": {"type": "boolean", "description": "required 须为 true 或 false", "default": true},
|
|
209
|
+
"multiline": {"type": "boolean", "description": "multiline 须为 true 或 false"},
|
|
210
|
+
"host": {
|
|
211
|
+
"x-deferred": {
|
|
212
|
+
"allOf": [
|
|
213
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 253, "description": "host 须为一行、1–253 个字"},
|
|
214
|
+
{
|
|
215
|
+
"pattern": "^\\s*(?=[a-zA-Z0-9.-]{4,253}\\s*$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\s*$",
|
|
216
|
+
"description": "host 须为网站域名,如 signin.aws.amazon.com"
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
"dir": {
|
|
222
|
+
"x-deferred": {
|
|
223
|
+
"allOf": [
|
|
224
|
+
{"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 80, "description": "dir 须为一行、1–80 个字"},
|
|
225
|
+
{"pattern": "^\\s*[A-Za-z0-9][A-Za-z0-9._-]{0,79}\\s*$", "description": "dir 只能是字母、数字、点、下划线和连字符,以字母或数字开头,最长 80 个字符"}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Value operations JSON Schema cannot express: defer checking an expression until it is read, measure UTF-16 text
|
|
2
|
+
// (with a limit optionally named by a sibling), and validate JSON supplied as text or as a value, bounded in UTF-8 bytes.
|
|
3
|
+
// None knows what a card field is. Schemas select the operations and their limits; validation never changes the input.
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// The byte length of a JSON value, without recursion or serializing the whole object. Stop as soon as the budget is
|
|
7
|
+
// exceeded. Only JSON values count: cycles, non-finite numbers and non-JSON objects cannot turn into a different value.
|
|
8
|
+
function jsonFits(value, max) {
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
const active = new Set(), stack = [{ value }];
|
|
11
|
+
while (stack.length && bytes <= max) {
|
|
12
|
+
const frame = stack.at(-1);
|
|
13
|
+
if (frame.keys) {
|
|
14
|
+
if (frame.index === frame.keys.length) { active.delete(frame.value); stack.pop(); continue; }
|
|
15
|
+
const key = frame.keys[frame.index++];
|
|
16
|
+
bytes += (frame.index > 1 ? 1 : 0) + Buffer.byteLength(JSON.stringify(key)) + 1;
|
|
17
|
+
stack.push({ value: frame.value[key] });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (frame.array) {
|
|
21
|
+
if (frame.index === frame.value.length) { active.delete(frame.value); stack.pop(); continue; }
|
|
22
|
+
if (frame.index++) bytes++;
|
|
23
|
+
stack.push({ value: frame.value[frame.index - 1] });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const item = frame.value;
|
|
27
|
+
if (item !== null && typeof item === 'object') {
|
|
28
|
+
if (active.has(item)) return false;
|
|
29
|
+
active.add(item);
|
|
30
|
+
bytes += 2;
|
|
31
|
+
frame.index = 0;
|
|
32
|
+
if (Array.isArray(item)) {
|
|
33
|
+
if (item.length > max) return false;
|
|
34
|
+
frame.array = true;
|
|
35
|
+
} else {
|
|
36
|
+
if (![Object.prototype, null].includes(Object.getPrototypeOf(item))) return false;
|
|
37
|
+
frame.keys = Object.keys(item);
|
|
38
|
+
if (frame.keys.length > max) return false;
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
if (typeof item === 'number' && !Number.isFinite(item)) return false;
|
|
42
|
+
if (item !== null && !['string', 'number', 'boolean'].includes(typeof item)) return false;
|
|
43
|
+
// JSON escaping cannot shorten a string, so avoid serializing one already over budget.
|
|
44
|
+
if (typeof item === 'string' && item.length > max - bytes) return false;
|
|
45
|
+
bytes += Buffer.byteLength(JSON.stringify(item));
|
|
46
|
+
stack.pop();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return bytes <= max;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function valueKeywords(ajv, { feelProblem, isExpression }) {
|
|
53
|
+
ajv.addFormat('absolute-path', value => isAbsolute(value.trim()));
|
|
54
|
+
ajv.addFormat('https-url', value => { try { return new URL(value.trim()).protocol === 'https:'; } catch { return false; } });
|
|
55
|
+
ajv.addKeyword({
|
|
56
|
+
keyword: 'x-length', type: 'string', schemaType: ['number', 'object'], errors: true,
|
|
57
|
+
metaSchema: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'object', properties: { property: { type: 'string' } }, required: ['property'], additionalProperties: false }] },
|
|
58
|
+
compile(spec, parentSchema) {
|
|
59
|
+
return function length(value, cxt) {
|
|
60
|
+
const limit = typeof spec === 'number' ? spec : cxt.parentData && Object.hasOwn(cxt.parentData, spec.property) ? cxt.parentData[spec.property] : undefined;
|
|
61
|
+
if (Number.isInteger(limit) && limit >= 0 && value.length <= limit) return true;
|
|
62
|
+
length.errors = [{ keyword: 'x-length', params: { limit }, parentSchema, message: '文字过长' }];
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
ajv.addKeyword({
|
|
68
|
+
keyword: 'x-deferred', schemaType: ['object', 'boolean'], errors: true,
|
|
69
|
+
compile(schema) {
|
|
70
|
+
const validate = ajv.compile(schema);
|
|
71
|
+
return function deferred(value, cxt) {
|
|
72
|
+
// In a definition every textual value is also read as FEEL. At runtime even an expression-looking result is
|
|
73
|
+
// literal content; only a definition may postpone the wrapped schema's checks.
|
|
74
|
+
if (this?.definition && typeof value === 'string') {
|
|
75
|
+
const problem = feelProblem(value, this.names);
|
|
76
|
+
if (problem) { deferred.errors = [{ keyword: 'x-feel', params: {}, message: problem }]; return false; }
|
|
77
|
+
if (isExpression(value) || value.includes('{{')) return true;
|
|
78
|
+
}
|
|
79
|
+
const valid = validate.call(this, value, cxt);
|
|
80
|
+
deferred.errors = validate.errors;
|
|
81
|
+
return valid;
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
ajv.addKeyword({
|
|
86
|
+
keyword: 'x-json', schemaType: 'object', errors: true,
|
|
87
|
+
metaSchema: { type: 'object', properties: { schema: { type: ['object', 'boolean'] }, maxBytes: { type: 'integer', minimum: 0 } }, required: ['schema', 'maxBytes'], additionalProperties: false },
|
|
88
|
+
compile(spec, parentSchema) {
|
|
89
|
+
const validate = ajv.compile(spec.schema);
|
|
90
|
+
return function json(value, cxt) {
|
|
91
|
+
let parsed = value;
|
|
92
|
+
if (typeof value === 'string') try { parsed = JSON.parse(value); }
|
|
93
|
+
catch { json.errors = [{ keyword: 'x-json', params: {}, parentSchema, message: '不是 JSON' }]; return false; }
|
|
94
|
+
if (!jsonFits(parsed, spec.maxBytes)) {
|
|
95
|
+
json.errors = [{ keyword: 'x-json', params: {}, parentSchema: spec.schema, message: `须为不超过 ${spec.maxBytes} 字节的 JSON` }];
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const valid = validate.call(this, parsed, cxt);
|
|
99
|
+
json.errors = validate.errors;
|
|
100
|
+
return valid;
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
package/workflow/values.json
CHANGED
|
@@ -171,10 +171,77 @@
|
|
|
171
171
|
"UserTask.children": {
|
|
172
172
|
"type": "object",
|
|
173
173
|
"properties": {
|
|
174
|
-
"choice": {
|
|
175
|
-
"
|
|
176
|
-
"
|
|
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
|
@@ -5,12 +5,14 @@
|
|
|
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
6
|
// written. readWorkflow refuses what the dialect does not have and a value of it its schema does not take; how the
|
|
7
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
|
|
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,13 @@ 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';
|
|
22
26
|
|
|
23
27
|
export const CODA_MODDLE = moddle;
|
|
24
28
|
export const CODA_NAMESPACE = moddle.uri;
|
|
25
29
|
export const CODA_VALUES = values;
|
|
30
|
+
export const CODA_FIELDS = fields;
|
|
26
31
|
// Each action by name, with the parameters it takes (`coda:param`).
|
|
27
32
|
export const CODA_ACTIONS = Object.fromEntries(Object.entries(actions.actions).map(([name, action]) => [name, action.params]));
|
|
28
33
|
export const WORKFLOW_KINDS = Object.keys(dialect.kinds);
|
|
@@ -72,6 +77,7 @@ export function isDuration(text) {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const ajv = newAjv();
|
|
80
|
+
valueKeywords(ajv, { feelProblem, isExpression });
|
|
75
81
|
ajv.addKeyword({
|
|
76
82
|
keyword: 'x-feel', type: 'string', schemaType: ['boolean', 'object'], errors: true,
|
|
77
83
|
metaSchema: { anyOf: [{ type: 'boolean' }, { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } } }, required: ['names'], additionalProperties: false }] },
|
|
@@ -86,11 +92,12 @@ ajv.addKeyword({
|
|
|
86
92
|
});
|
|
87
93
|
ajv.addKeyword({ keyword: 'x-duration', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || isDuration(value) });
|
|
88
94
|
ajv.addKeyword({ keyword: 'x-number', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || Number.isFinite(Number(value)) });
|
|
95
|
+
ajv.addSchema(fields);
|
|
89
96
|
ajv.addSchema(values);
|
|
90
97
|
const checks = new Map();
|
|
91
98
|
// The validator of a definition of values.json, by its name; none for a type it says nothing of.
|
|
92
99
|
const check = name => {
|
|
93
|
-
if (!checks.has(name)) checks.set(name, values.$defs
|
|
100
|
+
if (!checks.has(name)) checks.set(name, Object.hasOwn(values.$defs, name) ? ajv.getSchema(`${values.$id}#/$defs/${name}`) : null);
|
|
94
101
|
return checks.get(name);
|
|
95
102
|
};
|
|
96
103
|
|
|
@@ -98,6 +105,12 @@ const SIZE_LIMIT = 1_000_000;
|
|
|
98
105
|
// bpmn-moddle rewrites a descriptor as it takes it in (its names get their prefix), and whoever imports CODA_MODDLE may hand
|
|
99
106
|
// 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
107
|
const DESCRIPTOR = structuredClone(moddle);
|
|
108
|
+
// moddle's Boolean/Integer coercion accepts `no` as false and `1.5` as 1. Keep field attributes as text until their
|
|
109
|
+
// complete spelling is known; valid ones are restored to the descriptor's types before returning the parsed workflow.
|
|
110
|
+
const FIELD_SCALARS = DESCRIPTOR.types.find(type => type.name === 'Field').properties.filter(p => ['Boolean', 'Integer'].includes(p.type));
|
|
111
|
+
const PARSER_DESCRIPTOR = structuredClone(DESCRIPTOR);
|
|
112
|
+
for (const property of PARSER_DESCRIPTOR.types.find(type => type.name === 'Field').properties)
|
|
113
|
+
if (FIELD_SCALARS.some(p => p.name === property.name)) property.type = 'String';
|
|
101
114
|
const children = element => Object.values(element).flatMap(v => (Array.isArray(v) ? v : [v])).filter(v => v?.$type && v.$parent === element);
|
|
102
115
|
// Everything in the definition under `element`, depth first, by a stack of its own, pushed one at a time: a definition may
|
|
103
116
|
// nest deeper than the call stack goes, and hold more in one place than a call takes arguments.
|
|
@@ -130,7 +143,40 @@ function said(error, at, attribute) {
|
|
|
130
143
|
return `${at} 需要 ${attribute ? 'coda:' : ''}${error.params.missingProperty}${own ? `:${own}` : ''}`;
|
|
131
144
|
}
|
|
132
145
|
if (error.keyword === 'x-unique-by') return `${at}:${error.message}`;
|
|
133
|
-
return `${at}:${
|
|
146
|
+
return `${at}:${description(error)}`;
|
|
147
|
+
}
|
|
148
|
+
const description = (error, object = {}) => (described(error, { schemas: [values, fields] }) ?? error.message)
|
|
149
|
+
.replace(/\{(\w+)\}/g, (whole, key) => Object.hasOwn(error.params, key) ? String(error.params[key])
|
|
150
|
+
: object && Object.hasOwn(object, key) ? String(object[key]) : whole);
|
|
151
|
+
// Required values and allowed attributes come from the same schema as validation; none of the field types is named here.
|
|
152
|
+
function fieldSaid(error, at, field) {
|
|
153
|
+
if (error.keyword === 'additionalProperties') {
|
|
154
|
+
const allowed = Object.keys(error.parentSchema.properties), type = error.parentSchema.properties.type.const;
|
|
155
|
+
const extra = Object.keys(error.data).filter(key => !Object.hasOwn(error.parentSchema.properties, key));
|
|
156
|
+
return `${at}:${type} 字段没有 ${extra.join('、')},可写 ${allowed.join('、')}`;
|
|
157
|
+
}
|
|
158
|
+
return `${at}:${error.keyword === 'x-feel' ? error.message : description(error, field)}`;
|
|
159
|
+
}
|
|
160
|
+
// A resolved field, at task opening or when accepting an answer. No expression is evaluated and no default is inserted.
|
|
161
|
+
export function fieldProblem(field, where = '字段') {
|
|
162
|
+
const validate = ajv.getSchema(fields.$id);
|
|
163
|
+
return validate(field) ? null : fieldSaid(validate.errors[0], where, field);
|
|
164
|
+
}
|
|
165
|
+
// The expanded fields of one task (after reading coda:fields JSON and applying host/dir), checked together as in a
|
|
166
|
+
// definition. Choice references still need the task's choices; Coda checks those after expansion.
|
|
167
|
+
let fieldsBound;
|
|
168
|
+
export function fieldsProblem(list, where = '任务') {
|
|
169
|
+
const schema = values.$defs['UserTask.children'].properties.field;
|
|
170
|
+
// Check the bound before visiting items, and individual values before uniqueness can inspect their ids.
|
|
171
|
+
const { 'x-unique-by': unique, ...shape } = schema;
|
|
172
|
+
const bound = fieldsBound ??= ajv.compile(shape);
|
|
173
|
+
if (!bound(list)) return `${where}:${description(bound.errors[0])}`;
|
|
174
|
+
for (const [index, field] of list.entries()) {
|
|
175
|
+
const problem = fieldProblem(field, `${where} 的字段 ${typeof field?.id === 'string' ? field.id : index + 1}`);
|
|
176
|
+
if (problem) return problem;
|
|
177
|
+
}
|
|
178
|
+
const validate = ajv.getSchema(`${values.$id}#/$defs/UserTask.children/properties/field`);
|
|
179
|
+
return validate(list) ? null : `${where}:字段 id 重复:${validate.errors[0].message.slice('id 不能重复:'.length)}`;
|
|
134
180
|
}
|
|
135
181
|
// What of the coda: values in the definition its schemas do not take, or null: each element in order, its coda:
|
|
136
182
|
// attributes by each type of Coda's extending what it is, the coda: elements it holds by kind, and each coda: element.
|
|
@@ -141,12 +187,13 @@ function valuesProblem(root) {
|
|
|
141
187
|
const loops = elements.filter(e => e.$type === 'bpmn:MultiInstanceLoopCharacteristics');
|
|
142
188
|
const names = Object.fromEntries([...RUN_VARIABLES, ...elements.filter(e => e.$type === 'bpmn:Message' || e.$instanceOf?.('bpmn:FlowElement')).map(e => e.id),
|
|
143
189
|
...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
|
|
190
|
+
const failed = (validate, object, at, attribute, field = false) => validate && !validate.call({ names, definition: true }, object)
|
|
191
|
+
? field ? fieldSaid(validate.errors[0], at, object) : said(validate.errors[0], at, attribute) : null;
|
|
145
192
|
for (const element of elements) {
|
|
146
193
|
const type = codaType(element);
|
|
147
194
|
if (type) {
|
|
148
195
|
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);
|
|
196
|
+
const problem = failed(check(kind), codaObject(element), `${owner(element)?.id} 的 coda:${lower(type.name)}${name ? ` ${name}` : ''}`, false, ['Field', 'Fields'].includes(kind));
|
|
150
197
|
if (problem) return problem;
|
|
151
198
|
continue;
|
|
152
199
|
}
|
|
@@ -160,7 +207,16 @@ function valuesProblem(root) {
|
|
|
160
207
|
if (!held.length) continue;
|
|
161
208
|
const grouped = {};
|
|
162
209
|
for (const child of held) (grouped[lower(codaType(child).name)] ??= []).push(codaObject(child));
|
|
163
|
-
const
|
|
210
|
+
const validate = check(`${element.$type.slice(5)}.children`);
|
|
211
|
+
let problem = failed(validate, grouped, at, false);
|
|
212
|
+
if (problem && validate.errors[0].keyword === 'x-ref') {
|
|
213
|
+
const error = validate.errors[0], path = error.instancePath.split('/').slice(1);
|
|
214
|
+
let parent = grouped;
|
|
215
|
+
for (const key of path.slice(0, -1)) parent = parent[key];
|
|
216
|
+
problem = `${at} 的 coda:choice ${parent.id}:${description(error)}`;
|
|
217
|
+
}
|
|
218
|
+
if (problem && validate.errors[0].keyword === 'x-unique-by' && validate.errors[0].instancePath === '/field')
|
|
219
|
+
problem = `${at}:字段 id 重复:${validate.errors[0].message.slice('id 不能重复:'.length)}`;
|
|
164
220
|
if (problem) return problem;
|
|
165
221
|
}
|
|
166
222
|
return null;
|
|
@@ -173,10 +229,15 @@ function valuesProblem(root) {
|
|
|
173
229
|
export async function readWorkflow(source, file = 'workflow') {
|
|
174
230
|
if (typeof source !== 'string' || source.length > SIZE_LIMIT) return { problem: `${file}.bpmn 过大` };
|
|
175
231
|
let parsed;
|
|
176
|
-
try { parsed = await new BpmnModdle({ coda: structuredClone(
|
|
232
|
+
try { parsed = await new BpmnModdle({ coda: structuredClone(PARSER_DESCRIPTOR) }).fromXML(source); }
|
|
177
233
|
catch (e) { return { problem: `${file}.bpmn 不是有效的 BPMN 2.0:${e.message}` }; }
|
|
178
234
|
const { rootElement: root, warnings } = parsed;
|
|
179
235
|
if (warnings.length) return { problem: `${file}.bpmn 有无法识别的内容:${warnings.slice(0, 3).map(w => w.message).join(';')}` };
|
|
236
|
+
for (const field of all(root).filter(e => e.$type === 'coda:Field')) for (const { name, type } of FIELD_SCALARS) {
|
|
237
|
+
const value = field[name];
|
|
238
|
+
if (type === 'Boolean' && ['true', 'false'].includes(value)) field[name] = value === 'true';
|
|
239
|
+
if (type === 'Integer' && typeof value === 'string' && /^[+-]?\d+$/.test(value)) field[name] = Number(value);
|
|
240
|
+
}
|
|
180
241
|
for (const task of all(root).filter(e => e.$type === 'bpmn:ServiceTask' && e.action !== undefined)) {
|
|
181
242
|
if (!Object.hasOwn(CODA_ACTIONS, task.action)) return { problem: `${task.id}:coda:action 只能是 ${Object.keys(CODA_ACTIONS).join('、')}` };
|
|
182
243
|
const params = CODA_ACTIONS[task.action];
|