@coda-console/protocol 1.5.0 → 1.6.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 +1 -1
- package/package.json +4 -2
- package/validate.mjs +29 -9
- package/workflow/dialect.json +6 -2
- package/workflow/values.json +177 -0
- package/workflow.mjs +141 -8
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ 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 的描述格式),`actions.json` 是 `coda:action` 的各个动作和它们接受的参数,`dialect.json` 是流程种类、Coda
|
|
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
10
|
## 用法
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coda-console/protocol",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Coda 的协议:插件清单的类型(JSON Schema)、通用校验器、流程方言与 Agent 接入协议",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
"node": ">=24"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"@0dep/piso": "5.2.0",
|
|
33
34
|
"ajv": "8.20.0",
|
|
34
|
-
"bpmn-moddle": "10.3.1"
|
|
35
|
+
"bpmn-moddle": "10.3.1",
|
|
36
|
+
"feelin": "8.2.0"
|
|
35
37
|
},
|
|
36
38
|
"scripts": {
|
|
37
39
|
"test": "node --test 'test/**/*.test.mjs'"
|
package/validate.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// when it reads one, and coda-plugins' contract check does where a plugin is written.
|
|
7
7
|
// What JSON Schema cannot say of one value alone it says with five keywords, each meaning one thing wherever it is written,
|
|
8
8
|
// as Kubernetes extends its OpenAPI with `x-kubernetes-*`:
|
|
9
|
-
// - `x-unique-by`: [name or {
|
|
10
|
-
// and so is `part`'s first group of each of its texts (a directory's last name)
|
|
9
|
+
// - `x-unique-by`: [name, { part } or { key, nested }] — in an array, each named property of its objects is unique among the
|
|
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
|
+
// items and those in their `nested` arrays, at every depth (a task's answers and those offered on holding one);
|
|
11
12
|
// - `x-max-contains`: { match, max } — of an object's values, at most `max` satisfy `match` (`maxContains` of an object);
|
|
12
13
|
// - `x-ref`: { pointer, part?, match? } — the value (or `part`'s first group of it) names a key of the object, or an element
|
|
13
14
|
// of the array, at `pointer` in the manifest: absolute (`/contributes/configuration/properties`), or relative to the value
|
|
@@ -53,7 +54,8 @@ const SCHEMA_VALUE = { type: ['object', 'boolean'] };
|
|
|
53
54
|
const META = {
|
|
54
55
|
'x-max-contains': { type: 'object', properties: { match: SCHEMA_VALUE, max: { type: 'integer', minimum: 0 } }, required: ['match', 'max'], additionalProperties: false },
|
|
55
56
|
'x-unique-by': { type: 'array', minItems: 1, items: { anyOf: [{ type: 'string' },
|
|
56
|
-
{ type: 'object', properties: { part: { type: 'string' } }, required: ['part'], additionalProperties: false }
|
|
57
|
+
{ type: 'object', properties: { part: { type: 'string' } }, required: ['part'], additionalProperties: false },
|
|
58
|
+
{ type: 'object', properties: { key: { type: 'string' }, nested: { type: 'string' } }, required: ['key', 'nested'], additionalProperties: false }] } },
|
|
57
59
|
'x-ref': { type: 'object', properties: { pointer: { type: 'string', pattern: '^(?:\\d+(?:/.*)?|/.*)$' }, part: { type: 'string' }, match: SCHEMA_VALUE },
|
|
58
60
|
required: ['pointer'], additionalProperties: false },
|
|
59
61
|
'x-placeholders': { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } }, config: SCHEMA_VALUE }, additionalProperties: false },
|
|
@@ -90,11 +92,23 @@ function keywords(ajv) {
|
|
|
90
92
|
ajv.addKeyword({
|
|
91
93
|
keyword: 'x-unique-by', type: 'array', schemaType: 'array', metaSchema: META['x-unique-by'], errors: true,
|
|
92
94
|
compile(keys, parentSchema) {
|
|
93
|
-
const by = keys.map(key => typeof key
|
|
95
|
+
const by = keys.map(key => typeof key !== 'object' ? { key } : key.part !== undefined ? { part: new RegExp(key.part, 'u') } : key);
|
|
96
|
+
// The items, and with `nested` those in each one's array of that name, at every depth: by a stack of its own.
|
|
97
|
+
const every = (items, nested) => {
|
|
98
|
+
if (!nested) return items;
|
|
99
|
+
const found = [], stack = [...items].reverse();
|
|
100
|
+
while (stack.length) {
|
|
101
|
+
const item = stack.pop();
|
|
102
|
+
found.push(item);
|
|
103
|
+
const inner = item && typeof item === 'object' ? item[nested] : undefined;
|
|
104
|
+
if (Array.isArray(inner)) for (let i = inner.length - 1; i >= 0; i--) stack.push(inner[i]);
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
};
|
|
94
108
|
return function unique(items) {
|
|
95
|
-
for (const { key, part } of by) {
|
|
109
|
+
for (const { key, part, nested } of by) {
|
|
96
110
|
const seen = new Set();
|
|
97
|
-
for (const item of items) {
|
|
111
|
+
for (const item of every(items, nested)) {
|
|
98
112
|
const value = part ? (typeof item === 'string' ? part.exec(item)?.[1] : undefined)
|
|
99
113
|
: item && typeof item === 'object' && Object.hasOwn(item, key) ? item[key] : undefined;
|
|
100
114
|
if (value === undefined) continue;
|
|
@@ -162,8 +176,14 @@ function keywords(ajv) {
|
|
|
162
176
|
});
|
|
163
177
|
}
|
|
164
178
|
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
// An Ajv as these schemas are checked with, the keywords above in it: also for the schemas of the protocol's other parts
|
|
180
|
+
// (workflow.mjs), which add their own keywords to it.
|
|
181
|
+
export function newAjv() {
|
|
182
|
+
const made = new Ajv2020({ allErrors: false, verbose: true, passContext: true, strict: true, strictTypes: false, strictRequired: false, allowUnionTypes: true });
|
|
183
|
+
keywords(made);
|
|
184
|
+
return made;
|
|
185
|
+
}
|
|
186
|
+
const ajv = newAjv();
|
|
167
187
|
for (const schema of Object.values(SCHEMAS)) ajv.addSchema(schema);
|
|
168
188
|
// The validators of the schemas here, by file.
|
|
169
189
|
const compiled = new Map();
|
|
@@ -197,7 +217,7 @@ const nodes = function* (node) {
|
|
|
197
217
|
yield node;
|
|
198
218
|
for (const child of Object.values(node)) yield* nodes(child);
|
|
199
219
|
};
|
|
200
|
-
function described(error, { roots = true, schemas = [] } = {}) {
|
|
220
|
+
export function described(error, { roots = true, schemas = [] } = {}) {
|
|
201
221
|
if (!error.parentSchema) return undefined;
|
|
202
222
|
if (error.parentSchema.description) return roots || !error.parentSchema.$id ? error.parentSchema.description : undefined;
|
|
203
223
|
const steps = error.schemaPath.replace(/^#\//, '').split('/').map(unescape).slice(0, -1);
|
package/workflow/dialect.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Coda 的 BPMN 方言里共用的名字:流程种类、Coda
|
|
2
|
+
"description": "Coda 的 BPMN 方言里共用的名字:流程种类、Coda 自己发的事件、步骤失败时抛出的错误码、表达式按名字读的运行变量,以及运行的输入能是哪些类型、从会话的哪里取值(inputs.sources:来源 → 它能给的类型)",
|
|
3
3
|
"kinds": {
|
|
4
4
|
"deploy": "部署后端与服务;只跑合并进默认分支的提交",
|
|
5
5
|
"preview": "发布预览版客户端;只跑合并进默认分支的提交",
|
|
@@ -16,5 +16,9 @@
|
|
|
16
16
|
},
|
|
17
17
|
"userMessage": "^user\\.[a-z][a-z0-9-]{0,39}$",
|
|
18
18
|
"stepFailed": "STEP_FAILED",
|
|
19
|
-
"variables": ["inputs", "run", "github", "vars"]
|
|
19
|
+
"variables": ["inputs", "run", "github", "vars"],
|
|
20
|
+
"inputs": {
|
|
21
|
+
"types": ["string", "boolean", "number", "branch"],
|
|
22
|
+
"sources": {"session.branch": ["branch", "string"]}
|
|
23
|
+
}
|
|
20
24
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://coda.pub/schema/bpmn/1.0/values.json",
|
|
4
|
+
"title": "流程里每个 coda: 值能写什么",
|
|
5
|
+
"description": "$defs 按 moddle.json 的类型名,各是那个类型的属性(和它套着的 coda: 元素)组成的对象的 schema;「<BPMN 类型>.children」是一个元素的 extensionElements 里各种 coda: 元素按名字成组的 schema。x-feel:值是 FEEL(「=」开头是一个表达式,否则文字里每个 {{ … }} 是一个),按这份流程里的名字读得通;x-duration:ISO 8601 时长,不为负;x-number:文字换算成数字是个有限的数",
|
|
6
|
+
"$defs": {
|
|
7
|
+
"ProcessProperties": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"concurrency": {"type": "string", "minLength": 1, "maxLength": 200, "description": "coda:concurrency 须为 1–200 个字符"},
|
|
11
|
+
"key": {"type": "string", "pattern": "\\S", "maxLength": 4000, "x-feel": {"names": ["inputs", "github", "vars"]}, "description": "coda:key 须为 1–4000 个字符"},
|
|
12
|
+
"title": {"type": "string", "pattern": "\\S", "maxLength": 200, "x-feel": true, "description": "coda:title 须为 1–200 个字符"},
|
|
13
|
+
"kind": {"enum": ["deploy", "preview", "release", "check", "premerge"], "description": "coda:kind 只能是 deploy、preview、release、check、premerge"},
|
|
14
|
+
"agents": {"type": "string", "pattern": "^[\\s,]*[a-z][a-z0-9-]{0,39}(?:[\\s,]+[a-z][a-z0-9-]{0,39})*[\\s,]*$",
|
|
15
|
+
"description": "coda:agents 写 Agent 的 ID,用逗号或空格分隔(小写字母、数字和「-」,以字母开头,至多 40 个字符),不能为空"}
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"ScriptTaskProperties": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"audience": {"const": "user", "description": "coda:audience 只能是 user"},
|
|
22
|
+
"group": {"type": "string", "pattern": "^\\S{1,100}$", "description": "coda:group 须为 1–100 个非空白字符的组名"},
|
|
23
|
+
"timeout": {"type": "number", "exclusiveMinimum": 0, "maximum": 1440, "description": "coda:timeout 须为 0–1440 之间的分钟数"},
|
|
24
|
+
"cancelTimeout": {"type": "number", "exclusiveMinimum": 0, "maximum": 1440, "description": "coda:cancelTimeout 须为 0–1440 之间的分钟数"},
|
|
25
|
+
"checkout": {"enum": ["true", "false"], "description": "coda:checkout 只能是 true 或 false"},
|
|
26
|
+
"retry": {"type": "string", "pattern": "^[1-5]$", "description": "coda:retry 是失败后最多再试几次,须为 1 到 5 的整数"}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"RunsOnProperties": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"runsOn": {"type": "string", "pattern": "^\\s*[a-z0-9][a-z0-9._-]{0,39}\\s*(?:,\\s*[a-z0-9][a-z0-9._-]{0,39}\\s*){0,9}$",
|
|
33
|
+
"description": "coda:runsOn 写逗号分隔的标签(小写字母、数字与 . _ -,至多 10 个),如 linux,x64"}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"LoopProperties": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"inputCollection": {"type": "string", "pattern": "^\\s*=", "x-feel": true, "description": "coda:inputCollection 须为「=」开头的 FEEL 表达式,如 =inputs.targets"},
|
|
40
|
+
"inputElement": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "not": {"enum": ["inputs", "run", "github", "vars", "loopCounter"]},
|
|
41
|
+
"description": "coda:inputElement 须为字母开头的字母、数字、「_」,且不是 inputs、run、github、vars、loopCounter 或节点、消息的 id"}
|
|
42
|
+
},
|
|
43
|
+
"allOf": [
|
|
44
|
+
{"if": {"required": ["inputCollection"]}, "then": {"required": ["inputElement"], "description": "写 coda:inputElement,给每份拿到的元素起个名字"}},
|
|
45
|
+
{"if": {"required": ["inputElement"]}, "then": {"required": ["inputCollection"], "description": "coda:inputElement 只配合 coda:inputCollection"}}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"FlowNodeProperties": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"status": {"type": "string", "pattern": "\\S", "maxLength": 200, "x-feel": true, "description": "coda:status 须为 1–200 个字符"},
|
|
52
|
+
"expect": {"type": "string", "anyOf": [{"pattern": "^\\s*=", "maxLength": 200}, {"maxLength": 200, "x-duration": true}], "x-feel": true,
|
|
53
|
+
"description": "coda:expect 须为 ISO 8601 时长,如 PT1H,或读出时长的表达式"}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"StartEventProperties": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"reportTo": {"type": "string",
|
|
60
|
+
"anyOf": [{"pattern": "^\\s*=\\s*vars(?:\\.[A-Za-z0-9_-]+)+\\s*$"},
|
|
61
|
+
{"pattern": "^\\s*(?:coda://threads/|coda\\+space://[^/#]+/threads/)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?:#(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|command:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))?\\s*$"}],
|
|
62
|
+
"description": "coda:reportTo 写会话引用(coda://threads/<会话 UUID>),或只读插件设置的表达式,如 =vars.maintenance.reportTo"},
|
|
63
|
+
"reportOn": {"enum": ["failure", "always"], "description": "coda:reportOn 只能是 failure、always"},
|
|
64
|
+
"scope": {"enum": ["project", "machine"], "description": "coda:scope 只能是 project、machine"}
|
|
65
|
+
},
|
|
66
|
+
"if": {"required": ["reportOn"]},
|
|
67
|
+
"then": {"required": ["reportTo"], "description": "写了 coda:reportOn 就要写 coda:reportTo(结果交给谁)"}
|
|
68
|
+
},
|
|
69
|
+
"EndEventProperties": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"notify": {"type": "string", "anyOf": [{"enum": ["true", "false"]}, {"pattern": "^\\s*="}], "x-feel": true, "description": "coda:notify 须为 true、false 或一个表达式"}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"UserTaskProperties": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"assignee": {"enum": ["agent", "user"], "description": "coda:assignee 只能是 agent(先交给发起会话的 Agent,它停下后转给用户)或 user(直接交给用户)"}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"Input": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"name": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "name 不合法:须为字母开头的字母、数字、「_」"},
|
|
85
|
+
"type": {"enum": ["string", "boolean", "number", "branch"], "description": "type 只能是 string、boolean、number、branch"},
|
|
86
|
+
"label": {"type": "string", "pattern": "\\S", "maxLength": 40, "description": "label 须为 1–40 个字"},
|
|
87
|
+
"defaultFrom": {"enum": ["session.branch"], "description": "defaultFrom 只能是 session.branch"}
|
|
88
|
+
},
|
|
89
|
+
"required": ["name"],
|
|
90
|
+
"allOf": [
|
|
91
|
+
{"if": {"required": ["defaultFrom"]}, "then": {"properties": {"type": {"enum": ["branch", "string"], "description": "defaultFrom=\"session.branch\" 只用于 type 为 branch、string 的参数"}}}},
|
|
92
|
+
{"if": {"required": ["type"], "properties": {"type": {"const": "number"}}},
|
|
93
|
+
"then": {"properties": {"default": {"pattern": "^\\s*[-+]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][-+]?\\d+)?\\s*$", "x-number": true, "description": "default 须为数字"}}}}
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
"Env": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"name": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "名字不合法:须为字母开头的字母、数字、「_」"},
|
|
100
|
+
"value": {"type": "string", "maxLength": 20000, "x-feel": true, "not": {"pattern": "\\$\\{\\{"},
|
|
101
|
+
"description": "value 不超过 20000 字;这里的表达式以「=」开头,如 =inputs.name,不写 ${{ … }}"}
|
|
102
|
+
},
|
|
103
|
+
"required": ["name", "value"]
|
|
104
|
+
},
|
|
105
|
+
"Output": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"name": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "名字不合法:须为字母开头的字母、数字、「_」"},
|
|
109
|
+
"value": {"type": "string", "maxLength": 4000, "x-feel": true, "description": "value 不超过 4000 字"}
|
|
110
|
+
},
|
|
111
|
+
"required": ["name"]
|
|
112
|
+
},
|
|
113
|
+
"Param": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"name": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", "description": "名字不合法:须为字母开头的字母、数字、「_」"},
|
|
117
|
+
"value": {"type": "string", "maxLength": 4000, "x-feel": true, "description": "value 不超过 4000 字"}
|
|
118
|
+
},
|
|
119
|
+
"required": ["name", "value"]
|
|
120
|
+
},
|
|
121
|
+
"Inspect": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"label": {"type": "string", "pattern": "\\S", "maxLength": 100, "description": "label 须为 1–100 个字"},
|
|
125
|
+
"type": {"enum": ["string", "number", "boolean", "timestamp"], "description": "type 只能是 string、number、boolean、timestamp"},
|
|
126
|
+
"phase": {"enum": ["input", "output"], "description": "phase 须为 input 或 output"},
|
|
127
|
+
"value": {"type": "string", "pattern": "^\\s*=", "x-feel": true, "description": "value 须为「=」开头的表达式,如 =approval-facts.given"}
|
|
128
|
+
},
|
|
129
|
+
"required": ["label", "type", "phase", "value"]
|
|
130
|
+
},
|
|
131
|
+
"Choice": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"id": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}$", "not": {"const": "handback"}, "description": "id 须为字母开头的字母、数字、「_」「-」,不能是 handback"},
|
|
135
|
+
"name": {"type": "string", "pattern": "\\S", "maxLength": 40, "description": "name 须为 1–40 个字"},
|
|
136
|
+
"detail": {"type": "string", "maxLength": 200, "x-feel": true, "description": "detail 不超过 200 字"},
|
|
137
|
+
"choices": {"type": "array", "maxItems": 6, "description": "最多 6 个按住出现的选项"},
|
|
138
|
+
"options": {"type": "array", "maxItems": 12, "x-unique-by": ["id"], "description": "最多 12 个勾选项"}
|
|
139
|
+
},
|
|
140
|
+
"required": ["id", "name"],
|
|
141
|
+
"allOf": [{"description": "勾选项(coda:option)只能属于没有嵌套 choice 的外层 choice", "not": {"required": ["choices", "options"]}}]
|
|
142
|
+
},
|
|
143
|
+
"NestedChoice": {
|
|
144
|
+
"allOf": [{"$ref": "#/$defs/Choice"}],
|
|
145
|
+
"properties": {
|
|
146
|
+
"primary": {"const": false, "description": "只有最外层的选项能是主按钮"},
|
|
147
|
+
"choices": {"maxItems": 0, "description": "按住出现的选项里不能再嵌套"},
|
|
148
|
+
"options": {"maxItems": 0, "description": "勾选项(coda:option)只能属于没有嵌套 choice 的外层 choice"}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"Option": {
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {
|
|
154
|
+
"id": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}$", "description": "须有不重复的 id(字母开头的字母、数字、「_」「-」)"},
|
|
155
|
+
"name": {"type": "string", "pattern": "\\S", "maxLength": 40, "x-feel": true, "description": "name 须为 1–40 个字"},
|
|
156
|
+
"detail": {"type": "string", "maxLength": 200, "x-feel": true, "description": "detail 不超过 200 字"},
|
|
157
|
+
"shown": {"type": "string", "pattern": "^\\s*=", "x-feel": true, "description": "coda:shown 须为表达式(以 = 开头)"},
|
|
158
|
+
"default": {"type": "string", "anyOf": [{"enum": ["true", "false"]}, {"pattern": "^\\s*="}], "x-feel": true, "description": "default 须为 true、false 或表达式"}
|
|
159
|
+
},
|
|
160
|
+
"required": ["id", "name"]
|
|
161
|
+
},
|
|
162
|
+
"Process.children": {
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"input": {"type": "array", "x-unique-by": ["name"], "description": "coda:input 的 name 不能重复"}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"UserTask.children": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"properties": {
|
|
171
|
+
"choice": {"type": "array", "maxItems": 6, "x-unique-by": [{"key": "id", "nested": "choices"}],
|
|
172
|
+
"contains": {"type": "object", "properties": {"primary": {"const": true}}, "required": ["primary"]}, "minContains": 0, "maxContains": 1,
|
|
173
|
+
"description": "最多 6 个 coda:choice,最多一个主按钮;所有答复的 id(包括按住出现的)不重复"}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
package/workflow.mjs
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
// Coda's dialect of BPMN 2.0 (workflow/): what a workflow of a project or a plugin may write in Coda's namespace, as a
|
|
2
|
-
// bpmn-moddle descriptor (moddle.json);
|
|
3
|
-
// (actions.json); and the names workflows share (dialect.json):
|
|
4
|
-
// failed step, the run's variables an expression reads by name
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// bpmn-moddle descriptor (moddle.json); what each of those values may be, as JSON Schemas (values.json); the steps it may
|
|
3
|
+
// ask of Coda itself, each with the parameters it takes (actions.json); and the names workflows share (dialect.json):
|
|
4
|
+
// their kinds, the events Coda sends, the error code of a failed step, the run's variables an expression reads by name,
|
|
5
|
+
// the types a run's inputs take. Coda reads a workflow with them, and coda-plugins' contract check does where a plugin is
|
|
6
|
+
// written. readWorkflow refuses what the dialect does not have and a value of it its schema does not take; how the
|
|
7
|
+
// process itself is put together is still Coda's to check as it reads the workflow.
|
|
8
|
+
// What JSON Schema cannot say of a value it says with three keywords of its own, besides the manifests' (validate.mjs):
|
|
9
|
+
// - `x-feel`: true, or { names } — the value is FEEL: an expression when it starts with `=`, else text whose every
|
|
10
|
+
// `{{ … }}` holds one, each reading by name what the workflow has (its nodes and messages, the run's variables, a loop's
|
|
11
|
+
// element), or only `names`;
|
|
12
|
+
// - `x-duration`: true — the value is an ISO 8601 duration, not negative;
|
|
13
|
+
// - `x-number`: true — the text, read as a number, is a finite one (`1e999` reads as none).
|
|
14
|
+
import { parseDuration } from '@0dep/piso';
|
|
7
15
|
import { BpmnModdle } from 'bpmn-moddle';
|
|
16
|
+
import { parseExpression } from 'feelin';
|
|
17
|
+
import { described, newAjv } from './validate.mjs';
|
|
8
18
|
import moddle from './workflow/moddle.json' with { type: 'json' };
|
|
19
|
+
import values from './workflow/values.json' with { type: 'json' };
|
|
9
20
|
import actions from './workflow/actions.json' with { type: 'json' };
|
|
10
21
|
import dialect from './workflow/dialect.json' with { type: 'json' };
|
|
11
22
|
|
|
12
23
|
export const CODA_MODDLE = moddle;
|
|
13
24
|
export const CODA_NAMESPACE = moddle.uri;
|
|
25
|
+
export const CODA_VALUES = values;
|
|
14
26
|
// Each action by name, with the parameters it takes (`coda:param`).
|
|
15
27
|
export const CODA_ACTIONS = Object.fromEntries(Object.entries(actions.actions).map(([name, action]) => [name, action.params]));
|
|
16
28
|
export const WORKFLOW_KINDS = Object.keys(dialect.kinds);
|
|
@@ -23,8 +35,69 @@ export const SESSION_OPENED = 'session.opened', SESSION_FACTS = 'session.facts',
|
|
|
23
35
|
export const USER_MESSAGE = new RegExp(dialect.userMessage);
|
|
24
36
|
export const STEP_FAILED = dialect.stepFailed;
|
|
25
37
|
export const RUN_VARIABLES = dialect.variables;
|
|
38
|
+
// The types a run's inputs take (`coda:input type`), and where one started from a session takes its value from when given
|
|
39
|
+
// none (`defaultFrom`), with the types each gives.
|
|
40
|
+
export const INPUT_TYPES = dialect.inputs.types;
|
|
41
|
+
export const INPUT_SOURCES = Object.freeze(Object.fromEntries(Object.entries(dialect.inputs.sources).map(([name, types]) => [name, { types }])));
|
|
42
|
+
|
|
43
|
+
// A value that starts with `=` is a FEEL expression; anything else is text, where each `{{ … }}` holds one.
|
|
44
|
+
export const isExpression = value => typeof value === 'string' && value.trimStart().startsWith('=');
|
|
45
|
+
const TEMPLATE = /\{\{([\s\S]*?)\}\}/g;
|
|
46
|
+
// What the expressions of the engine before FEEL looked like: refused with the way to write it now.
|
|
47
|
+
const OLD = /\$\{\s*environment\./;
|
|
48
|
+
// FEEL reads every name it is given at each parse, and a workflow has a name for each of its nodes: an expression is given
|
|
49
|
+
// only those it mentions.
|
|
50
|
+
const mentioned = (expression, names) => Object.fromEntries(Object.entries(names).filter(([name]) => expression.includes(name)));
|
|
51
|
+
// Why a value does not read as FEEL, or null: `names`, what it may read by name (an object with them as keys), so that a
|
|
52
|
+
// task id like `in-queue` reads as one name.
|
|
53
|
+
export function feelProblem(value, names = {}) {
|
|
54
|
+
if (typeof value !== 'string') return null;
|
|
55
|
+
if (OLD.test(value)) return '表达式改用 FEEL,以「=」开头,如 =review.verdict;文字里用 {{ … }},如 还在验证:{{verifying.what}}(docs/workflows.md「表达式」)';
|
|
56
|
+
const expressions = isExpression(value) ? [value.trimStart().slice(1)] : [...value.matchAll(TEMPLATE)].map(m => m[1]);
|
|
57
|
+
for (const expression of expressions) {
|
|
58
|
+
if (!expression.trim()) return '表达式是空的';
|
|
59
|
+
// A property's name says nothing of the grammar, and what a value holds is known only once read (`vars.probe.every`
|
|
60
|
+
// names a setting, not FEEL's `every`): each is read as a plain one.
|
|
61
|
+
const plain = expression.replace(/\.(?=[A-Za-z_])[\w-]+/g, '.p');
|
|
62
|
+
let error = null;
|
|
63
|
+
parseExpression(plain, mentioned(plain, names)).iterate({ enter(node) { if (node.type.isError && error === null) error = node.from; } });
|
|
64
|
+
if (error !== null) return `「${expression.trim()}」不是有效的 FEEL 表达式(第 ${error + 1} 个字符附近)`;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// An ISO 8601 duration (`P30D`, `PT6H`), not negative, as bpmn-engine reads its timers.
|
|
69
|
+
export function isDuration(text) {
|
|
70
|
+
if (typeof text !== 'string' || !text || text.length > 200) return false;
|
|
71
|
+
try { return parseDuration(text).result.sign !== -1; } catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ajv = newAjv();
|
|
75
|
+
ajv.addKeyword({
|
|
76
|
+
keyword: 'x-feel', type: 'string', schemaType: ['boolean', 'object'], errors: true,
|
|
77
|
+
metaSchema: { anyOf: [{ type: 'boolean' }, { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } } }, required: ['names'], additionalProperties: false }] },
|
|
78
|
+
compile(spec, parentSchema) {
|
|
79
|
+
const own = typeof spec === 'object' ? Object.fromEntries(spec.names.map(name => [name, null])) : null;
|
|
80
|
+
return function feel(value) {
|
|
81
|
+
const problem = spec ? feelProblem(value, own ?? this?.names ?? {}) : null;
|
|
82
|
+
if (problem) feel.errors = [{ keyword: 'x-feel', message: problem, params: {}, parentSchema }];
|
|
83
|
+
return !problem;
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
ajv.addKeyword({ keyword: 'x-duration', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || isDuration(value) });
|
|
88
|
+
ajv.addKeyword({ keyword: 'x-number', type: 'string', schemaType: 'boolean', compile: spec => value => !spec || Number.isFinite(Number(value)) });
|
|
89
|
+
ajv.addSchema(values);
|
|
90
|
+
const checks = new Map();
|
|
91
|
+
// The validator of a definition of values.json, by its name; none for a type it says nothing of.
|
|
92
|
+
const check = name => {
|
|
93
|
+
if (!checks.has(name)) checks.set(name, values.$defs[name] ? ajv.getSchema(`${values.$id}#/$defs/${name}`) : null);
|
|
94
|
+
return checks.get(name);
|
|
95
|
+
};
|
|
26
96
|
|
|
27
97
|
const SIZE_LIMIT = 1_000_000;
|
|
98
|
+
// bpmn-moddle rewrites a descriptor as it takes it in (its names get their prefix), and whoever imports CODA_MODDLE may hand
|
|
99
|
+
// 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
|
+
const DESCRIPTOR = structuredClone(moddle);
|
|
28
101
|
const children = element => Object.values(element).flatMap(v => (Array.isArray(v) ? v : [v])).filter(v => v?.$type && v.$parent === element);
|
|
29
102
|
// Everything in the definition under `element`, depth first, by a stack of its own, pushed one at a time: a definition may
|
|
30
103
|
// nest deeper than the call stack goes, and hold more in one place than a call takes arguments.
|
|
@@ -34,14 +107,73 @@ const all = element => {
|
|
|
34
107
|
while (stack.length) { const next = stack.pop(); found.push(next); push(children(next)); }
|
|
35
108
|
return found;
|
|
36
109
|
};
|
|
110
|
+
const TYPES = new Map(DESCRIPTOR.types.map(type => [type.name, type]));
|
|
111
|
+
const codaType = element => element.$type.startsWith('coda:') ? TYPES.get(element.$type.slice(5)) : undefined;
|
|
112
|
+
const lower = name => `${name[0].toLowerCase()}${name.slice(1)}`;
|
|
113
|
+
// The attributes `type` declares that `element` has, as moddle read them.
|
|
114
|
+
const attributes = (element, type) => Object.fromEntries(type.properties.filter(p => p.isAttr && element[p.name] !== undefined).map(p => [p.name, element[p.name]]));
|
|
115
|
+
// A coda: element as one object: its attributes, and the coda: elements it holds (`isMany`), each as its attributes.
|
|
116
|
+
function codaObject(element) {
|
|
117
|
+
const type = codaType(element), object = attributes(element, type);
|
|
118
|
+
for (const p of type.properties.filter(p => p.isMany && element[p.name]?.length)) object[p.name] = element[p.name].map(child => attributes(child, codaType(child)));
|
|
119
|
+
return object;
|
|
120
|
+
}
|
|
121
|
+
// The element of the process a coda: element is written in: the nearest around it of BPMN's with an id.
|
|
122
|
+
const owner = element => { let at = element.$parent; while (at && (codaType(at) || !at.id)) at = at.$parent; return at; };
|
|
123
|
+
// What is wrong, said once, as Coda says it: where (`at`), then what the schema says of the value there. `attribute`: the
|
|
124
|
+
// object is a BPMN element's coda: attributes, whose names are said with their prefix.
|
|
125
|
+
function said(error, at, attribute) {
|
|
126
|
+
const key = error.instancePath.split('/')[1], name = key === undefined ? '' : `${attribute ? 'coda:' : ''}${key}`;
|
|
127
|
+
if (error.keyword === 'x-feel') return `${at}${key && key !== 'value' ? ` 的 ${name}` : ''}:${error.message}`;
|
|
128
|
+
if (error.keyword === 'required') {
|
|
129
|
+
const own = error.parentSchema?.description;
|
|
130
|
+
return `${at} 需要 ${attribute ? 'coda:' : ''}${error.params.missingProperty}${own ? `:${own}` : ''}`;
|
|
131
|
+
}
|
|
132
|
+
if (error.keyword === 'x-unique-by') return `${at}:${error.message}`;
|
|
133
|
+
return `${at}:${described(error, { schemas: [values] }) ?? error.message}`;
|
|
134
|
+
}
|
|
135
|
+
// What of the coda: values in the definition its schemas do not take, or null: each element in order, its coda:
|
|
136
|
+
// attributes by each type of Coda's extending what it is, the coda: elements it holds by kind, and each coda: element.
|
|
137
|
+
function valuesProblem(root) {
|
|
138
|
+
const elements = all(root);
|
|
139
|
+
// What an expression may read by name: the run's own variables, each node's result and each message's latest, and in a
|
|
140
|
+
// loop its element and `loopCounter`.
|
|
141
|
+
const loops = elements.filter(e => e.$type === 'bpmn:MultiInstanceLoopCharacteristics');
|
|
142
|
+
const names = Object.fromEntries([...RUN_VARIABLES, ...elements.filter(e => e.$type === 'bpmn:Message' || e.$instanceOf?.('bpmn:FlowElement')).map(e => e.id),
|
|
143
|
+
...loops.map(loop => loop.inputElement).filter(Boolean), ...(loops.length ? ['loopCounter'] : [])].filter(Boolean).map(name => [name, null]));
|
|
144
|
+
const failed = (validate, object, at, attribute) => validate && !validate.call({ names }, object) ? said(validate.errors[0], at, attribute) : null;
|
|
145
|
+
for (const element of elements) {
|
|
146
|
+
const type = codaType(element);
|
|
147
|
+
if (type) {
|
|
148
|
+
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);
|
|
150
|
+
if (problem) return problem;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (element.$type?.startsWith('bpmn:') !== true || !element.$instanceOf) continue;
|
|
154
|
+
const at = element.id ?? owner(element)?.id;
|
|
155
|
+
for (const extending of DESCRIPTOR.types.filter(t => t.extends?.some(on => element.$instanceOf(on)))) {
|
|
156
|
+
const problem = failed(check(extending.name), attributes(element, extending), at, true);
|
|
157
|
+
if (problem) return problem;
|
|
158
|
+
}
|
|
159
|
+
const held = (element.extensionElements?.values ?? []).filter(codaType);
|
|
160
|
+
if (!held.length) continue;
|
|
161
|
+
const grouped = {};
|
|
162
|
+
for (const child of held) (grouped[lower(codaType(child).name)] ??= []).push(codaObject(child));
|
|
163
|
+
const problem = failed(check(`${element.$type.slice(5)}.children`), grouped, at, false);
|
|
164
|
+
if (problem) return problem;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
37
168
|
|
|
38
169
|
// A workflow's definition (`file`, its name without `.bpmn`, for what is said) read with Coda's dialect: `{ root }`, the
|
|
39
170
|
// parsed definitions, or `{ problem }`, what of it the dialect does not have — not BPMN, a coda: attribute or element it
|
|
40
|
-
// does not know, an action it does not have or a parameter that action does not take
|
|
171
|
+
// does not know, an action it does not have or a parameter that action does not take — or a value of it its schema does
|
|
172
|
+
// not take.
|
|
41
173
|
export async function readWorkflow(source, file = 'workflow') {
|
|
42
174
|
if (typeof source !== 'string' || source.length > SIZE_LIMIT) return { problem: `${file}.bpmn 过大` };
|
|
43
175
|
let parsed;
|
|
44
|
-
try { parsed = await new BpmnModdle({ coda:
|
|
176
|
+
try { parsed = await new BpmnModdle({ coda: structuredClone(DESCRIPTOR) }).fromXML(source); }
|
|
45
177
|
catch (e) { return { problem: `${file}.bpmn 不是有效的 BPMN 2.0:${e.message}` }; }
|
|
46
178
|
const { rootElement: root, warnings } = parsed;
|
|
47
179
|
if (warnings.length) return { problem: `${file}.bpmn 有无法识别的内容:${warnings.slice(0, 3).map(w => w.message).join(';')}` };
|
|
@@ -51,7 +183,8 @@ export async function readWorkflow(source, file = 'workflow') {
|
|
|
51
183
|
const given = (task.extensionElements?.values ?? []).filter(v => v.$type === 'coda:Param');
|
|
52
184
|
if (given.some(p => !params.includes(p.name))) return { problem: `${task.id}:coda:action ${task.action} 只接受参数 ${params.join('、')}` };
|
|
53
185
|
}
|
|
54
|
-
|
|
186
|
+
const problem = valuesProblem(root);
|
|
187
|
+
return problem ? { problem } : { root };
|
|
55
188
|
}
|
|
56
189
|
// What of a workflow's definition the dialect does not have, or null.
|
|
57
190
|
export async function workflowProblem(source, file) {
|