@coda-console/protocol 1.4.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 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 自己发的事件、步骤失败的错误码和表达式能读的运行变量。`workflow.mjs` 用它们读一份流程,方言里没有的都拒绝;每个值能写什么、流程的结构对不对,现在仍由 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
  ## 用法
@@ -37,6 +37,12 @@ const { root } = await readWorkflow(source, 'deploy');
37
37
 
38
38
  `TYPES` 是 Coda 自己的类型(`contributes` 的键),`SCHEMAS` 是全部 schema(按文件名)。
39
39
 
40
+ ## 插件的后端通道
41
+
42
+ 插件清单可以声明 `"coda": { "channels": ["preview"] }`,限定只在预览版后端安装和更新。允许的通道是 `stable`(正式版)和 `preview`(预览版);数组非空、不重复。不写 `channels` 的插件不限制通道。
43
+
44
+ 宿主按每台目标后端自己的安装记录判断,不按 Mac / iPhone 客户端版本判断;没有通道记录的后端不满足显式限制。商店和直接从 npm、Git 安装执行同一限制。这是 Coda 的安装约束,不改变 npm 包的公开性,也不能约束不认识该字段的旧宿主。
45
+
40
46
  ## 项目中的多选选项
41
47
 
42
48
  设置的 `items.anyOf` 选项可附带 `workflowKind`(`check`、`premerge`、`deploy`、`preview`、`release`)。全局设置显示全部选项;项目设置只显示该项目有的流程类别,未写此字段的选项始终显示。宿主负责按项目提供可选项,流程执行时仍需按运行的提交判断可用操作。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coda-console/protocol",
3
- "version": "1.4.0",
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'"
@@ -8,6 +8,14 @@
8
8
  "id": {"$ref": "common.json#/$defs/id", "description": "插件 ID:小写字母、数字和「-」,以字母开头,至多 40 个字符;不写就用 name"},
9
9
  "name": {"type": "string"},
10
10
  "categories": {"description": "分类:文本的数组,第一个是插件页里归的类", "type": "array", "items": {"type": "string"}},
11
+ "coda": {
12
+ "description": "Coda 宿主的安装要求;不写 channels 就不限制后端通道",
13
+ "type": "object",
14
+ "properties": {
15
+ "channels": {"description": "允许安装和更新的后端通道:stable(正式版)、preview(预览版);以目标后端的安装记录为准,没有通道记录不满足限制", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stable", "preview"]}}
16
+ },
17
+ "additionalProperties": false
18
+ },
11
19
  "main": {"description": "插件宿主运行的模块:插件目录里的 .js 或 .mjs 文件", "type": "string", "pattern": "\\.m?js$", "x-file": {"type": "file"}},
12
20
  "activationEvents": {"description": "宿主什么时候加载 main:onStartupFinished(后端一启动),不重复", "type": "array", "items": {"enum": ["onStartupFinished"]}, "uniqueItems": true},
13
21
  "extensionDependencies": {"description": "需要先装的插件的 ID,不重复", "type": "array", "items": {"$ref": "common.json#/$defs/id"}, "uniqueItems": true},
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 { part }] — in an array, each named property of its objects is unique among the items having it,
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 === 'object' ? { part: new RegExp(key.part, 'u') } : { 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
- const ajv = new Ajv2020({ allErrors: false, verbose: true, passContext: true, strict: true, strictTypes: false, strictRequired: false, allowUnionTypes: true });
166
- keywords(ajv);
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);
@@ -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); the steps it may ask of Coda itself, each with the parameters it takes
3
- // (actions.json); and the names workflows share (dialect.json): their kinds, the events Coda sends, the error code of a
4
- // failed step, the run's variables an expression reads by name. Coda reads a workflow with them, and coda-plugins'
5
- // contract check does where a plugin is written. readWorkflow refuses what the dialect does not have; what each value may
6
- // be is still Coda's to check as it reads the workflow.
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: CODA_MODDLE }).fromXML(source); }
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
- return { root };
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) {