From 819355b31fb81b5307da73fa4ed43ca63b09a8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E7=8E=84?= Date: Mon, 10 Aug 2026 16:20:22 +0800 Subject: [PATCH 1/2] feat: add aitable workflow run and history commands --- CHANGELOG.md | 1 + internal/helpers/aitable.go | 180 +++++++++++++++++- .../helpers/aitable_workflow_write_test.go | 148 +++++++++++++- skills/mono/references/products/aitable.md | 4 +- .../products/aitable/aitable-workflow.md | 36 +++- .../dingtalk-aitable/references/aitable.md | 2 + .../references/aitable/aitable-workflow.md | 53 +++++- 7 files changed, 402 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 124df785..9f74e56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added +- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation. - **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。 - **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。 - **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。 diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 7dc93a09..f79cca63 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -16,6 +16,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging" "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) @@ -125,6 +126,70 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) { return dsl, nil } +func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error { + tableID, _ := cmd.Flags().GetString("table-id") + tableID = strings.TrimSpace(tableID) + recordIDs, _ := cmd.Flags().GetStringSlice("record-ids") + cleaned := make([]string, 0, len(recordIDs)) + seen := make(map[string]struct{}, len(recordIDs)) + for _, recordID := range recordIDs { + recordID = strings.TrimSpace(recordID) + if recordID == "" { + continue + } + if _, ok := seen[recordID]; ok { + return apperrors.NewValidation(fmt.Sprintf("--record-ids 不能包含重复值 %q", recordID)) + } + seen[recordID] = struct{}{} + cleaned = append(cleaned, recordID) + } + if cmd.Flags().Changed("table-id") && tableID == "" { + return apperrors.NewValidation("--table-id 不能为空") + } + if cmd.Flags().Changed("record-ids") && len(cleaned) == 0 { + return apperrors.NewValidation("--record-ids 必须包含 1 到 5 个非空记录 ID") + } + if len(cleaned) > 5 { + return apperrors.NewValidation(fmt.Sprintf("--record-ids 最多支持 5 个记录 ID,got %d", len(cleaned))) + } + if (tableID != "") != (len(cleaned) > 0) { + return apperrors.NewValidation("--table-id 与 --record-ids 必须同时提供;定时触发工作流则两者都不传") + } + return nil +} + +func validateWorkflowHistoryFlags(cmd *cobra.Command, _ []string) error { + if cmd.Flags().Changed("page") { + page, _ := cmd.Flags().GetInt("page") + if page < 0 { + return apperrors.NewValidation(fmt.Sprintf("--page 必须 >= 0,got %d", page)) + } + } + if cmd.Flags().Changed("size") { + size, _ := cmd.Flags().GetInt("size") + if size < 1 || size > 100 { + return apperrors.NewValidation(fmt.Sprintf("--size 必须在 [1, 100] 范围内,got %d", size)) + } + } + for _, name := range []string{"after-time", "before-time"} { + if !cmd.Flags().Changed(name) { + continue + } + value, _ := cmd.Flags().GetInt(name) + if value < 0 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 >= 0 的 Unix 毫秒时间戳,got %d", name, value)) + } + } + if cmd.Flags().Changed("after-time") && cmd.Flags().Changed("before-time") { + afterTime, _ := cmd.Flags().GetInt("after-time") + beforeTime, _ := cmd.Flags().GetInt("before-time") + if afterTime >= beforeTime { + return apperrors.NewValidation(fmt.Sprintf("--after-time 必须小于 --before-time,got %d >= %d", afterTime, beforeTime)) + } + } + return nil +} + // recordQueryFetchAll implements --all auto-pagination for record query. // It prints only a complete result. A page limit, empty/invalid response, // transport failure, or cursor cycle returns a non-zero structured error whose @@ -1038,7 +1103,7 @@ func newAitableCommand() *cobra.Command { dws aitable form [list|delete|update] 表单管理 dws aitable form field [list|update|hide] 表单字段管理 dws aitable form share [get|update|notify] 表单分享管理 - dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理 + dws aitable workflow [edit-example|create|update|enable|disable|run|history|get|list] 自动化工作流管理 dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理 dws aitable chart [get|create|update|delete|widgets-example] 图表管理 dws aitable export data 数据导出 @@ -4878,7 +4943,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, workflowCmd := &cobra.Command{ Use: "workflow", - Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)", + Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)", RunE: groupRunE, } @@ -5223,6 +5288,116 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 }, }) + workflowRunCmd := NewLeafCommand(LeafSpec{ + Use: "run", + Short: "执行指定自动化工作流", + Long: `立即执行指定 Base 中的自动化工作流。此命令会启动真实的异步执行,并可能产生该工作流配置的消息发送、记录写入等副作用,因此执行前需要确认;CLI 不自动重试。 + +记录类触发器必须同时提供 --table-id 与 --record-ids;--table-id 必须与触发器绑定的数据表一致,--record-ids 接受 1 到 5 个不重复记录 ID。定时触发器不传这两个参数。 +返回每条记录的提交状态;提交成功项包含 executionId,可用 workflow history 返回项的 instanceId 匹配执行记录。`, + Example: ` dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2 + dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID`, + Tool: "run_workflow", + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Validate: validateWorkflowRunFlags, + Flags: []LeafFlag{ + {Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}}, + {Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "workflowId", Trim: true, Required: true}, + {Name: "table-id", Usage: "记录类触发器绑定的 Table ID;定时触发器不传", Bind: "tableId", Trim: true, OmitEmpty: true, RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"}, + {Name: "record-ids", Usage: "触发工作流的记录 ID,逗号分隔;记录类触发器必填,1 到 5 个且不可重复", Kind: LeafStringSlice, Bind: "recordIds", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"}, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, + Flags: []string{"table-id", "record-ids"}, + Description: "记录类触发器必须同时提供 --table-id 与 --record-ids;定时触发器两者都不传", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "workflow_run", + CanonicalPath: "aitable.workflow_run", + CLIPath: "aitable workflow run", + PrimaryCLIPath: "aitable workflow run", + }, + Description: "立即执行 AI 表格自动化工作流,并返回异步执行提交结果。", + Interface: aitableMCPInterface("run_workflow"), + Selection: contract.SelectionSpec{ + AgentSummary: "立即执行已知 AI 表格自动化工作流,并获取 executionId。", + UseWhen: []string{"用户明确要求立即执行已知工作流,已确认真实 base-id、workflow-id、触发类型及可能产生的业务副作用;记录类触发器还需确认绑定的 table-id 和 1 到 5 个真实 record-id"}, + AvoidWhen: []string{"仅开启后续自动触发用 workflow enable;查询工作流定义用 workflow get;查询既有执行结果用 workflow history;返回 executionId 后应以 history 的 instanceId 核对,不要在结果不确定时直接重复执行"}, + Examples: []string{ + "dws aitable workflow run --base-id --workflow-id --table-id --record-ids ", + "dws aitable workflow run --base-id --workflow-id ", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"}, + {Name: "workflow-id", Property: "workflowId", Required: boolPtr(true), InterfaceType: "string"}, + {Name: "table-id", Property: "tableId", InterfaceType: "string", RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"}, + {Name: "record-ids", Property: "recordIds", InterfaceType: "array", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"}, + }, + }, + }) + + workflowHistoryCmd := NewLeafCommand(LeafSpec{ + Use: "history", + Short: "查询工作流执行历史", + Long: `分页查询指定 AI 表格工作流的执行历史。 +可按状态和 Unix 毫秒时间范围筛选;同时提供 --after-time 与 --before-time 时,前者必须小于后者。--page 从 0 开始,--size 默认 20、最大 100。 +返回 totalCount 与 list;run 返回的 executionId 可与历史项 instanceId 匹配。`, + Example: ` dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID + dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID --status failed --after-time 1786000000000 --before-time 1787000000000 --page 0 --size 50`, + Tool: "get_flow_record_list", + Safety: aitableSafetyRead(), + Validate: validateWorkflowHistoryFlags, + Flags: []LeafFlag{ + {Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}}, + {Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "flowId", Trim: true, Required: true}, + {Name: "status", Usage: "执行状态筛选;不传表示全部", Bind: "status", Trim: true, OmitEmpty: true, Enum: []string{"success", "failed", "running", "break", "untrigger"}}, + {Name: "after-time", Usage: "开始时间(Unix 毫秒)", Kind: LeafInt, Bind: "afterTime"}, + {Name: "before-time", Usage: "结束时间(Unix 毫秒)", Kind: LeafInt, Bind: "beforeTime"}, + {Name: "page", Usage: "页码,从 0 开始", Kind: LeafInt, Default: "0", Bind: "page"}, + {Name: "size", Usage: "每页条数 [1, 100]", Kind: LeafInt, Default: "20", Bind: "size"}, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, + Flags: []string{"after-time", "before-time"}, + Description: "同时提供 --after-time 与 --before-time 时,--after-time 必须小于 --before-time", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "workflow_history", + CanonicalPath: "aitable.workflow_history", + CLIPath: "aitable workflow history", + PrimaryCLIPath: "aitable workflow history", + }, + Description: "分页查询 AI 表格自动化工作流执行历史。", + Interface: aitableMCPInterface("get_flow_record_list"), + Selection: contract.SelectionSpec{ + AgentSummary: "按状态、时间和分页条件查询工作流执行历史。", + UseWhen: []string{"需要核对工作流是否执行、执行结果或定位 run 返回的 executionId 时;executionId 与历史项 instanceId 相同,running 为非终态"}, + AvoidWhen: []string{"查询工作流定义用 workflow get;列出工作流用 workflow list;立即发起执行用 workflow run"}, + Examples: []string{ + "dws aitable workflow history --base-id --workflow-id ", + "dws aitable workflow history --base-id --workflow-id --status failed --page 0 --size 50", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"}, + {Name: "workflow-id", Property: "flowId", Required: boolPtr(true), InterfaceType: "string"}, + {Name: "status", Property: "status", InterfaceType: "string", Enum: []string{"success", "failed", "running", "break", "untrigger"}}, + {Name: "after-time", Property: "afterTime", InterfaceType: "number"}, + {Name: "before-time", Property: "beforeTime", InterfaceType: "number"}, + {Name: "page", Property: "page", InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, + }, + }, + }) + // ── dashboard: 仪表盘管理 ──────────────────────────────────── dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE} @@ -7424,6 +7599,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 workflowCmd.AddCommand( workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd, workflowEnableCmd, workflowDisableCmd, + workflowRunCmd, workflowHistoryCmd, workflowGetCmd, workflowListCmd, ) diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index 3546fb83..8d304cc0 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -40,26 +41,23 @@ func (*aitableWorkflowCaller) JQ() string { return "" } func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) { t.Helper() - previousDeps := deps - previousArgs := os.Args - t.Cleanup(func() { - deps = previousDeps - os.Args = previousArgs - }) + testseam.Protect(t, &os.Args) caller := &aitableWorkflowCaller{} - InitDeps(caller) + InitDepsForTest(t, caller) deps.Out.w = io.Discard os.Args = append([]string{"dws", "aitable", "workflow"}, args...) cmd := newAitableCommand() cmd.PersistentFlags().String("format", "json", "output format") + cmd.PersistentFlags().Bool("yes", false, "skip confirmation") cmd.SilenceErrors = true cmd.SilenceUsage = true cmd.SetArgs(append([]string{"workflow"}, args...)) - if stdin != nil { - cmd.SetIn(stdin) + if stdin == nil { + stdin = strings.NewReader("") } + cmd.SetIn(stdin) return caller, cmd.Execute() } @@ -197,3 +195,135 @@ func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) { }) } } + +func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, + "run", + "--base-id", "base-run", + "--workflow-id", "workflow-run", + "--table-id", "table-run", + "--record-ids", "record-1,record-2", + "--yes", + ) + if err != nil { + t.Fatalf("workflow run returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != "aitable" || call.toolName != "run_workflow" { + t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName) + } + wantArgs := map[string]any{ + "baseId": "base-run", + "workflowId": "workflow-run", + "tableId": "table-run", + "recordIds": []string{"record-1", "record-2"}, + } + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, + "run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes", + ) + if err != nil { + t.Fatalf("scheduled workflow run returned error: %v", err) + } + wantArgs := map[string]any{ + "baseId": "base-scheduled", + "workflowId": "workflow-scheduled", + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) { + t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs) + } +} + +func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"}, + {name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"}, + {name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"}, + {name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"}, + {name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, tc.args...) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid run reached MCP: %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, + "history", + "--base-id", "base-history", + "--workflow-id", "workflow-history", + "--status", "failed", + "--after-time", "1786000000000", + "--before-time", "1787000000000", + "--page", "2", + "--size", "50", + ) + if err != nil { + t.Fatalf("workflow history returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != "aitable" || call.toolName != "get_flow_record_list" { + t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName) + } + wantArgs := map[string]any{ + "baseId": "base-history", + "flowId": "workflow-history", + "status": "failed", + "afterTime": 1786000000000, + "beforeTime": 1787000000000, + "page": 2, + "size": 50, + } + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"}, + {name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"}, + {name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"}, + {name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"}, + {name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"}, + {name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid history query reached MCP: %#v", caller.calls) + } + }) + } +} diff --git a/skills/mono/references/products/aitable.md b/skills/mono/references/products/aitable.md index 53e80a9d..d6792aee 100644 --- a/skills/mono/references/products/aitable.md +++ b/skills/mono/references/products/aitable.md @@ -112,8 +112,10 @@ | `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) | | `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` | | `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP | +| `workflow run` | 立即执行工作流(需确认) | `--base-id` `--workflow-id`;记录触发另需 `--table-id` `--record-ids` | 返回 `executionId`;不确定时先用 history 核对,避免重复执行 | +| `workflow history` | 查询工作流执行历史 | `--base-id` `--workflow-id` | 支持 status、Unix 毫秒时间范围和 page/size;`instanceId` 对应 run 的 `executionId` | -> 当前支持创建、更新、查询和启停;删除、运行历史及手动触发仍未开放。 +> 当前支持创建、更新、查询、启停、手动执行和执行历史;删除工作流仍未开放。 ### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md) diff --git a/skills/mono/references/products/aitable/aitable-workflow.md b/skills/mono/references/products/aitable/aitable-workflow.md index 49af4100..af79f179 100644 --- a/skills/mono/references/products/aitable/aitable-workflow.md +++ b/skills/mono/references/products/aitable/aitable-workflow.md @@ -1,7 +1,7 @@ # workflow — 自动化工作流管理 -创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。 -适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。 +创建 / 更新 / 启停 / 手动执行 / 查询执行历史 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。 +适用场景:用户要求创建自动化、修改流程、停掉或恢复流程、立即执行流程、核对执行结果或查询已有流程。 ## 命令一览 @@ -14,6 +14,8 @@ | `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) | | `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) | | `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) | +| `workflow run` | 立即执行指定工作流(会产生真实副作用,需确认) | +| `workflow history` | 按状态、时间和分页条件查询工作流执行历史 | > `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 @@ -104,6 +106,29 @@ dws aitable workflow update \ update 返回结构与 create 相同,并会对瞬态错误重试。成功时 `data.flowId` 应与传入的工作流 ID 一致;随后用 `workflow get/list` 验证发布结果和运行状态。 +### workflow run — 立即执行工作流 + +```bash +# 记录类触发器 +dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID \ + --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2 + +# 定时触发器不传 table-id / record-ids +dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID +``` + +记录类触发器必须同时传 `--table-id` 和 `--record-ids`;记录 ID 限 1–5 个且不可重复。`run` 会启动真实异步执行并产生工作流配置的副作用,必须先取得用户确认。返回的 `executionId` 可与 `workflow history` 项目的 `instanceId` 匹配;网络结果不确定时先查历史,不要直接重复执行。 + +### workflow history — 查询执行历史 + +```bash +dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID \ + --status failed --after-time 1786000000000 --before-time 1787000000000 \ + --page 0 --size 50 +``` + +`--status` 接受 `success` / `failed` / `running` / `break` / `untrigger`;时间参数是 Unix 毫秒且 after 必须小于 before;`--page` 从 0 开始,`--size` 默认 20、最大 100。返回 `totalCount` 与 `list`,其中 `running` 为非终态。 + ### workflow list — 列出工作流 ```bash @@ -211,9 +236,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes - | 列出工作流 | ✅ | | 看工作流详情(含 flowSchema) | ✅ | | 启用/禁用 | ✅ | +| 查看运行历史/执行日志 | ✅ `workflow history` | +| 手动触发/单次运行 | ✅ `workflow run`(需确认) | | 删除工作流 | ❌ 暂未开放 | -| 查看运行历史/执行日志 | ❌ 暂未开放 | -| 手动触发/单次运行 | ❌ 暂未开放 | ## 错误码速查 @@ -282,4 +307,5 @@ done - create 不自动重试;update 仅对网络、5xx、`retryable:true` 等瞬态错误自动重试。 - enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。 - `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。 -- 删除、运行历史和手动触发当前仍未开放。 +- `workflow run` 不自动重试;结果不确定时用 `workflow history` 按 executionId / instanceId 核对。 +- 删除工作流当前仍未开放。 diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md index 5f210a52..6c1026b5 100644 --- a/skills/multi/dingtalk-aitable/references/aitable.md +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -141,6 +141,8 @@ Flags: | `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) | | `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` | | `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP | +| `workflow run` | 立即执行工作流(需确认) | `--base-id` `--workflow-id`;记录触发另需 `--table-id` `--record-ids` | 返回 `executionId`;不确定时先用 history 核对,避免重复执行 | +| `workflow history` | 查询工作流执行历史 | `--base-id` `--workflow-id` | 支持 status、Unix 毫秒时间范围和 page/size;`instanceId` 对应 run 的 `executionId` | > 创建/更新的 `--dsl` 使用钉钉 AI 表格 `workflow-dsl/v1`;完整格式和最小 Demo 见 [aitable-workflow.md](./aitable/aitable-workflow.md)。删除工作流暂未开放。 diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md index eb3640f1..fe3edc37 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md @@ -1,7 +1,7 @@ # workflow — 自动化工作流管理 -创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。 -适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。 +创建 / 更新 / 启停 / 手动执行 / 查询执行历史 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。 +适用场景:用户要求创建自动化、修改流程、停掉或恢复流程、立即执行流程、核对执行结果或查询已有流程。 ## 命令一览 @@ -14,6 +14,8 @@ | `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) | | `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) | | `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) | +| `workflow run` | 立即执行指定工作流(会产生真实副作用,需确认) | +| `workflow history` | 按状态、时间和分页条件查询工作流执行历史 | > `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 @@ -155,6 +157,46 @@ dws aitable workflow update \ 返回结构与 create 相同,成功时 `flowId` 应为目标工作流。update 使用 AI 表格瞬态错误重试;最终仍必须检查 `data.valid` 和 `issues`,并用 `workflow get/list` 验证发布结果与运行状态。 +### workflow run — 立即执行工作流 + +```bash +# 记录类触发器 +dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID \ + --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2 + +# 定时触发器不传 table-id / record-ids +dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID +``` + +| flag | 必填 | 说明 | +|------|------|------| +| `--base-id` | 是 | 所属 Base ID | +| `--workflow-id` | 是 | 目标工作流 ID | +| `--table-id` | 条件必填 | 记录类触发器绑定的数据表;必须与触发器配置一致 | +| `--record-ids` | 条件必填 | 记录类触发器的记录 ID,1–5 个、逗号分隔且不可重复 | + +`run` 启动真实异步执行,工作流中的发消息、写记录等动作会实际发生;执行前必须取得用户确认。返回项中的 `executionId` 是本次执行标识,可与 `workflow history` 项目的 `instanceId` 匹配。网络结果不确定时不要直接重复执行,先按该标识查询历史。 + +### workflow history — 查询执行历史 + +```bash +dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID \ + --status failed --after-time 1786000000000 --before-time 1787000000000 \ + --page 0 --size 50 +``` + +| flag | 说明 | +|------|------| +| `--base-id` | 必填 | +| `--workflow-id` | 必填;CLI 会映射为 MCP 的 `flowId` | +| `--status` | 可选:`success` / `failed` / `running` / `break` / `untrigger` | +| `--after-time` | 可选,Unix 毫秒开始时间 | +| `--before-time` | 可选,Unix 毫秒结束时间;与 after-time 同传时必须更大 | +| `--page` | 可选,从 0 开始,默认 0 | +| `--size` | 可选,默认 20,范围 `[1, 100]` | + +返回 `totalCount` 和 `list`。`running` 是非终态;`success`、`failed`、`break`、`untrigger` 是终态。 + ### workflow list — 列出工作流 ```bash @@ -262,9 +304,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes - | 列出工作流 | ✅ | | 看工作流详情(含 flowSchema) | ✅ | | 启用/禁用 | ✅ | +| 查看运行历史/执行日志 | ✅ `workflow history` | +| 手动触发/单次运行 | ✅ `workflow run`(需确认) | | 删除工作流 | ❌ 暂未开放 | -| 查看运行历史/执行日志 | ❌ 暂未开放 | -| 手动触发/单次运行 | ❌ 暂未开放 | ## 错误码速查 @@ -338,4 +380,5 @@ done - create 不自动重试;update 仅对网络/5xx/`retryable:true` 瞬态错误自动重试。 - enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。 - `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。 -- 删除、运行历史和手动触发当前仍未开放。 +- `workflow run` 不自动重试;结果不确定时用 `workflow history` 按 executionId / instanceId 核对。 +- 删除工作流当前仍未开放。 From 5c45bd57da1e8b7d0c2a960a64b2875c6f2925f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E7=8E=84?= Date: Mon, 10 Aug 2026 17:13:58 +0800 Subject: [PATCH 2/2] test: cover aitable workflow validation branches --- .../helpers/aitable_workflow_write_test.go | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index 8d304cc0..1feff897 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -249,6 +249,8 @@ func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *t want string }{ {name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"}, + {name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"}, + {name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"}, {name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"}, {name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"}, {name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"}, @@ -302,6 +304,27 @@ func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) { } } +func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, + "history", + "--base-id", "base-history", + "--workflow-id", "workflow-history", + "--after-time", "1786000000000", + ) + if err != nil { + t.Fatalf("workflow history returned error: %v", err) + } + wantArgs := map[string]any{ + "baseId": "base-history", + "flowId": "workflow-history", + "afterTime": 1786000000000, + "size": 20, + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) { + t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs) + } +} + func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) { tests := []struct { name string