@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 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, CODA_MODDLE, CODA_ACTIONS } from '@coda-console/protocol/workflow';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coda-console/protocol",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Coda 的协议:插件清单的类型(JSON Schema)、通用校验器、流程方言与 Agent 接入协议",
5
5
  "type": "module",
6
6
  "license": "MIT",
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) names a key of the object, or an element
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) ? at.indexOf(name) : at !== null && typeof at === 'object' && Object.hasOwn(at, name) ? name : -1;
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
- return function (value, cxt) {
135
- const name = part ? part.exec(value)?.[1] : value;
136
- return name !== undefined && resolves(this, ref, matches, name, cxt);
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
+ }
@@ -171,10 +171,77 @@
171
171
  "UserTask.children": {
172
172
  "type": "object",
173
173
  "properties": {
174
- "choice": {"type": "array", "maxItems": 6, "x-unique-by": [{"key": "id", "nested": "choices"}],
175
- "contains": {"type": "object", "properties": {"primary": {"const": true}}, "required": ["primary"]}, "minContains": 0, "maxContains": 1,
176
- "description": "最多 6 个 coda:choice,最多一个主按钮;所有答复的 id(包括按住出现的)不重复"}
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 three keywords of its own, besides the manifests' (validate.mjs):
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[name] ? ajv.getSchema(`${values.$id}#/$defs/${name}`) : null);
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}:${described(error, { schemas: [values] }) ?? error.message}`;
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 }, object) ? said(validate.errors[0], at, attribute) : null;
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 problem = failed(check(`${element.$type.slice(5)}.children`), grouped, at, false);
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(DESCRIPTOR) }).fromXML(source); }
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];