skill(iter-1): fix CLI getRecords flag + docs from eval feedback

Ran 4 end-to-end evals (skill-creator flow): all 23 assertions passed (100%).
The evals exposed 3 issues, now fixed:

1. CLI bug: getRecords/createRecord/updateRecord --target-table flag was
   read as opts['target-table'] but commander stores it camelCase
   (opts.targetTable). Added a pick() helper accepting both forms.
2. trigger-types.md: buttonClick output said record.fields.* implying all
   fields; actually record.fields only contains the button field's count.
   Documented the back-channel API fetch pattern for other fields.
3. automation-guide.md: documented that 'workflow test' uses mock input
   with empty fields, so upstream outputs are absent in test mode — use
   real fire for data-dependent validation.

Also: corrected button field options (workflow.id object, not workflowId).
This commit is contained in:
yeuimu
2026-08-05 10:48:33 +08:00
parent d3790ab856
commit 3e06a10fe0
6 changed files with 92 additions and 16 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ These differ from official teable and from the (stale) `docs/automation/triggers
1. **Use `inkreach-teable` CLI, never `teable` CLI for automations** — official CLI uses incompatible contract (filter/watchFieldIds/recordMatchesConditions) and will 404 or corrupt config. 1. **Use `inkreach-teable` CLI, never `teable` CLI for automations** — official CLI uses incompatible contract (filter/watchFieldIds/recordMatchesConditions) and will 404 or corrupt config.
2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires. 2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires.
3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot. 3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot.
4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflowId`, or it won't render/click. Verify `workflow.isActive` (the field options capture a stale `isActive` snapshot at creation). 4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflow.id` (the `workflow` value is an **object** `{id, name?}`, NOT a bare `workflowId` string — matching the DB shape), or the field won't render/click. Note: field `options` captures a stale `isActive` snapshot at creation; the API validates the DB `isActive` at click time instead, so a deactivated workflow returns 400.
5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeIndex.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel. 5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeIndex.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel.
## 5. Common Errors & Recovery ## 5. Common Errors & Recovery
@@ -176,12 +176,21 @@ inkreach-teable trigger set <wfId> --type buttonClick --table-id tblXXX --field-
```json ```json
{ "tableId": "tblXXX", "fieldIds": ["fldGo"] } { "tableId": "tblXXX", "fieldIds": ["fldGo"] }
``` ```
- The field **must be a Button-type field** whose `options.workflowId` points to this workflow. - The field **must be a Button-type field** whose `options.workflow.id` points to this workflow.
- Create it: `inkreach-teable field create --table-id tblXXX --name "Go" --type button --options '{"workflowId":"wflXXX","label":"Go"}'` - Create it: `inkreach-teable field create --table-id tblXXX --name "Go" --type button --options '{"workflow":{"id":"wflXXX"},"label":"Go"}'`
**Triggering:** the click comes from `POST /api/table/{tableId}/record/{recordId}/{fieldId}/button-click` (the web UI calls this; CLI tests via `fire button-click`). **Triggering:** the click comes from `POST /api/table/{tableId}/record/{recordId}/{fieldId}/button-click` (the web UI calls this; CLI tests via `fire button-click`).
**Output:** `record.id`, `record.fields.*`, `fieldId`, `user`, `runId`, `name`, `tableId`, `workflowId`. **Output:** `record.id`, `record.name` (primary field), `record.fields.<buttonFieldId>` (**only the clicked button field's count, NOT all fields**), `fieldId`, `user`, `runId`, `name`, `tableId`, `workflowId`.
⚠️ **`record.fields` contains ONLY the button field itself** (e.g. `{fldXxx:{count:1}}`), not the record's other fields. To read other fields (e.g. an "订单号" text field), use the back-channel API inside a script:
```js
const rec = await fetch(`${triggerInput._apiBaseUrl}/api/record/${triggerInput.record.id}`, {
headers: { Authorization: 'Bearer ' + triggerInput._accessToken }
}).then(r => r.json());
return { orderId: rec.fields.fldXxx };
```
`record.name` (primary field) and `record.id` ARE directly available.
⚠️ The Button field's `options` captures a stale `isActive` snapshot at creation. After deactivating, the API validates the DB `isActive` instead — so a deactivated workflow returns 400 "not active" on click. Re-`activate` to fix. ⚠️ The Button field's `options` captures a stale `isActive` snapshot at creation. After deactivating, the API validates the DB `isActive` instead — so a deactivated workflow returns 400 "not active" on click. Re-`activate` to fix.
@@ -0,0 +1,33 @@
{
"skill_name": "inkreach-teable",
"evals": [
{
"id": 1,
"name": "button-click-automation",
"prompt": "帮我在 order_logs 表里加一个按钮字段叫「催单」,点这个按钮的时候触发一个自动化,自动化里跑一段脚本,脚本内容是返回当前记录的订单号字段。然后激活它,点一下按钮测试看看执行结果。",
"expected_output": "应创建 button 字段(options.workflow.id 指向新 workflow)、创建 workflow、设 buttonClick 触发器、加 script action(读取 triggerInput.record.fields)、激活、fire button-click 测试、runs list 看到 succeeded。关键:button options 用 workflow 对象结构而非 workflowId 字符串。",
"files": []
},
{
"id": 2,
"name": "scheduled-digest",
"prompt": "我想每天定时跑一次自动化,去查一下 order_logs 表里所有记录,把数量统计出来。帮我配一个定时自动化。",
"expected_output": "应创建 workflow、设 scheduledTime 触发器(timing.type + interval,不配后端忽略的字段如 triggerTime/weekdays)、加 getRecords action、加 script action 统计、激活。关键:知道 scheduledTime 后端只认 type+interval。",
"files": []
},
{
"id": 3,
"name": "record-condition-trigger",
"prompt": "当 order_logs 表里某条记录的「结果」字段变成「失败」的时候,自动发一封邮件通知到 ops@xingchen.tech。帮我配这个自动化。",
"expected_output": "应创建 workflow、设 recordCondition 触发器(用 conditions 而非 filter,category 是 recordCondition 而非 recordMatchesConditions)、加 sendEmail action、激活。关键:用正确的自研契约键名 conditions。",
"files": []
},
{
"id": 4,
"name": "debug-failed-run",
"prompt": "我有个自动化 wfl5F8dQRDloo28w909,最近好像没正常跑,帮我看看是什么情况。",
"expected_output": "应先 workflow get 查 isActive、再 runs list --status failed 查失败记录、runs get 看具体错误。关键:用排查流程而非盲目重新激活。",
"files": []
}
]
}
@@ -147,6 +147,19 @@ inkreach-teable runs get wflXXX <executionId>
inkreach-teable runs rerun wflXXX <executionId> inkreach-teable runs rerun wflXXX <executionId>
``` ```
### `workflow test` vs real fire
`workflow test <id>` runs actions synchronously with **mock input** (a fake
`{record:{id:'rec_test',fields:{}}, user:{...}}`). It does NOT fire the
trigger or use the queue. Two caveats:
- **Upstream outputs are absent in test mode** — if a script reads
`triggerInput.record.fields.<X>`, it gets `{}` because the mock record has
no fields. Test only validates that the script *runs without throwing*, not
that it produces correct data.
- For data-dependent validation, do a **real fire** (button-click / webhook)
or perform the real triggering action, then check `runs list`.
## Variable References (cross-node data) ## Variable References (cross-node data)
An action can reference upstream nodes' output in string fields via `{{nodeIndex.path}}`: An action can reference upstream nodes' output in string fields via `{{nodeIndex.path}}`:
+18 -8
View File
@@ -466,11 +466,15 @@ function readFile(p) {
/** Build action config from granular flags. */ /** Build action config from granular flags. */
function buildActionConfig(opts) { function buildActionConfig(opts) {
const type = opts.type; const type = opts.type;
// Helper: commander stores --kebab-case flags as camelCase in opts (e.g.
// --target-table -> opts.targetTable). Accept both forms for safety.
const pick = (k) => (opts[k] ?? opts[toCamel(k)]);
const config = {}; const config = {};
switch (type) { switch (type) {
case 'script': { case 'script': {
if (opts['code-file']) const codeFile = pick('code-file');
config.code = readFile(opts['code-file']); if (codeFile)
config.code = readFile(codeFile);
else if (opts.code) else if (opts.code)
config.code = opts.code; config.code = opts.code;
if (opts.timeout) if (opts.timeout)
@@ -493,21 +497,24 @@ function buildActionConfig(opts) {
config.method = opts.method; config.method = opts.method;
if (opts.headers) if (opts.headers)
config.headers = JSON.parse(opts.headers); config.headers = JSON.parse(opts.headers);
if (opts['req-body']) const reqBody = pick('req-body');
config.body = JSON.parse(opts['req-body']); if (reqBody)
config.body = JSON.parse(reqBody);
break; break;
} }
case 'createRecord': case 'createRecord':
case 'updateRecord': { case 'updateRecord': {
if (opts['target-table']) const tt = pick('target-table');
config.tableId = opts['target-table']; if (tt)
config.tableId = tt;
if (opts.records) if (opts.records)
config.records = JSON.parse(opts.records); config.records = JSON.parse(opts.records);
break; break;
} }
case 'getRecords': { case 'getRecords': {
if (opts['target-table']) const tt = pick('target-table');
config.tableId = opts['target-table']; if (tt)
config.tableId = tt;
if (opts.query) if (opts.query)
config.query = JSON.parse(opts.query); config.query = JSON.parse(opts.query);
break; break;
@@ -518,3 +525,6 @@ function buildActionConfig(opts) {
return config; return config;
} }
exports.buildActionConfig = buildActionConfig; exports.buildActionConfig = buildActionConfig;
function toCamel(s) {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
+15 -4
View File
@@ -501,10 +501,14 @@ function readFile(p: string): string {
/** Build action config from granular flags. */ /** Build action config from granular flags. */
export function buildActionConfig(opts: Record<string, string>): Record<string, unknown> { export function buildActionConfig(opts: Record<string, string>): Record<string, unknown> {
const type = opts.type; const type = opts.type;
// Helper: commander stores --kebab-case flags as camelCase in opts (e.g.
// --target-table -> opts.targetTable). Accept both forms for safety.
const pick = (k: string): string | undefined => (opts[k] ?? opts[toCamel(k)]) as string | undefined;
const config: Record<string, unknown> = {}; const config: Record<string, unknown> = {};
switch (type) { switch (type) {
case 'script': { case 'script': {
if (opts['code-file']) config.code = readFile(opts['code-file']); const codeFile = pick('code-file');
if (codeFile) config.code = readFile(codeFile);
else if (opts.code) config.code = opts.code; else if (opts.code) config.code = opts.code;
if (opts.timeout) config.timeout = Number(opts.timeout); if (opts.timeout) config.timeout = Number(opts.timeout);
break; break;
@@ -519,17 +523,20 @@ export function buildActionConfig(opts: Record<string, string>): Record<string,
if (opts.url) config.url = opts.url; if (opts.url) config.url = opts.url;
if (opts.method) config.method = opts.method; if (opts.method) config.method = opts.method;
if (opts.headers) config.headers = JSON.parse(opts.headers); if (opts.headers) config.headers = JSON.parse(opts.headers);
if (opts['req-body']) config.body = JSON.parse(opts['req-body']); const reqBody = pick('req-body');
if (reqBody) config.body = JSON.parse(reqBody);
break; break;
} }
case 'createRecord': case 'createRecord':
case 'updateRecord': { case 'updateRecord': {
if (opts['target-table']) config.tableId = opts['target-table']; const tt = pick('target-table');
if (tt) config.tableId = tt;
if (opts.records) config.records = JSON.parse(opts.records); if (opts.records) config.records = JSON.parse(opts.records);
break; break;
} }
case 'getRecords': { case 'getRecords': {
if (opts['target-table']) config.tableId = opts['target-table']; const tt = pick('target-table');
if (tt) config.tableId = tt;
if (opts.query) config.query = JSON.parse(opts.query); if (opts.query) config.query = JSON.parse(opts.query);
break; break;
} }
@@ -538,3 +545,7 @@ export function buildActionConfig(opts: Record<string, string>): Record<string,
} }
return config; return config;
} }
function toCamel(s: string): string {
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}