From 8eeabd1419999b506ea8dbd6d3252ef060bc02d0 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 7 Aug 2026 10:45:59 +0800 Subject: [PATCH 01/10] chore: update reviewer pool --- .github/workflows/reviewer-router.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index 14cba345..af1b8692 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -30,10 +30,9 @@ jobs: const pullNumber = context.payload.pull_request.number; const eventHeadSha = context.payload.pull_request.head.sha; const reviewerPool = [ - 'sczheng189', - 'shangguanxuan633-lab', - 'audanye-sudo', 'wxianfeng', + 'typefield', + 'haofeng0705', ]; async function getReadyEventPull(phase) { From 201949aec18ebdaf405e5c0dbcfc780a6a46fa85 Mon Sep 17 00:00:00 2001 From: Dennis Date: Thu, 6 Aug 2026 22:00:35 +0800 Subject: [PATCH 02/10] feat(aitable): add verified shortcut workflows --- docs/shortcut-public-catalog.json | 837 ++++++++++++++++-- internal/app/schema_shortcut_contract_test.go | 6 +- internal/helpers/aitable.go | 265 ++++-- internal/helpers/aitable_core_test.go | 8 +- .../helpers/aitable_record_query_e2e_test.go | 173 ++++ internal/helpers/helpers.go | 16 +- internal/helpers/helpers_core_test.go | 6 +- internal/shortcut/aitable/aitable.go | 398 ++++----- .../shortcut/aitable/attachment_composite.go | 366 ++++++++ .../aitable/attachment_composite_e2e_test.go | 159 ++++ internal/shortcut/aitable/base_composite.go | 393 ++++++++ .../aitable/base_composite_e2e_test.go | 151 ++++ .../shortcut/aitable/composite_contract.go | 38 + internal/shortcut/aitable/composite_output.go | 71 ++ .../aitable/list_projection_e2e_test.go | 92 ++ internal/shortcut/aitable/record_batch.go | 403 +++++++++ .../shortcut/aitable/record_bulk_patch.go | 117 +++ internal/shortcut/aitable/record_query_all.go | 78 ++ .../shortcut/aitable/record_upsert_by_key.go | 341 +++++++ .../aitable/record_upsert_by_key_e2e_test.go | 460 ++++++++++ internal/shortcut/aitable/schema_contracts.go | 147 +++ .../shortcut/aitable/schema_contracts_test.go | 34 + internal/shortcut/aitable/table_copy.go | 247 ++++++ .../aitable/table_copy_bulk_patch_e2e_test.go | 154 ++++ internal/shortcut/aitable/url_resolve.go | 187 ++++ .../shortcut/aitable/url_resolve_e2e_test.go | 152 ++++ internal/shortcut/aitable/view_preset.go | 179 ++++ .../view_workflow_composite_e2e_test.go | 173 ++++ internal/shortcut/aitable/workflow_deploy.go | 243 +++++ internal/shortcut/aitabletarget/resolver.go | 438 +++++++++ .../shortcut/aitabletarget/resolver_test.go | 219 +++++ .../builtin/aitable_semantic_catalog_test.go | 92 ++ internal/shortcut/public_catalog_generated.go | 63 ++ internal/shortcut/runner.go | 26 +- internal/shortcut/semantic_catalog.go | 4 + .../shortcut/semantic_catalog_aitable.json | 112 +++ .../smart/aitable_resolver_e2e_test.go | 124 +++ internal/shortcut/smart/resolve_base.go | 53 +- internal/shortcut/smart/resolve_table.go | 66 +- pkg/paging/paging.go | 105 ++- pkg/paging/paging_test.go | 44 +- scripts/gen_shortcut_public_catalog.py | 1 + skills/mono/SKILL.md | 2 +- skills/multi/dingtalk-aitable/SKILL.md | 63 ++ 44 files changed, 6823 insertions(+), 483 deletions(-) create mode 100644 internal/helpers/aitable_record_query_e2e_test.go create mode 100644 internal/shortcut/aitable/attachment_composite.go create mode 100644 internal/shortcut/aitable/attachment_composite_e2e_test.go create mode 100644 internal/shortcut/aitable/base_composite.go create mode 100644 internal/shortcut/aitable/base_composite_e2e_test.go create mode 100644 internal/shortcut/aitable/composite_contract.go create mode 100644 internal/shortcut/aitable/composite_output.go create mode 100644 internal/shortcut/aitable/list_projection_e2e_test.go create mode 100644 internal/shortcut/aitable/record_batch.go create mode 100644 internal/shortcut/aitable/record_bulk_patch.go create mode 100644 internal/shortcut/aitable/record_query_all.go create mode 100644 internal/shortcut/aitable/record_upsert_by_key.go create mode 100644 internal/shortcut/aitable/record_upsert_by_key_e2e_test.go create mode 100644 internal/shortcut/aitable/schema_contracts.go create mode 100644 internal/shortcut/aitable/schema_contracts_test.go create mode 100644 internal/shortcut/aitable/table_copy.go create mode 100644 internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go create mode 100644 internal/shortcut/aitable/url_resolve.go create mode 100644 internal/shortcut/aitable/url_resolve_e2e_test.go create mode 100644 internal/shortcut/aitable/view_preset.go create mode 100644 internal/shortcut/aitable/view_workflow_composite_e2e_test.go create mode 100644 internal/shortcut/aitable/workflow_deploy.go create mode 100644 internal/shortcut/aitabletarget/resolver.go create mode 100644 internal/shortcut/aitabletarget/resolver_test.go create mode 100644 internal/shortcut/builtin/aitable_semantic_catalog_test.go create mode 100644 internal/shortcut/semantic_catalog_aitable.json create mode 100644 internal/shortcut/smart/aitable_resolver_e2e_test.go diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index f860209e..68bb8e2d 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,209 +1,926 @@ { - "generated_at": "2026-08-05T22:43:52.497190", - "count": 294, + "generated_at": "2026-08-06T21:08:26.697858", + "count": 357, "results": [ { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+advperm-disable", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+advperm-enable", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "开启 Base 高级权限总开关的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+attachment-put", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+attachment-remove", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+attachment-upload", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+base-bootstrap", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+base-copy", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+base-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+base-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+base-get-primary-doc-id", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+base-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+base-schema-snapshot", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+base-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+base-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+chart-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+chart-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取指定图表完整配置的一对一入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+chart-share-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取图表分享配置的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+chart-share-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一开启、关闭和设置图表分享类型。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+chart-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+chart-widgets-example", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+dashboard-arrange", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "触发服务端智能布局重排并保留统一写入确认。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+dashboard-config-example", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+dashboard-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+dashboard-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+dashboard-share-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取仪表盘分享配置的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+dashboard-share-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一开启、关闭和设置仪表盘分享类型。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+dashboard-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "更新仪表盘配置的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+export-data", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+field-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+field-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+field-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+find-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+form-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除表单视图的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+form-field-hide", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+form-field-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+form-field-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一更新表单字段必填状态与描述。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+form-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+form-share-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取当前视图分享表单配置的一对一入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+form-share-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一开启或关闭指定视图的分享表单。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+form-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一更新表单标题和描述。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+import-data", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+import-upload", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+list-tables", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+record-bulk-patch", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+record-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+record-history-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "按 recordId 查询变更历史的一对一只读入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+record-primary-doc-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+record-primary-doc-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取记录主键文档 nodeId 的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+record-query", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+record-query-empty", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+record-share-links", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+record-share-url", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+record-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+record-upsert", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+record-upsert-by-key", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+resolve-base", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+resolve-table", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+role-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一角色名称、成员与权限配置的创建输入。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+role-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除自定义角色的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+role-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取单个角色完整权限配置的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+role-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "列出 Base 全部自定义角色的一对一入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+role-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+section-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+section-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除目录文件夹的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+section-list-empty", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+section-list-nodes", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+section-move-node", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+section-rename", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "重命名目录文件夹的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+section-reorder", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按当前父目录语义调整文件夹展示顺序。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+table-copy", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+table-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+table-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+table-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+template-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "aitable", + "command": "+url-resolve", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-duplicate", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "复制现有视图配置并生成新视图的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", "service": "aitable", "command": "+view-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+view-get-frozen-cols", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取 Grid 视图冻结列数的一对一入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+view-get-lock", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取视图锁定状态的一对一入口。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "aitable", "command": "+view-get-row-height", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取视图行高像素值的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-lock", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-preset-apply", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-set-fill-color-rule", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-set-frozen-cols", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-set-row-height", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+view-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+workflow-deploy", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+workflow-disable", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+workflow-enable", + "risk": "write", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "启用指定自动化工作流的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+workflow-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "读取单个自动化工作流详情的一对一入口。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "aitable", + "command": "+workflow-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "分页列出 Base 自动化工作流的一对一入口。", + "availability": "available" }, { "suite": "read", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 1f1cdcc7..9da3c5ce 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 294 + publicShortcutCount = 357 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including hidden leaves such as minutes.shortcut_minutes_search. - schemaPublishedShortcutCount = 295 + schemaPublishedShortcutCount = 358 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 294 + publiclyDeliveredShortcutCount = 357 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 5344479c..1ff70119 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -5,12 +5,15 @@ import ( "encoding/json" "fmt" "io" + "math" "os" "strconv" "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging" "github.com/spf13/cobra" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -123,113 +126,191 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) { } // recordQueryFetchAll implements --all auto-pagination for record query. -// - pageLimit controls max pages (default 50, 0 = unlimited) -// - Mid-loop errors break gracefully, outputting already-fetched data -// - Merged result preserves last page's cursor so caller can resume +// It prints only a complete result. A page limit, empty/invalid response, +// transport failure, or cursor cycle returns a non-zero structured error whose +// details retain the incomplete records and retry cursor. func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error { - const pageDelayMs = 200 const serverID = "aitable" ctx := context.Background() - var allRecords []any - page := 0 - lastCursor := "" - - for { - page++ - - if page > 1 { - helperSleep(time.Duration(pageDelayMs) * time.Millisecond) - } - - fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page) - - result, err := deps.Caller.CallTool(ctx, serverID, "query_records", toolArgs) - if err != nil { - if page == 1 { - return WrapError(err) - } - fmt.Fprintf(os.Stderr, "[page %d] error, stopping pagination: %v\n", page, err) - break - } - - // Extract text content from tool result - var text string - for _, c := range result.Content { - if c.Type == "text" && c.Text != "" { - text = c.Text - break - } - } - if text == "" { - break - } - - var resp map[string]any - if err := json.Unmarshal([]byte(text), &resp); err != nil { - if page == 1 { - deps.Out.PrintRaw(text) - return nil - } - break - } - - // Extract records from response - var pageRecords []any - if data, ok := resp["data"].(map[string]any); ok { - if recs, ok := data["records"].([]any); ok { - pageRecords = recs - } - } else if recs, ok := resp["records"].([]any); ok { - pageRecords = recs - } - allRecords = append(allRecords, pageRecords...) - - // Extract cursor for next page (MCP returns "nextCursor" or "cursor") - cursor := "" - if data, ok := resp["data"].(map[string]any); ok { - if c, ok := data["nextCursor"].(string); ok && c != "" { - cursor = c - } else if c, ok := data["cursor"].(string); ok && c != "" { - cursor = c - } - } else if c, ok := resp["nextCursor"].(string); ok && c != "" { - cursor = c - } else if c, ok := resp["cursor"].(string); ok && c != "" { - cursor = c - } + requestArgs := make(map[string]any, len(toolArgs)) + for key, value := range toolArgs { + requestArgs[key] = value + } + initialCursor, _ := requestArgs["cursor"].(string) + result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) { if cursor == "" { - lastCursor = "" - break + delete(requestArgs, "cursor") + } else { + requestArgs["cursor"] = cursor } - - // Check page limit (0 = unlimited) - if pageLimit > 0 && page >= pageLimit { - lastCursor = cursor - fmt.Fprintf(os.Stderr, "[pagination] reached page limit (%d), stopping. Use --page-limit 0 to fetch all.\n", pageLimit) - break + toolResult, err := deps.Caller.CallTool(ctx, serverID, "query_records", requestArgs) + if err != nil { + return paging.Page{}, err } + if toolResult == nil { + return paging.Page{}, fmt.Errorf("query_records returned a nil result") + } + for _, content := range toolResult.Content { + if content.Type == "text" && strings.TrimSpace(content.Text) != "" { + return parseRecordQueryPage(content.Text) + } + } + return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content") + }, paging.Options{ + PageLimit: pageLimit, + InterPageDelay: paging.DefaultInterPageDelay, + InitialCursor: initialCursor, + }) - lastCursor = "" - toolArgs["cursor"] = cursor + if !result.Complete { + return recordQueryIncompleteError(result, pageLimit) } - fmt.Fprintf(os.Stderr, "[pagination] done: %d pages, %d total records\n", page, len(allRecords)) - - // Build merged output — preserve cursor & has_more for resume capability + fmt.Fprintf(os.Stderr, "[pagination] complete: %d pages, %d fetched records\n", result.Pages, len(result.Records)) mergedData := map[string]any{ - "records": allRecords, - "totalCount": len(allRecords), + "records": result.Records, + "fetchedCount": len(result.Records), + "hasMore": false, + "complete": true, + "pages": result.Pages, } - if lastCursor != "" { - mergedData["cursor"] = lastCursor - mergedData["hasMore"] = true - } else { - mergedData["hasMore"] = false + if result.TotalCount != nil { + mergedData["totalCount"] = *result.TotalCount + } + return deps.Out.PrintJSON(map[string]any{"data": mergedData}) +} + +func parseRecordQueryPage(text string) (paging.Page, error) { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + var response map[string]any + if err := decoder.Decode(&response); err != nil { + return paging.Page{}, fmt.Errorf("query_records returned invalid JSON: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return paging.Page{}, fmt.Errorf("query_records returned more than one JSON value") + } + return paging.Page{}, fmt.Errorf("query_records returned trailing invalid JSON: %w", err) + } + if response == nil { + return paging.Page{}, fmt.Errorf("query_records returned null instead of an object") } - return deps.Out.PrintJSON(map[string]any{"data": mergedData}) + payload := response + if rawData, exists := response["data"]; exists { + data, ok := rawData.(map[string]any) + if !ok { + return paging.Page{}, fmt.Errorf("query_records data must be an object, got %T", rawData) + } + payload = data + } + + rawRecords, exists := payload["records"] + if !exists { + return paging.Page{}, fmt.Errorf("query_records response is missing records") + } + records, ok := rawRecords.([]any) + if !ok { + return paging.Page{}, fmt.Errorf("query_records records must be an array, got %T", rawRecords) + } + for index, record := range records { + if _, ok := record.(map[string]any); !ok { + return paging.Page{}, fmt.Errorf("query_records records[%d] must be an object, got %T", index, record) + } + } + + nextCursor := firstNonEmptyString(payload, "nextCursor", "cursor") + if hasMore, ok := payload["hasMore"].(bool); ok && hasMore && nextCursor == "" { + return paging.Page{}, fmt.Errorf("query_records reported hasMore=true without a next cursor") + } + totalCount, err := parseOptionalNonNegativeInt(payload["totalCount"]) + if err != nil { + return paging.Page{}, fmt.Errorf("query_records totalCount: %w", err) + } + return paging.Page{Records: records, NextCursor: nextCursor, TotalCount: totalCount}, nil +} + +func firstNonEmptyString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func parseOptionalNonNegativeInt(value any) (*int, error) { + if value == nil { + return nil, nil + } + var parsed int64 + switch typed := value.(type) { + case json.Number: + integer, err := typed.Int64() + if err != nil { + return nil, fmt.Errorf("must be an integer, got %q", typed) + } + parsed = integer + case float64: + if math.Trunc(typed) != typed { + return nil, fmt.Errorf("must be an integer, got %v", typed) + } + parsed = int64(typed) + case int: + parsed = int64(typed) + case int64: + parsed = typed + default: + return nil, fmt.Errorf("must be a non-negative integer, got %T", value) + } + if parsed < 0 || int64(int(parsed)) != parsed { + return nil, fmt.Errorf("must fit a non-negative int, got %d", parsed) + } + result := int(parsed) + return &result, nil +} + +func recordQueryIncompleteError(result paging.Result, pageLimit int) error { + incomplete := map[string]any{ + "records": result.Records, + "fetchedCount": len(result.Records), + "pages": result.Pages, + "attempts": result.Attempts, + "complete": false, + "hasMore": result.HasMore, + "stopReason": result.StopReason, + } + if result.LastCursor != "" { + incomplete["cursor"] = result.LastCursor + } + if result.TotalCount != nil { + incomplete["totalCount"] = *result.TotalCount + } + + hint := "retry the command; the structured error details preserve fetched records and the retry cursor" + if result.StopReason == paging.StopPageLimit { + hint = "rerun with --page-limit 0 to require a complete result, or resume from details.cursor" + } + message := fmt.Sprintf("record pagination incomplete after %d successful page(s) and %d fetched record(s)", result.Pages, len(result.Records)) + return apperrors.NewAPI(message, + apperrors.WithOperation("aitable.query_records.all"), + apperrors.WithServerKey("aitable"), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("pagination"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(true), + apperrors.WithReason("pagination_"+string(result.StopReason)), + apperrors.WithHint(hint), + apperrors.WithDetails(map[string]any{ + "page_limit": pageLimit, + "incomplete_result": incomplete, + }), + apperrors.WithCause(result.Err), + ) } // ─── filters 格式校验 ────────────────────────────────────────────────────── diff --git a/internal/helpers/aitable_core_test.go b/internal/helpers/aitable_core_test.go index b8628ec9..66f97454 100644 --- a/internal/helpers/aitable_core_test.go +++ b/internal/helpers/aitable_core_test.go @@ -292,15 +292,15 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing t.Fatalf("update view top-level: %v", err) } - caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}} + caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}} out = installAitableDeps(t, caller) - if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") { + if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) { t.Fatalf("paginated records = %q, %v", out.String(), err) } caller = &aitableTestCaller{responses: []string{"not-json"}} out = installAitableDeps(t, caller) - if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") { - t.Fatalf("raw first page = %q, %v", out.String(), err) + if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 { + t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err) } caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}} installAitableDeps(t, caller) diff --git a/internal/helpers/aitable_record_query_e2e_test.go b/internal/helpers/aitable_record_query_e2e_test.go new file mode 100644 index 00000000..9c9c6634 --- /dev/null +++ b/internal/helpers/aitable_record_query_e2e_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package helpers + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "reflect" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type recordQueryE2EStep struct { + result *edition.ToolResult + err error +} + +type recordQueryE2ECaller struct { + steps []recordQueryE2EStep + calls []aitableTestCall +} + +func (c *recordQueryE2ECaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: cloned}) + index := len(c.calls) - 1 + if index >= len(c.steps) { + return nil, fmt.Errorf("unexpected tool call %d", index+1) + } + return c.steps[index].result, c.steps[index].err +} + +func (*recordQueryE2ECaller) Format() string { return "json" } +func (*recordQueryE2ECaller) DryRun() bool { return false } +func (*recordQueryE2ECaller) Fields() string { return "" } +func (*recordQueryE2ECaller) JQ() string { return "" } + +func runRecordQueryCLI(t *testing.T, caller *recordQueryE2ECaller, extraArgs ...string) (string, error) { + t.Helper() + testseam.Protect(t, &deps) + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + + InitDeps(caller) + out := &bytes.Buffer{} + deps.Out.w = out + deps.Out.errW = out + args := []string{"record", "query", "--base-id", "base-e2e", "--table-id", "table-e2e", "--all"} + args = append(args, extraArgs...) + os.Args = append([]string{"dws", "aitable"}, args...) + + command := newAitableCommand() + command.SilenceErrors = true + command.SilenceUsage = true + command.SetArgs(args) + err := command.Execute() + return out.String(), err +} + +func recordQueryTextStep(text string) recordQueryE2EStep { + return recordQueryE2EStep{result: textToolResult(text)} +} + +func TestCrossPlatformCoverageRecordQueryCLICompleteE2E(t *testing.T) { + caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{ + recordQueryTextStep(`{"data":{"records":[{"id":"r1"},{"id":"r2"}],"nextCursor":"cursor-2","totalCount":3}}`), + recordQueryTextStep(`{"data":{"records":[{"id":"r3"}],"nextCursor":"","totalCount":3}}`), + }} + out, err := runRecordQueryCLI(t, caller, "--page-limit", "0") + if err != nil { + t.Fatalf("record query CLI returned error: %v", err) + } + for _, want := range []string{`"complete": true`, `"fetchedCount": 3`, `"totalCount": 3`, `"id": "r3"`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("record query CLI output missing %s:\n%s", want, out) + } + } + if len(caller.calls) != 2 { + t.Fatalf("tool calls = %d, want 2", len(caller.calls)) + } + if got := caller.calls[0].args["cursor"]; got != nil { + t.Fatalf("first-page cursor = %#v, want absent", got) + } + if got := caller.calls[1].args["cursor"]; got != "cursor-2" { + t.Fatalf("second-page cursor = %#v, want cursor-2", got) + } +} + +func TestCrossPlatformCoverageRecordQueryCLIFailsClosedE2E(t *testing.T) { + tests := []struct { + name string + steps []recordQueryE2EStep + args []string + }{ + {name: "nil result", steps: []recordQueryE2EStep{{result: nil}}}, + {name: "empty content", steps: []recordQueryE2EStep{{result: &edition.ToolResult{}}}}, + {name: "empty text", steps: []recordQueryE2EStep{recordQueryTextStep("")}}, + {name: "invalid json", steps: []recordQueryE2EStep{recordQueryTextStep("{")}}, + {name: "null payload", steps: []recordQueryE2EStep{recordQueryTextStep("null")}}, + {name: "missing records", steps: []recordQueryE2EStep{recordQueryTextStep(`{"data":{"nextCursor":"c"}}`)}}, + {name: "records wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":{}}`)}}, + {name: "records null", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":null}`)}}, + {name: "record item wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":["bad"]}`)}}, + {name: "trailing json", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[]} {}`)}}, + {name: "has more without cursor", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"hasMore":true}`)}}, + {name: "invalid total", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"totalCount":"many"}`)}}, + {name: "first transport error", steps: []recordQueryE2EStep{{err: errors.New("offline")}}}, + { + name: "second page error", + steps: []recordQueryE2EStep{ + recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"retry-me"}`), + {err: errors.New("upstream reset")}, + }, + args: []string{"--page-limit", "0"}, + }, + { + name: "page limit", + steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"resume-me"}`)}, + args: []string{"--page-limit", "1"}, + }, + { + name: "cursor cycle", + steps: []recordQueryE2EStep{ + recordQueryTextStep(`{"records":[{"id":"r1"}],"nextCursor":"same"}`), + recordQueryTextStep(`{"records":[{"id":"r2"}],"nextCursor":"same"}`), + }, + args: []string{"--page-limit", "0"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &recordQueryE2ECaller{steps: test.steps} + out, err := runRecordQueryCLI(t, caller, test.args...) + if err == nil { + t.Fatalf("CLI treated %s as success; output=%s", test.name, out) + } + if out != "" { + t.Fatalf("CLI emitted success output for %s: %q", test.name, out) + } + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error type = %T, want *errors.Error", err) + } + if typed.Reason == "" || typed.Details["incomplete_result"] == nil { + t.Fatalf("error lacks recovery metadata: %#v", typed) + } + }) + } +} + +func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) { + caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{ + recordQueryTextStep(`{"records":[]}`), + }} + if out, err := runRecordQueryCLI(t, caller, "--cursor", "resume-from-here", "--page-limit", "0"); err != nil || out == "" { + t.Fatalf("resume query = %q, %v", out, err) + } + want := map[string]any{"baseId": "base-e2e", "tableId": "table-e2e", "cursor": "resume-from-here"} + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want) + } +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 9729a287..a366c0b6 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -210,8 +210,16 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul } return "", WrapError(err) } + if result == nil { + return "", &CLIError{ + Code: CodeMCPToolError, + Message: "MCP 工具返回 nil result,无法判断操作结果", + Suggestion: "不要把空回复当作成功;请重试并携带 operation/trace 信息排查服务端", + Operation: serverID + "/" + toolName, + } + } for _, c := range result.Content { - if c.Type == "text" && c.Text != "" { + if c.Type == "text" && strings.TrimSpace(c.Text) != "" { dumpRawToolResponse(serverID, toolName, c.Text) var errBody map[string]any if json.Unmarshal([]byte(c.Text), &errBody) == nil { @@ -243,6 +251,12 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul return c.Text, nil } } + // Some legacy tools intentionally use an empty text response as an + // acknowledgement. This low-level parser cannot know the business contract, + // so preserve that representation. Data-returning callers must validate the + // operation-specific shape (for example records/tables/valid) before they + // report success; write orchestrators use CallMCPWriteData, which treats an + // empty response as unknown unless an independent read-back proves the state. return "", nil } diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go index 6896fea3..19ee7b29 100644 --- a/internal/helpers/helpers_core_test.go +++ b/internal/helpers/helpers_core_test.go @@ -196,7 +196,11 @@ func TestCrossPlatformCoverageMCPReturnTextClassification(t *testing.T) { } caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "ignored"}, {Type: "text"}}} if got, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err != nil || got != "" { - t.Fatalf("empty text result = %q, %v", got, err) + t.Fatalf("low-level empty text representation = %q, %v", got, err) + } + caller.result = nil + if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil { + t.Fatal("nil result must fail") } caller.result = textToolResult("shortcut result") if got, err := CallMCPToolTextOnServer("server", "tool", nil); err != nil || got != "shortcut result" { diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index faecef3e..0077dda9 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -59,6 +59,51 @@ func parseJSONObject(flag, s string) (map[string]any, error) { return m, nil } +// resolveNamedList finds a list in a response envelope without conflating a +// missing/malformed collection with a legitimate empty array. Some AITable MCP +// versions use a direct collection key while others wrap it one level below +// data/result, so all reviewed shapes are accepted but at least one explicit +// JSON array must be present. +func resolveNamedList(operation string, data map[string]any, outerKeys, innerKeys []string) ([]any, error) { + if data == nil { + return nil, fmt.Errorf("%s response is nil and does not establish a list result", operation) + } + invalid := make([]string, 0) + for _, key := range outerKeys { + value, exists := data[key] + if !exists { + continue + } + if list, ok := value.([]any); ok { + return list, nil + } + envelope, ok := value.(map[string]any) + if !ok { + invalid = append(invalid, fmt.Sprintf("%s=%T", key, value)) + continue + } + foundInner := false + for _, innerKey := range innerKeys { + innerValue, innerExists := envelope[innerKey] + if !innerExists { + continue + } + foundInner = true + if list, ok := innerValue.([]any); ok { + return list, nil + } + invalid = append(invalid, fmt.Sprintf("%s.%s=%T", key, innerKey, innerValue)) + } + if !foundInner { + invalid = append(invalid, fmt.Sprintf("%s has no recognized collection", key)) + } + } + if len(invalid) > 0 { + return nil, fmt.Errorf("%s response has no valid list collection (%v)", operation, invalid) + } + return nil, fmt.Errorf("%s response is missing its list collection", operation) +} + // ───────────────────────────────────────────────────────────── // base: Base 管理(server: aitable) // ───────────────────────────────────────────────────────────── @@ -116,7 +161,10 @@ var BaseList = shortcut.Shortcut{ if err != nil { return err } - bases := baseListProject(data) + bases, err := baseListProject("list_bases", data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(bases), "bases": bases}) }, } @@ -124,15 +172,19 @@ var BaseList = shortcut.Shortcut{ // baseListProject reshapes list_bases / search_bases responses into a clean // {baseId, baseName} list — clean output projection. Both the list // container and the per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func baseListProject(data map[string]any) []map[string]any { - raw := baseListResolveList(data) +// candidate keys. An explicitly returned empty array is a successful empty +// result; a missing/malformed collection is a protocol error and must not be +// reported as count=0. +func baseListProject(operation string, data map[string]any) ([]map[string]any, error) { + raw, err := baseListResolveList(operation, data) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { + for index, item := range raw { m, ok := item.(map[string]any) if !ok { - continue + return nil, fmt.Errorf("%s response item %d must be an object, got %T", operation, index, item) } row := map[string]any{} if v, ok := baseListFirst(m, "baseId", "base_id", "id"); ok { @@ -141,36 +193,20 @@ func baseListProject(data map[string]any) []map[string]any { if v, ok := baseListFirst(m, "baseName", "base_name", "name", "title"); ok { row["baseName"] = v } - if len(row) > 0 { - out = append(out, row) + if _, ok := row["baseId"]; !ok { + return nil, fmt.Errorf("%s response item %d is missing baseId", operation, index) } + out = append(out, row) } - return out + return out, nil } // baseListResolveList locates the list payload inside the response, tolerating a // bare top-level array container or nesting one level under a common envelope. -func baseListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"bases", "result", "data", "list", "items", "records"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"bases", "list", "items", "result", "data", "records"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} +func baseListResolveList(operation string, data map[string]any) ([]any, error) { + return resolveNamedList(operation, data, + []string{"bases", "result", "data", "list", "items", "records"}, + []string{"bases", "list", "items", "result", "data", "records"}) } // baseListFirst returns the first present candidate key's value. @@ -230,7 +266,10 @@ var BaseSearch = shortcut.Shortcut{ if err != nil { return err } - bases := baseListProject(data) + bases, err := baseListProject("search_bases", data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(bases), "bases": bases}) }, } @@ -339,7 +378,7 @@ var BaseDelete = shortcut.Shortcut{ {Name: "base-id", Type: shortcut.FlagString, Desc: "待删除 Base ID", Required: true}, {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, }, - Tips: []string{`dws aitable +base-delete --base-id BASE_ID --yes`}, + Tips: []string{`dws aitable +base-delete --base-id BASE_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"baseId": rt.Str("base-id")} if rt.Changed("reason") { @@ -474,7 +513,7 @@ var TableDelete = shortcut.Shortcut{ {Name: "table-id", Type: shortcut.FlagString, Desc: "待删除 Table ID", Required: true}, {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, }, - Tips: []string{`dws aitable +table-delete --base-id B --table-id T --yes`}, + Tips: []string{`dws aitable +table-delete --base-id B --table-id T`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "baseId": rt.Str("base-id"), @@ -601,7 +640,7 @@ var FieldDelete = shortcut.Shortcut{ {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, {Name: "field-id", Type: shortcut.FlagString, Desc: "待删除字段 ID", Required: true}, }, - Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F --yes`}, + Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("delete_field", map[string]any{ "baseId": rt.Str("base-id"), @@ -704,26 +743,16 @@ var RecordUpdate = shortcut.Shortcut{ Service: "aitable", Command: "+record-update", Product: serverMain, - Description: "批量更新记录(每条含 recordId 与 cells),单次最多 100 条", - Intent: "当你要批量修改已有行的字段值(如把一批任务标为已完成,每条须带 recordId)时使用;会实际覆盖对应单元格,单次最多 100 条。", + Description: "批量更新记录,自动按 100 条分片并逐批读回验证", + Intent: "当你要批量修改已有行的字段值(每条须带 recordId)时使用;自动按 100 条分片,任一批失败或读回不一致即非零退出并给出 nextOffset。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, {Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,如 '[{\"recordId\":\"rec\",\"cells\":{...}}]'", Required: true}, }, - Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`}, - Execute: func(rt *shortcut.RuntimeContext) error { - recs, err := parseJSONAny("records", rt.Str("records")) - if err != nil { - return err - } - return rt.CallMCP("update_records", map[string]any{ - "baseId": rt.Str("base-id"), - "tableId": rt.Str("table-id"), - "records": recs, - }) - }, + Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`}, + Execute: executeRecordUpdateBatches, } // RecordDelete 删除行记录(delete_records)。 @@ -731,22 +760,16 @@ var RecordDelete = shortcut.Shortcut{ Service: "aitable", Command: "+record-delete", Product: serverMain, - Description: "批量删除记录(不可逆),单次最多 100 条", - Intent: "当你确认要批量删除若干行记录时使用;不可逆,按 recordId 列表删除,单次最多 100 条。", + Description: "批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在", + Intent: "当你确认要批量删除若干行记录时使用;自动按 100 条分片,只有每批全部 recordId 读回均不存在才判定成功,否则非零退出并给出 nextOffset。", Risk: shortcut.RiskHighWrite, Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, - {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔", Required: true}, - }, - Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2 --yes`}, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("delete_records", map[string]any{ - "baseId": rt.Str("base-id"), - "tableId": rt.Str("table-id"), - "recordIds": rt.StrSlice("record-ids"), - }) + {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔;最多 10000 个唯一 ID", Required: true}, }, + Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2`}, + Execute: executeRecordDeleteBatches, } // RecordQueryEmpty 查询空行(query_empty_records,server: aitable-helper)。 @@ -919,26 +942,16 @@ var RecordUpsert = shortcut.Shortcut{ Service: "aitable", Command: "+record-upsert", Product: serverHelper, - Description: "按 records 是否带 recordId 自动拆分 create / update", - Intent: "当你有一批数据、其中部分是新增部分是更新、不想自己区分时使用;按记录是否带 recordId 自动拆分为创建或更新并实际写入,单次最多 100 条。", + Description: "按 recordId 自动拆分 create/update,按 100 条分片并读回验证", + Intent: "当一批数据中部分新增、部分更新时使用;自动按 recordId 拆分并以 100 条为批次写入,创建必须返回新 recordId,全部批次都需读回验证。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, {Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,单次最多 100 条", Required: true}, }, - Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`}, - Execute: func(rt *shortcut.RuntimeContext) error { - recs, err := parseJSONAny("records", rt.Str("records")) - if err != nil { - return err - } - return rt.CallMCP("record_upsert", map[string]any{ - "baseId": rt.Str("base-id"), - "tableId": rt.Str("table-id"), - "records": recs, - }) - }, + Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`}, + Execute: executeRecordUpsertBatches, } // RecordPrimaryDocGet 查询记录主键文档(get_primary_doc,server: aitable-helper)。 @@ -1047,7 +1060,10 @@ var TemplateSearch = shortcut.Shortcut{ if err != nil { return err } - templates := templateSearchProject(data) + templates, err := templateSearchProject(data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(templates), "templates": templates}) }, } @@ -1055,15 +1071,17 @@ var TemplateSearch = shortcut.Shortcut{ // templateSearchProject reshapes the raw search_templates response into a clean // {templateId, templateName} list — clean output projection. Both // the list container and the per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func templateSearchProject(data map[string]any) []map[string]any { - raw := templateSearchResolveList(data) +// candidate keys. Only an explicit array can establish a successful result. +func templateSearchProject(data map[string]any) ([]map[string]any, error) { + raw, err := templateSearchResolveList(data) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { + for index, item := range raw { m, ok := item.(map[string]any) if !ok { - continue + return nil, fmt.Errorf("search_templates response item %d must be an object, got %T", index, item) } row := map[string]any{} if v, ok := templateSearchFirst(m, "templateId", "template_id", "id"); ok { @@ -1072,37 +1090,21 @@ func templateSearchProject(data map[string]any) []map[string]any { if v, ok := templateSearchFirst(m, "templateName", "template_name", "name", "title"); ok { row["templateName"] = v } - if len(row) > 0 { - out = append(out, row) + if _, ok := row["templateId"]; !ok { + return nil, fmt.Errorf("search_templates response item %d is missing templateId", index) } + out = append(out, row) } - return out + return out, nil } // templateSearchResolveList locates the list payload inside the response, // tolerating a bare top-level array container or nesting one level under a // common envelope key. -func templateSearchResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"templates", "result", "data", "list", "items"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"templates", "list", "items", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} +func templateSearchResolveList(data map[string]any) ([]any, error) { + return resolveNamedList("search_templates", data, + []string{"templates", "result", "data", "list", "items"}, + []string{"templates", "list", "items", "result", "data"}) } // templateSearchFirst returns the first present candidate key's value. @@ -1198,7 +1200,10 @@ var ViewGet = shortcut.Shortcut{ if err != nil { return err } - views := viewGetProject(data) + views, err := viewGetProject(data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(views), "views": views}) }, } @@ -1206,15 +1211,17 @@ var ViewGet = shortcut.Shortcut{ // viewGetProject reshapes the raw get_views response into a clean // {viewId, viewName, viewType} list — clean output projection. Both // the list container and the per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func viewGetProject(data map[string]any) []map[string]any { - raw := viewGetResolveList(data) +// candidate keys. Only an explicit array can establish a successful result. +func viewGetProject(data map[string]any) ([]map[string]any, error) { + raw, err := viewGetResolveList(data) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { + for index, item := range raw { m, ok := item.(map[string]any) if !ok { - continue + return nil, fmt.Errorf("get_views response item %d must be an object, got %T", index, item) } row := map[string]any{} if v, ok := viewGetFirst(m, "viewId", "view_id", "id"); ok { @@ -1226,37 +1233,21 @@ func viewGetProject(data map[string]any) []map[string]any { if v, ok := viewGetFirst(m, "viewType", "view_type", "type"); ok { row["viewType"] = v } - if len(row) > 0 { - out = append(out, row) + if _, ok := row["viewId"]; !ok { + return nil, fmt.Errorf("get_views response item %d is missing viewId", index) } + out = append(out, row) } - return out + return out, nil } // viewGetResolveList locates the list payload inside the response, tolerating a // bare top-level array container or nesting one level under a common envelope // key. -func viewGetResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"views", "result", "data", "list", "items"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"views", "list", "items", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} +func viewGetResolveList(data map[string]any) ([]any, error) { + return resolveNamedList("get_views", data, + []string{"views", "result", "data", "list", "items"}, + []string{"views", "list", "items", "result", "data"}) } // viewGetFirst returns the first present candidate key's value. @@ -1355,7 +1346,7 @@ var ViewDelete = shortcut.Shortcut{ {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, {Name: "view-id", Type: shortcut.FlagString, Desc: "待删除 View ID", Required: true}, }, - Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V --yes`}, + Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("delete_view", map[string]any{ "baseId": rt.Str("base-id"), @@ -1668,23 +1659,28 @@ var FormList = shortcut.Shortcut{ if err != nil { return err } - forms := formListProject(data) + forms, err := formListProject(data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(forms), "forms": forms}) }, } // formListProject reshapes the raw list_form_views response into a clean form // view list ({viewId, viewName, viewType}) — clean output projection. -// Both the list container and the per-item field names are probed -// defensively across candidate keys, so an empty/unknown shape yields an empty -// list rather than a crash or fabricated data. -func formListProject(data map[string]any) []map[string]any { - raw := formListResolveList(data) +// Both the list container and the per-item field names are probed defensively +// across candidate keys. Only an explicit array can establish success. +func formListProject(data map[string]any) ([]map[string]any, error) { + raw, err := formListResolveList(data) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { + for index, item := range raw { m, ok := item.(map[string]any) if !ok { - continue + return nil, fmt.Errorf("list_form_views response item %d must be an object, got %T", index, item) } row := map[string]any{} if v, ok := formListFirst(m, "viewId", "view_id", "id"); ok { @@ -1696,36 +1692,20 @@ func formListProject(data map[string]any) []map[string]any { if v, ok := formListFirst(m, "viewType", "view_type", "type"); ok { row["viewType"] = v } - if len(row) > 0 { - out = append(out, row) + if _, ok := row["viewId"]; !ok { + return nil, fmt.Errorf("list_form_views response item %d is missing viewId", index) } + out = append(out, row) } - return out + return out, nil } // formListResolveList locates the list payload inside the response, tolerating a // bare top-level array container or nesting one level under a common envelope. -func formListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"forms", "views", "formViews", "result", "data", "list", "items"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"forms", "views", "formViews", "list", "items", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} +func formListResolveList(data map[string]any) ([]any, error) { + return resolveNamedList("list_form_views", data, + []string{"forms", "views", "formViews", "result", "data", "list", "items"}, + []string{"forms", "views", "formViews", "list", "items", "result", "data"}) } // formListFirst returns the first present candidate key's value. @@ -1751,7 +1731,7 @@ var FormDelete = shortcut.Shortcut{ {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, {Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true}, }, - Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V --yes`}, + Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("delete_form_view", map[string]any{ "baseId": rt.Str("base-id"), @@ -2013,7 +1993,7 @@ var WorkflowDisable = shortcut.Shortcut{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true}, }, - Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W --yes`}, + Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("disable_workflow", map[string]any{ "baseId": rt.Str("base-id"), @@ -2069,23 +2049,27 @@ var WorkflowList = shortcut.Shortcut{ if err != nil { return err } - workflows := workflowListProject(data) + workflows, err := workflowListProject(data) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(workflows), "workflows": workflows}) }, } // workflowListProject reshapes the raw list_workflows response into a clean // workflow list ({workflowId, name, status/enabled}) — output-projection -// clean output projection. Both the list container and the per-item field names are -// probed defensively across candidate keys, so an empty/unknown shape yields an -// empty list rather than a crash or fabricated data. -func workflowListProject(data map[string]any) []map[string]any { - raw := workflowListResolveList(data) +// clean output projection. Only an explicit array can establish success. +func workflowListProject(data map[string]any) ([]map[string]any, error) { + raw, err := workflowListResolveList(data) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { + for index, item := range raw { m, ok := item.(map[string]any) if !ok { - continue + return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item) } row := map[string]any{} if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok { @@ -2097,37 +2081,21 @@ func workflowListProject(data map[string]any) []map[string]any { if v, ok := workflowListFirst(m, "status", "enabled", "isEnabled", "state"); ok { row["status"] = v } - if len(row) > 0 { - out = append(out, row) + if _, ok := row["workflowId"]; !ok { + return nil, fmt.Errorf("list_workflows response item %d is missing workflowId", index) } + out = append(out, row) } - return out + return out, nil } // workflowListResolveList locates the list payload inside the response, // tolerating a bare top-level array container or nesting one level under a // common envelope. -func workflowListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"workflows", "result", "data", "list", "items"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"workflows", "list", "items", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} +func workflowListResolveList(data map[string]any) ([]any, error) { + return resolveNamedList("list_workflows", data, + []string{"workflows", "result", "data", "list", "items"}, + []string{"workflows", "list", "items", "result", "data"}) } // workflowListFirst returns the first present candidate key's value. @@ -2281,7 +2249,7 @@ var DashboardDelete = shortcut.Shortcut{ {Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true}, {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, }, - Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D --yes`}, + Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "baseId": rt.Str("base-id"), @@ -2512,7 +2480,7 @@ var ChartDelete = shortcut.Shortcut{ {Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true}, {Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"}, }, - Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C --yes`}, + Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C`}, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{ "baseId": rt.Str("base-id"), @@ -2744,7 +2712,7 @@ var AdvpermDisable = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, }, - Tips: []string{`dws aitable +advperm-disable --base-id B --yes`}, + Tips: []string{`dws aitable +advperm-disable --base-id B`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("set_advanced_permission", map[string]any{ "baseId": rt.Str("base-id"), @@ -2908,7 +2876,7 @@ var RoleDelete = shortcut.Shortcut{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID(数字 long 字符串)", Required: true}, }, - Tips: []string{`dws aitable +role-delete --base-id B --role-id R --yes`}, + Tips: []string{`dws aitable +role-delete --base-id B --role-id R`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("delete_role", map[string]any{ "baseId": rt.Str("base-id"), @@ -2986,7 +2954,7 @@ var SectionDelete = shortcut.Shortcut{ {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, {Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true}, }, - Tips: []string{`dws aitable +section-delete --base-id B --section-id S --yes`}, + Tips: []string{`dws aitable +section-delete --base-id B --section-id S`}, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("delete_section", map[string]any{ "baseId": rt.Str("base-id"), @@ -3131,7 +3099,7 @@ var SectionMoveNode = shortcut.Shortcut{ } func init() { - shortcut.Register( + shortcut.Register(withReviewedAITableShortcutContracts( BaseList, BaseSearch, BaseGet, @@ -3139,7 +3107,10 @@ func init() { BaseUpdate, BaseDelete, BaseCopy, + BaseSchemaSnapshot, + BaseBootstrap, TableGet, + TableCopy, TableUpdate, TableDelete, FieldGet, @@ -3152,12 +3123,17 @@ func init() { RecordHistoryList, RecordShareURL, RecordUpsert, + RecordUpsertByKey, + RecordBulkPatch, RecordPrimaryDocGet, RecordPrimaryDocCreate, TemplateSearch, AttachmentUpload, + AttachmentPut, + AttachmentRemove, ViewGet, ViewUpdate, + ViewPresetApply, ViewDuplicate, ViewDelete, ViewGetLock, @@ -3179,6 +3155,7 @@ func init() { WorkflowDisable, WorkflowGet, WorkflowList, + WorkflowDeploy, DashboardConfigExample, DashboardGet, DashboardUpdate, @@ -3209,5 +3186,6 @@ func init() { SectionListEmpty, SectionListNodes, SectionMoveNode, - ) + URLResolve, + )...) } diff --git a/internal/shortcut/aitable/attachment_composite.go b/internal/shortcut/aitable/attachment_composite.go new file mode 100644 index 00000000..219e907d --- /dev/null +++ b/internal/shortcut/aitable/attachment_composite.go @@ -0,0 +1,366 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const maxAttachmentFileSize = int64(100 * 1024 * 1024) + +var attachmentHTTPDo = http.DefaultClient.Do + +var AttachmentPut = shortcut.Shortcut{ + Service: "aitable", + Command: "+attachment-put", + Product: serverMain, + Description: "准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证", + Intent: "当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: aitableCompositeContract( + "+attachment-put", + "准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证", + "当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。", + "只申请上传凭证用 attachment upload;不要用 drive fileId;现有附件读回缺 fileToken 时无法安全 append,只能 replace 或停止", + `dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`, + ), + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true}, + {Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true}, + {Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "append 保留现有附件;replace 整体替换", Enum: []string{"append", "replace"}}, + {Name: "mime-type", Type: shortcut.FlagString, Desc: "覆盖自动推断的 MIME type(可选)"}, + }, + Tips: []string{`dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeAttachmentPut(rt) + }, +} + +var AttachmentRemove = shortcut.Shortcut{ + Service: "aitable", + Command: "+attachment-remove", + Product: serverMain, + Description: "从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证", + Intent: "当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。", + Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: aitableCompositeContract( + "+attachment-remove", + "从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证", + "当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。", + "读回仅有下载 URL 而没有剩余附件 fileToken 时,DWS 无法在覆盖式写接口上安全对齐逐项删除;此时只能 --clear-all 或停止", + `dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`, + ), + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true}, + {Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true}, + {Name: "remove-name", Type: shortcut.FlagString, Desc: "移除精确文件名的所有匹配项;与 --clear-all 二选一"}, + {Name: "clear-all", Type: shortcut.FlagBool, Desc: "清空该字段全部附件;与 --remove-name 二选一"}, + }, + Tips: []string{`dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeAttachmentRemove(rt) + }, +} + +func executeAttachmentPut(rt *shortcut.RuntimeContext) error { + file, info, mimeType, err := openAttachmentFile(rt.Str("file"), rt.Str("mime-type")) + if err != nil { + return err + } + defer file.Close() + baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id") + record, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID) + if err != nil { + return err + } + _ = record + desired := make([]any, 0, len(existing)+1) + if rt.Str("mode") == "append" { + for index, item := range existing { + token := attachmentToken(item) + if token == "" { + return apperrors.NewValidation(fmt.Sprintf("现有附件[%d]读回缺少 fileToken,覆盖式写接口无法安全 append;请用 --mode replace 或保留原数据后人工处理", index), + apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false)) + } + desired = append(desired, map[string]any{"fileToken": token}) + } + } + result := newCompositeResult("attachment_put") + result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "fileName": info.Name(), "size": info.Size(), "mode": rt.Str("mode")} + result.Plan = []compositeStep{ + {Index: 1, Name: "prepare attachment upload", Tool: "prepare_attachment_upload", Status: "planned"}, + {Index: 2, Name: "PUT file bytes", Tool: "HTTP PUT", Status: "planned"}, + {Index: 3, Name: "write and verify attachment cell", Tool: "update_records", Status: "planned"}, + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + prepareData, err := rt.CallMCPWriteData(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType}) + if err != nil { + result.Status = "unknown" + return compositeError(result, err, false) + } + uploadURL := findStringByKeys(prepareData, "uploadUrl") + fileToken := findStringByKeys(prepareData, "fileToken") + if uploadURL == "" || fileToken == "" { + result.Status = "unknown" + return compositeError(result, fmt.Errorf("prepare_attachment_upload response is missing uploadUrl or fileToken"), false) + } + if err := validateAttachmentUploadURL(uploadURL); err != nil { + result.Status = "unknown" + return compositeError(result, err, false) + } + request, err := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file) + if err != nil { + return compositeError(result, err, false) + } + request.Header.Set("Content-Type", mimeType) + request.ContentLength = info.Size() + response, err := attachmentHTTPDo(request) + if err != nil { + result.Status = "partial_success" + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken}) + return compositeError(result, fmt.Errorf("attachment HTTP PUT failed: %w", err), false) + } + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20)) + closeErr := response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 || closeErr != nil { + result.Status = "partial_success" + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken, "httpStatus": response.StatusCode}) + return compositeError(result, fmt.Errorf("attachment HTTP PUT returned status %d (close error: %v)", response.StatusCode, closeErr), false) + } + desired = append(desired, map[string]any{"fileToken": fileToken}) + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "HTTP PUT", "fileToken": fileToken, "fileName": info.Name(), "size": info.Size()}) + _, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{ + "baseId": baseID, "tableId": tableID, + "records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}}, + }) + _, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID) + if verifyErr == nil { + verifyErr = verifyAttachmentPut(readBack, len(desired), fileToken, info.Name(), info.Size()) + } + if verifyErr != nil { + result.Status = "partial_success" + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "update_records", "recordId": recordIDValue, "fieldId": fieldID}) + if writeErr != nil { + result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error()) + } + return compositeError(result, verifyErr, false) + } + result.CompletedCount = 1 + result.Verification = map[string]any{"status": "verified", "attachmentCount": len(readBack), "fileName": info.Name(), "size": info.Size()} + result.Result = map[string]any{"fileToken": fileToken, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType, "attachmentCount": len(readBack)} + if writeErr != nil { + result.Status = "recovered" + result.Warnings = append(result.Warnings, "record write response was an error, but the attachment cell was proven by read-back") + } + return rt.Output(result) +} + +func executeAttachmentRemove(rt *shortcut.RuntimeContext) error { + removeName := strings.TrimSpace(rt.Str("remove-name")) + clearAll := rt.Bool("clear-all") + if (removeName == "") == !clearAll { + return apperrors.NewValidation("必须且只能提供 --remove-name 或 --clear-all") + } + baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id") + _, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID) + if err != nil { + return err + } + desired := make([]any, 0, len(existing)) + removed := 0 + if clearAll { + removed = len(existing) + } else { + for index, item := range existing { + if attachmentName(item) == removeName { + removed++ + continue + } + token := attachmentToken(item) + if token == "" { + return apperrors.NewValidation(fmt.Sprintf("剩余附件[%d]读回缺少 fileToken,覆盖式写接口无法安全保留后删除;服务端当前不支持按附件增量删除", index), + apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false)) + } + desired = append(desired, map[string]any{"fileToken": token}) + } + } + result := newCompositeResult("attachment_remove") + result.RequestedCount = removed + result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "removeName": removeName, "clearAll": clearAll} + if removed == 0 { + result.Status = "unchanged" + result.Executed = false + result.Verification = map[string]any{"status": "verified", "attachmentCount": len(existing), "removedCount": 0} + return rt.Output(result) + } + result.Plan = []compositeStep{{Index: 1, Name: "replace attachment cell without selected items", Tool: "update_records", Status: "planned", Count: removed}} + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + _, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{ + "baseId": baseID, "tableId": tableID, + "records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}}, + }) + _, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID) + if verifyErr == nil && len(readBack) != len(desired) { + verifyErr = fmt.Errorf("attachment read-back count is %d, want %d", len(readBack), len(desired)) + } + if verifyErr == nil && removeName != "" { + for _, item := range readBack { + if attachmentName(item) == removeName { + verifyErr = fmt.Errorf("attachment %q is still present after removal", removeName) + break + } + } + } + if verifyErr != nil { + result.Status = "unknown" + if writeErr != nil { + result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error()) + } + return compositeError(result, verifyErr, true) + } + result.CompletedCount = removed + result.Verification = map[string]any{"status": "verified", "removedCount": removed, "attachmentCount": len(readBack)} + result.Result = map[string]any{"removedCount": removed, "attachmentCount": len(readBack)} + if writeErr != nil { + result.Status = "recovered" + result.Warnings = append(result.Warnings, "record write response was an error, but removal was proven by read-back") + } + return rt.Output(result) +} + +func openAttachmentFile(path, overrideMIME string) (*os.File, os.FileInfo, string, error) { + file, err := os.Open(path) + if err != nil { + return nil, nil, "", apperrors.NewValidation("无法打开 --file: " + err.Error()) + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxAttachmentFileSize { + file.Close() + return nil, nil, "", apperrors.NewValidation(fmt.Sprintf("--file 必须是 1..%d 字节的普通文件", maxAttachmentFileSize)) + } + mimeType := strings.TrimSpace(overrideMIME) + if mimeType == "" { + mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(info.Name()))) + } + if mimeType == "" { + header := make([]byte, 512) + read, _ := file.Read(header) + mimeType = http.DetectContentType(header[:read]) + _, _ = file.Seek(0, io.SeekStart) + } + return file, info, mimeType, nil +} + +func validateAttachmentUploadURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return fmt.Errorf("prepare_attachment_upload returned an invalid uploadUrl") + } + if parsed.Scheme == "http" { + host := parsed.Hostname() + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return fmt.Errorf("plaintext attachment uploadUrl is allowed only for loopback test servers") + } + } + return nil +} + +func readAttachmentCell(rt *shortcut.RuntimeContext, baseID, tableID, recordIDValue, fieldID string) (map[string]any, []map[string]any, error) { + records, err := queryRecordsByIDs(rt, baseID, tableID, []string{recordIDValue}) + if err != nil { + return nil, nil, err + } + if len(records) != 1 || recordID(records[0]) != recordIDValue { + return nil, nil, fmt.Errorf("attachment read-back returned %d exact records for %s, want 1", len(records), recordIDValue) + } + cells, ok := records[0]["cells"].(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("record %s is missing cells", recordIDValue) + } + raw, exists := cells[fieldID] + if !exists || raw == nil { + return records[0], []map[string]any{}, nil + } + list, ok := raw.([]any) + if !ok { + return nil, nil, fmt.Errorf("attachment field %s is not an array", fieldID) + } + out := make([]map[string]any, 0, len(list)) + for index, item := range list { + object, ok := item.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("attachment field %s item %d is not an object", fieldID, index) + } + out = append(out, object) + } + return records[0], out, nil +} + +func attachmentToken(item map[string]any) string { + return stringValue(item, "fileToken", "file_token") +} + +func attachmentName(item map[string]any) string { + return stringValue(item, "fileName", "filename", "name") +} + +func verifyAttachmentPut(actual []map[string]any, expectedCount int, token, name string, size int64) error { + if len(actual) != expectedCount { + return fmt.Errorf("attachment read-back count is %d, want %d", len(actual), expectedCount) + } + for _, item := range actual { + if attachmentToken(item) == token { + return nil + } + itemSize, sizeOK := numericInt64(item["size"]) + if attachmentName(item) == name && sizeOK && itemSize == size { + return nil + } + } + return fmt.Errorf("attachment read-back does not identify uploaded token or file %s/%d", name, size) +} + +func numericInt64(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + return int64(typed), typed == float64(int64(typed)) + case int: + return int64(typed), true + case int64: + return typed, true + } + return 0, false +} diff --git a/internal/shortcut/aitable/attachment_composite_e2e_test.go b/internal/shortcut/aitable/attachment_composite_e2e_test.go new file mode 100644 index 00000000..24163c6e --- /dev/null +++ b/internal/shortcut/aitable/attachment_composite_e2e_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func writeAttachmentFixture(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func attachmentRecordJSON(t *testing.T, fieldID string, attachments []any) string { + t.Helper() + return mustJSONText(t, map[string]any{"records": []any{map[string]any{ + "recordId": "record", "cells": map[string]any{fieldID: attachments}, + }}}) +} + +func TestCrossPlatformCoverageAttachmentPutPerformsHTTPAndReadBackE2E(t *testing.T) { + filePath := writeAttachmentFixture(t, "actual attachment bytes") + fileName := filepath.Base(filePath) + uploaded := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + if request.Method != http.MethodPut || request.Header.Get("Content-Type") != "text/plain" || string(body) != "actual attachment bytes" { + t.Errorf("upload request = method:%s type:%s body:%q", request.Method, request.Header.Get("Content-Type"), body) + http.Error(w, "bad upload", http.StatusBadRequest) + return + } + uploaded = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft1"})}, + {text: ""}, + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": fileName, "size": len("actual attachment bytes")}})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--mime-type", "text/plain", "--yes") + if err != nil || !uploaded { + t.Fatalf("attachment put = output:%q err:%v uploaded:%v", out, err, uploaded) + } + for _, want := range []string{`"status": "recovered"`, `"fileToken": "ft1"`, `"attachmentCount": 1`, `"status": "verified"`} { + if !strings.Contains(out, want) { + t.Fatalf("attachment output missing %s: %s", want, out) + } + } + if len(caller.calls) != 4 || caller.calls[1].tool != "prepare_attachment_upload" || caller.calls[2].tool != "update_records" { + t.Fatalf("attachment calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *testing.T) { + filePath := writeAttachmentFixture(t, "bytes") + t.Run("append stops before upload when existing tokens unavailable", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "old.pdf", "url": "https://download"}})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes") + if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") { + t.Fatalf("unsafe append = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("HTTP failure leaves no record write", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) })) + defer server.Close() + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" || len(caller.calls) != 2 || !strings.Contains(err.Error(), "partial_success") { + t.Fatalf("HTTP failure = output:%q err:%v calls:%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Retryable { + t.Fatalf("partially uploaded attachment must not be blindly retryable: %#v", err) + } + }) + + t.Run("prepare response must carry both values", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, {text: `{"uploadUrl":"https://example.invalid"}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" || len(caller.calls) != 2 { + t.Fatalf("bad prepare = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) { + t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "a.pdf", "url": "https://download"}})}, + {text: ""}, + {text: attachmentRecordJSON(t, "field", []any{})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-remove", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes") + if err != nil || !strings.Contains(out, `"status": "recovered"`) || !strings.Contains(out, `"removedCount": 1`) { + t.Fatalf("clear attachments = output:%q err:%v", out, err) + } + }) + + t.Run("selective removal preserves tokenized remainder", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"fileToken": "remove-token", "filename": "remove.pdf"}, + map[string]any{"fileToken": "keep-token", "filename": "keep.pdf"}, + })}, + {text: `{"updatedCount":1}`}, + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "keep.pdf", "size": 10}})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-remove", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes") + if err != nil || !strings.Contains(out, `"removedCount": 1`) { + t.Fatalf("selective remove = output:%q err:%v", out, err) + } + written := caller.calls[1].args["records"].([]any)[0].(map[string]any)["cells"].(map[string]any)["field"].([]any) + if len(written) != 1 || written[0].(map[string]any)["fileToken"] != "keep-token" { + t.Fatalf("selective desired attachments = %#v", written) + } + }) +} + +func TestCrossPlatformCoverageAttachmentRemoveExplainsTokenBoundaryE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"filename": "remove.pdf", "url": "https://one"}, + map[string]any{"filename": "keep.pdf", "url": "https://two"}, + })}}} + out, err := runAITableCompositeCLI(t, caller, "+attachment-remove", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes") + if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") || !strings.Contains(err.Error(), "增量删除") { + t.Fatalf("token boundary = output:%q err:%v calls:%#v", out, err, caller.calls) + } +} diff --git a/internal/shortcut/aitable/base_composite.go b/internal/shortcut/aitable/base_composite.go new file mode 100644 index 00000000..73971707 --- /dev/null +++ b/internal/shortcut/aitable/base_composite.go @@ -0,0 +1,393 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var BaseSchemaSnapshot = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-schema-snapshot", + Product: serverMain, + Description: "读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应", + Intent: "当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: aitableCompositeContract( + "+base-schema-snapshot", + "读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应", + "当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。", + "需要记录数据用 record query;只看 Base 目录用 base get;快照不会创建或修改资源", + "dws aitable +base-schema-snapshot --base-id BASE_ID", + ), + Flags: []shortcut.Flag{{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}}, + Tips: []string{`dws aitable +base-schema-snapshot --base-id BASE_ID`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeBaseSchemaSnapshot(rt) + }, +} + +var BaseBootstrap = shortcut.Shortcut{ + Service: "aitable", + Command: "+base-bootstrap", + Product: serverMain, + Description: "一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用", + Intent: "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: aitableCompositeContract( + "+base-bootstrap", + "一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用", + "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。", + "已有 Base 只需新增一张表时用 table create;复制现有 Base 用 base copy;不要对失败请求盲目重试", + `dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`, + ), + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "新 Base 名称", Required: true}, + {Name: "folder-id", Type: shortcut.FlagString, Desc: "目标知识库文件夹 ID(可选)"}, + {Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID(可选)"}, + {Name: "tables", Type: shortcut.FlagString, Desc: `表结构 JSON 数组:[{'name':'任务','fields':[...]}]`, Required: true}, + }, + Tips: []string{`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeBaseBootstrap(rt) + }, +} + +type bootstrapTable struct { + Name string + Fields []any +} + +func parseBootstrapTables(raw string) ([]bootstrapTable, error) { + value, err := parseJSONAny("tables", raw) + if err != nil { + return nil, err + } + items, ok := value.([]any) + if !ok || len(items) == 0 { + return nil, apperrors.NewValidation("--tables 必须是非空 JSON 数组") + } + if len(items) > 100 { + return nil, apperrors.NewValidation("--tables 最多接受 100 张表") + } + seen := map[string]bool{} + out := make([]bootstrapTable, 0, len(items)) + for index, item := range items { + object, ok := item.(map[string]any) + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index)) + } + name := strings.TrimSpace(stringValue(object, "name")) + if name == "" || seen[name] { + return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].name 必须非空且不能重复", index)) + } + seen[name] = true + fields := []any{} + if rawFields, exists := object["fields"]; exists { + var fieldsOK bool + fields, fieldsOK = rawFields.([]any) + if !fieldsOK { + return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index)) + } + } + for fieldIndex, field := range fields { + object, ok := field.(map[string]any) + if !ok || strings.TrimSpace(stringValue(object, "fieldName", "name")) == "" || strings.TrimSpace(stringValue(object, "type")) == "" { + return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex)) + } + } + out = append(out, bootstrapTable{Name: name, Fields: fields}) + } + return out, nil +} + +func executeBaseSchemaSnapshot(rt *shortcut.RuntimeContext) error { + baseID := rt.Str("base-id") + base, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID}) + if err != nil { + return err + } + if !deepContainsString(base, baseID) { + return fmt.Errorf("get_base response does not identify requested baseId %s", baseID) + } + tables, found := findNamedObjectList(base, "tables", "tableList", "sheets") + if !found { + return fmt.Errorf("get_base response is missing the tables collection") + } + result := newCompositeResult("base_schema_snapshot") + result.RequestedCount = len(tables) + result.Resolved = map[string]any{"baseId": baseID} + tableSnapshots := make([]any, 0, len(tables)) + for index, table := range tables { + tableID := stringValue(table, "tableId", "sheetId", "id") + if tableID == "" { + return fmt.Errorf("get_base tables[%d] is missing tableId", index) + } + detail, err := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}}) + if err != nil || !deepContainsString(detail, tableID) { + if err == nil { + err = fmt.Errorf("get_tables response does not identify tableId %s", tableID) + } + return err + } + fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID}) + if err != nil { + return err + } + fields, found := findNamedObjectList(fieldsData, "fields", "fieldList") + if !found { + return fmt.Errorf("get_fields response for %s is missing the fields collection", tableID) + } + viewsData, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID}) + if err != nil { + return err + } + views, found := findNamedObjectList(viewsData, "views", "viewList") + if !found { + return fmt.Errorf("get_views response for %s is missing the views collection", tableID) + } + tableSnapshots = append(tableSnapshots, map[string]any{ + "tableId": tableID, "summary": table, "detail": detail, "fields": fields, "views": views, + }) + result.CompletedCount = index + 1 + } + result.Result = map[string]any{"base": base, "tables": tableSnapshots} + result.Verification = map[string]any{"status": "verified", "tableCount": len(tables)} + return rt.Output(result) +} + +func executeBaseBootstrap(rt *shortcut.RuntimeContext) error { + tables, err := parseBootstrapTables(rt.Str("tables")) + if err != nil { + return err + } + result := newCompositeResult("base_bootstrap") + result.RequestedCount = len(tables) + result.Plan = append(result.Plan, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "planned"}) + for index, table := range tables { + result.Plan = append(result.Plan, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "planned", Count: len(table.Fields), Arguments: map[string]any{"name": table.Name}}) + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + baseArgs := map[string]any{"baseName": rt.Str("name")} + if rt.Changed("folder-id") { + baseArgs["folderId"] = rt.Str("folder-id") + } + if rt.Changed("template-id") { + baseArgs["templateId"] = rt.Str("template-id") + } + baseData, err := rt.CallMCPWriteData(serverMain, "create_base", baseArgs) + if err != nil { + result.Status = "unknown" + result.FailedCount = len(tables) + result.Checkpoint = map[string]any{"nextStep": "resolve whether base was created before retrying"} + return compositeError(result, err, false) + } + baseID := findStringByKeys(baseData, "baseId") + if baseID == "" { + result.Status = "unknown" + result.Result = baseData + result.Checkpoint = map[string]any{"nextStep": "locate the created Base by exact name before retrying"} + return compositeError(result, fmt.Errorf("create_base response is missing baseId"), false) + } + result.Resolved = map[string]any{"baseId": baseID} + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_base", "baseId": baseID}) + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "completed", Result: baseData}) + baseRead, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID}) + if err != nil || !deepContainsString(baseRead, baseID) { + if err == nil { + err = fmt.Errorf("get_base does not identify created baseId %s", baseID) + } + result.Status = "partial_success" + result.Checkpoint = map[string]any{"nextTableIndex": 0, "baseId": baseID} + return compositeError(result, err, false) + } + + createdTables := make([]any, 0, len(tables)) + for index, spec := range tables { + initialEnd := minInt(15, len(spec.Fields)) + initialFields := spec.Fields[:initialEnd] + createData, createErr := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{ + "baseId": baseID, "tableName": spec.Name, "fields": initialFields, + }) + tableID := findStringByKeys(createData, "tableId", "sheetId") + if createErr != nil || tableID == "" { + if createErr == nil { + createErr = fmt.Errorf("create_table response for %q is missing tableId", spec.Name) + } + result.Status = "partial_success" + result.CompletedCount = index + result.FailedCount = len(tables) - index + result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID} + return compositeError(result, createErr, false) + } + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name}) + for offset := initialEnd; offset < len(spec.Fields); offset += 15 { + end := minInt(offset+15, len(spec.Fields)) + _, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{ + "baseId": baseID, "tableId": tableID, "fields": spec.Fields[offset:end], + }) + if fieldErr != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; checking final field state: %v", offset, fieldErr)) + } + } + detail, verifyErr := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}}) + if verifyErr != nil || !deepContainsString(detail, tableID) { + if verifyErr == nil { + verifyErr = fmt.Errorf("get_tables does not identify created tableId %s", tableID) + } + result.Status = "partial_success" + result.CompletedCount = index + result.FailedCount = len(tables) - index + result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "verify table and fields"} + return compositeError(result, verifyErr, false) + } + fieldsData, verifyErr := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID}) + fields, found := findNamedObjectList(fieldsData, "fields", "fieldList") + if verifyErr != nil || !found || !containsAllFieldNames(fields, spec.Fields) { + if verifyErr == nil { + verifyErr = fmt.Errorf("field read-back for table %s does not contain the declared field set", tableID) + } + result.Status = "partial_success" + result.CompletedCount = index + result.FailedCount = len(tables) - index + result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "repair fields"} + return compositeError(result, verifyErr, false) + } + result.CompletedCount = index + 1 + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "completed", Count: len(spec.Fields), Result: map[string]any{"tableId": tableID, "fieldCount": len(fields)}}) + createdTables = append(createdTables, map[string]any{"tableId": tableID, "name": spec.Name, "fieldCount": len(fields)}) + } + result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableCount": len(createdTables)} + result.Result = map[string]any{"baseId": baseID, "tables": createdTables} + return rt.Output(result) +} + +func findNamedObjectList(value any, names ...string) ([]map[string]any, bool) { + nameSet := map[string]bool{} + for _, name := range names { + nameSet[strings.ToLower(name)] = true + } + var walk func(any) ([]map[string]any, bool) + walk = func(current any) ([]map[string]any, bool) { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + for key, child := range object { + if nameSet[strings.ToLower(key)] { + items, ok := child.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + entry, ok := item.(map[string]any) + if !ok { + return nil, false + } + out = append(out, entry) + } + return out, true + } + } + for _, child := range object { + if found, ok := walk(child); ok { + return found, true + } + } + return nil, false + } + return walk(value) +} + +func stringValue(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok { + return value + } + } + return "" +} + +func findStringByKeys(value any, keys ...string) string { + keySet := map[string]bool{} + for _, key := range keys { + keySet[strings.ToLower(key)] = true + } + var walk func(any) string + walk = func(current any) string { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if keySet[strings.ToLower(key)] { + if text, ok := child.(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + } + for _, child := range typed { + if found := walk(child); found != "" { + return found + } + } + case []any: + for _, child := range typed { + if found := walk(child); found != "" { + return found + } + } + } + return "" + } + return walk(value) +} + +func deepContainsString(value any, expected string) bool { + switch typed := value.(type) { + case map[string]any: + for _, child := range typed { + if deepContainsString(child, expected) { + return true + } + } + case []any: + for _, child := range typed { + if deepContainsString(child, expected) { + return true + } + } + case string: + return typed == expected + } + return false +} + +func containsAllFieldNames(actual []map[string]any, expected []any) bool { + names := map[string]bool{} + for _, field := range actual { + names[stringValue(field, "fieldName", "name")] = true + } + for _, raw := range expected { + field, _ := raw.(map[string]any) + if !names[stringValue(field, "fieldName", "name")] { + return false + } + } + return true +} diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go new file mode 100644 index 00000000..7ef86924 --- /dev/null +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -0,0 +1,151 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func runAITableCompositeCLI(t *testing.T, caller *upsertByKeyCaller, command string, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append([]string{"aitable", command}, args...)) + err := root.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) { + t.Run("one table with legal empty fields and views", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"baseId":"base","tables":[{"tableId":"t1","name":"任务"}]}}`}, + {text: `{"data":{"tables":[{"tableId":"t1","name":"任务"}]}}`}, + {text: `{"data":{"fields":[]}}`}, + {text: `{"data":{"views":[]}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base") + if err != nil { + t.Fatalf("snapshot error = %v", err) + } + for _, want := range []string{`"status": "success"`, `"tableCount": 1`, `"fields": []`, `"views": []`} { + if !strings.Contains(out, want) { + t.Fatalf("snapshot output missing %s: %s", want, out) + } + } + }) + + t.Run("empty tables is explicit success", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base") + if err != nil || !strings.Contains(out, `"tableCount": 0`) || len(caller.calls) != 1 { + t.Fatalf("empty snapshot = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("missing tables contract fails", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","data":{}}`}}} + out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base") + if err == nil || out != "" { + t.Fatalf("missing tables = output:%q err:%v", out, err) + } + }) +} + +func bootstrapFields(count int) []any { + fields := make([]any, 0, count) + for index := 0; index < count; index++ { + fields = append(fields, map[string]any{"fieldName": fmt.Sprintf("F%02d", index), "type": "text"}) + } + return fields +} + +func marshalBootstrapTables(t *testing.T, fields []any) string { + t.Helper() + if fields == nil { + fields = []any{} + } + raw, err := json.Marshal([]any{map[string]any{"name": "任务", "fields": fields}}) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func fieldReadBackJSON(t *testing.T, fields []any) string { + t.Helper() + raw, err := json.Marshal(map[string]any{"fields": fields}) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testing.T) { + fields := bootstrapFields(16) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"baseId":"b-new"}}`}, + {text: `{"baseId":"b-new","tables":[]}`}, + {text: `{"data":{"tableId":"t-new"}}`}, + {text: `{"createdFields":[{"fieldId":"f16"}]}`}, + {text: `{"tables":[{"tableId":"t-new","name":"任务"}]}`}, + {text: fieldReadBackJSON(t, fields)}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields)) + if err != nil { + t.Fatalf("bootstrap error = %v", err) + } + for _, want := range []string{`"baseId": "b-new"`, `"tableId": "t-new"`, `"status": "verified"`} { + if !strings.Contains(out, want) { + t.Fatalf("bootstrap output missing %s: %s", want, out) + } + } + if len(caller.calls) != 6 || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" { + t.Fatalf("bootstrap calls = %#v", caller.calls) + } + if got := len(caller.calls[2].args["fields"].([]any)); got != 15 { + t.Fatalf("create_table fields = %d, want 15", got) + } + if got := len(caller.calls[3].args["fields"].([]any)); got != 1 { + t.Fatalf("create_fields fields = %d, want 1", got) + } +} + +func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) { + t.Run("empty create response is unknown and not retry-safe", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}} + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil)) + if err == nil || out != "" { + t.Fatalf("unknown base create = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("unknown base error = %#v", err) + } + }) + + t.Run("dry run validates and makes no calls", func(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true} + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--dry-run") + if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) { + t.Fatalf("bootstrap dry run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) +} diff --git a/internal/shortcut/aitable/composite_contract.go b/internal/shortcut/aitable/composite_contract.go new file mode 100644 index 00000000..1e100eee --- /dev/null +++ b/internal/shortcut/aitable/composite_contract.go @@ -0,0 +1,38 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func aitableCompositeContract(command, description, useWhen, avoidWhen, example string) corecmd.ContractDecl { + name := strings.TrimPrefix(command, "+") + name = strings.ReplaceAll(name, "-", "_") + cliPath := "aitable " + command + return corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_" + name, + CanonicalPath: "aitable.shortcut_" + name, + CLIPath: cliPath, + PrimaryCLIPath: cliPath, + }, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "DWS reviewed composite orchestration with explicit read-back success predicates, partial-effect reporting, and resumable checkpoints; no single MCP operation represents the full command contract.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{useWhen}, + AvoidWhen: []string{avoidWhen}, + Examples: []string{example}, + }, + } +} diff --git a/internal/shortcut/aitable/composite_output.go b/internal/shortcut/aitable/composite_output.go new file mode 100644 index 00000000..d67d4e45 --- /dev/null +++ b/internal/shortcut/aitable/composite_output.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const aitableCompositeContractVersion = "aitable.composite.v1" + +type compositeResult struct { + ContractVersion string `json:"contractVersion"` + Operation string `json:"operation"` + Status string `json:"status"` + Executed bool `json:"executed"` + Retryable bool `json:"retryable"` + RequestedCount int `json:"requestedCount,omitempty"` + CompletedCount int `json:"completedCount,omitempty"` + FailedCount int `json:"failedCount,omitempty"` + Resolved map[string]any `json:"resolved,omitempty"` + Plan []compositeStep `json:"plan,omitempty"` + CompletedSteps []compositeStep `json:"completedSteps,omitempty"` + Verification map[string]any `json:"verification,omitempty"` + Checkpoint map[string]any `json:"checkpoint,omitempty"` + NextCommand string `json:"nextCommand,omitempty"` + KnownEffects []map[string]any `json:"knownSideEffects,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Result map[string]any `json:"result,omitempty"` +} + +type compositeStep struct { + Index int `json:"index"` + Name string `json:"name"` + Tool string `json:"tool,omitempty"` + Status string `json:"status"` + Offset int `json:"offset,omitempty"` + Count int `json:"count,omitempty"` + Arguments map[string]any `json:"arguments,omitempty"` + Result map[string]any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func newCompositeResult(operation string) compositeResult { + return compositeResult{ + ContractVersion: aitableCompositeContractVersion, + Operation: operation, + Status: "success", + Executed: true, + } +} + +func compositeError(result compositeResult, cause error, retryable bool) error { + result.Retryable = retryable + if result.Status == "success" { + result.Status = "unknown" + } + return apperrors.NewAPI(fmt.Sprintf("AI Table composite %s ended with status %s", result.Operation, result.Status), + apperrors.WithOperation("aitable."+result.Operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("composite_execution"), + apperrors.WithExecutionStarted(result.Executed), + apperrors.WithRetryable(retryable), + apperrors.WithReason("aitable_composite_"+result.Status), + apperrors.WithHint("inspect error.details.result before retrying; unknown means the remote effect could not be proven"), + apperrors.WithDetails(map[string]any{"result": result}), + apperrors.WithCause(cause), + ) +} diff --git a/internal/shortcut/aitable/list_projection_e2e_test.go b/internal/shortcut/aitable/list_projection_e2e_test.go new file mode 100644 index 00000000..99311c60 --- /dev/null +++ b/internal/shortcut/aitable/list_projection_e2e_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "strings" + "testing" +) + +func TestCrossPlatformCoverageAITableListProjectionExplicitEmptyIsSuccessE2E(t *testing.T) { + tests := []struct { + name string + command string + args []string + payload string + }{ + {name: "base list", command: "+base-list", payload: `{"bases":[]}`}, + {name: "base search", command: "+base-search", args: []string{"--query", "none"}, payload: `{"data":{"bases":[]}}`}, + {name: "template search", command: "+template-search", payload: `{"data":{"templates":[]}}`}, + {name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[]}`}, + {name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"data":{"formViews":[]}}`}, + {name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"data":{"list":[]}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}} + out, err := runAITableCompositeCLI(t, caller, test.command, test.args...) + if err != nil || !strings.Contains(out, `"count": 0`) || len(caller.calls) != 1 { + t.Fatalf("explicit empty list = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAITableListProjectionUnknownIsNotEmptySuccessE2E(t *testing.T) { + commands := []struct { + name string + command string + args []string + }{ + {name: "base list", command: "+base-list"}, + {name: "base search", command: "+base-search", args: []string{"--query", "none"}}, + {name: "template search", command: "+template-search"}, + {name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}}, + {name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}}, + {name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}}, + } + payloads := []struct { + name string + text string + }{ + {name: "empty response", text: ""}, + {name: "missing collection", text: `{"success":true}`}, + {name: "wrong collection type", text: `{"data":{"list":{}}}`}, + } + for _, command := range commands { + for _, payload := range payloads { + t.Run(command.name+"/"+payload.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: payload.text}}} + out, err := runAITableCompositeCLI(t, caller, command.command, command.args...) + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("unknown list result was accepted = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } + } +} + +func TestCrossPlatformCoverageAITableListProjectionMalformedItemIsNotSilentlyDroppedE2E(t *testing.T) { + tests := []struct { + name string + command string + args []string + payload string + }{ + {name: "base without id", command: "+base-list", payload: `{"bases":[{"baseName":"orphan"}]}`}, + {name: "template scalar", command: "+template-search", payload: `{"templates":["bad"]}`}, + {name: "view without id", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[{"viewName":"orphan"}]}`}, + {name: "form without id", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"forms":[{"viewName":"orphan"}]}`}, + {name: "workflow without id", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"workflows":[{"name":"orphan"}]}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}} + out, err := runAITableCompositeCLI(t, caller, test.command, test.args...) + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("malformed list item was accepted = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } +} diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go new file mode 100644 index 00000000..6ad8faca --- /dev/null +++ b/internal/shortcut/aitable/record_batch.go @@ -0,0 +1,403 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "sort" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + recordBatchSize = 100 + maxCompositeRecordRun = 10000 +) + +func parseRecordObjects(raw string, requireRecordID bool) ([]map[string]any, error) { + value, err := parseJSONAny("records", raw) + if err != nil { + return nil, err + } + list, ok := value.([]any) + if !ok || len(list) == 0 { + return nil, apperrors.NewValidation("--records 必须是非空 JSON 数组") + } + if len(list) > maxCompositeRecordRun { + return nil, apperrors.NewValidation(fmt.Sprintf("--records 最多接受 %d 条,got %d", maxCompositeRecordRun, len(list))) + } + out := make([]map[string]any, 0, len(list)) + for index, item := range list { + record, ok := item.(map[string]any) + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 必须是 JSON 对象", index)) + } + cells, ok := record["cells"].(map[string]any) + if !ok || len(cells) == 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d].cells 必须是非空 JSON 对象", index)) + } + if requireRecordID && recordID(record) == "" { + return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 缺少 recordId", index)) + } + out = append(out, record) + } + return out, nil +} + +func executeRecordUpdateBatches(rt *shortcut.RuntimeContext) error { + records, err := parseRecordObjects(rt.Str("records"), true) + if err != nil { + return err + } + return executeRecordBatches(rt, "record_update", "update_records", serverMain, records, verifyUpdateBatch) +} + +func executeRecordUpsertBatches(rt *shortcut.RuntimeContext) error { + records, err := parseRecordObjects(rt.Str("records"), false) + if err != nil { + return err + } + return executeRecordBatches(rt, "record_upsert", "record_upsert", serverHelper, records, verifyUpsertBatch) +} + +func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { + ids, err := parseRecordIDs(rt.StrSlice("record-ids")) + if err != nil { + return err + } + baseID, tableID := rt.Str("base-id"), rt.Str("table-id") + result := newCompositeResult("record_delete") + result.RequestedCount = len(ids) + for offset := 0; offset < len(ids); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(ids)) + result.Plan = append(result.Plan, compositeStep{ + Index: len(result.Plan) + 1, Name: "delete record batch", Tool: "delete_records", + Status: "planned", Offset: offset, Count: end - offset, + }) + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + + for offset := 0; offset < len(ids); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(ids)) + batch := ids[offset:end] + writeData, writeErr := rt.CallMCPWriteData(serverMain, "delete_records", map[string]any{ + "baseId": baseID, "tableId": tableID, "recordIds": batch, + }) + step := compositeStep{ + Index: len(result.CompletedSteps) + 1, Name: "delete record batch", Tool: "delete_records", + Status: "completed", Offset: offset, Count: len(batch), Result: writeData, + } + if writeErr != nil { + step.Status = "unknown" + step.Error = writeErr.Error() + } + remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch) + if verifyErr == nil && len(remaining) > 0 { + verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ",")) + } + if verifyErr != nil { + if step.Error == "" { + step.Error = verifyErr.Error() + } + step.Status = "unknown" + result.CompletedSteps = append(result.CompletedSteps, step) + result.CompletedCount = offset + result.FailedCount = len(ids) - offset + result.Status = "unknown" + if offset > 0 { + result.Status = "partial_success" + } + result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset} + result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize} + if writeErr != nil { + result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error()) + } + return compositeError(result, verifyErr, true) + } + if writeErr != nil { + step.Status = "recovered" + result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but deletion was proven by read-back", offset)) + } + step.Result = map[string]any{"status": "verified_absent", "recordIds": batch} + result.CompletedSteps = append(result.CompletedSteps, step) + result.CompletedCount = end + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch}) + } + result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)} + result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)} + return rt.Output(result) +} + +func parseRecordIDs(values []string) ([]string, error) { + seen := map[string]bool{} + ids := make([]string, 0, len(values)) + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" { + continue + } + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, apperrors.NewValidation("--record-ids 必须至少包含一个非空 recordId") + } + if len(ids) > maxCompositeRecordRun { + return nil, apperrors.NewValidation(fmt.Sprintf("--record-ids 最多接受 %d 个唯一 ID,got %d", maxCompositeRecordRun, len(ids))) + } + // Stable ordering makes checkpoints and retries deterministic even when the + // flag parser or a caller supplied IDs through more than one occurrence. + sort.Strings(ids) + return ids, nil +} + +type recordBatchVerifier func(*shortcut.RuntimeContext, string, string, []map[string]any, map[string]any) (map[string]any, error) + +func executeRecordBatches( + rt *shortcut.RuntimeContext, + operation, tool, product string, + records []map[string]any, + verify recordBatchVerifier, +) error { + baseID, tableID := rt.Str("base-id"), rt.Str("table-id") + result := newCompositeResult(operation) + result.RequestedCount = len(records) + for offset := 0; offset < len(records); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(records)) + result.Plan = append(result.Plan, compositeStep{ + Index: len(result.Plan) + 1, Name: "write record batch", Tool: tool, + Status: "planned", Offset: offset, Count: end - offset, + }) + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + + for offset := 0; offset < len(records); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(records)) + batch := records[offset:end] + wireRecords := make([]any, 0, len(batch)) + for _, record := range batch { + wireRecords = append(wireRecords, record) + } + params := map[string]any{"baseId": baseID, "tableId": tableID, "records": wireRecords} + writeData, writeErr := rt.CallMCPWriteData(product, tool, params) + step := compositeStep{ + Index: len(result.CompletedSteps) + 1, Name: "write record batch", Tool: tool, + Status: "completed", Offset: offset, Count: len(batch), Result: writeData, + } + if writeErr != nil { + step.Status = "unknown" + step.Error = writeErr.Error() + } + verification, verifyErr := verify(rt, baseID, tableID, batch, writeData) + if verifyErr != nil { + step.Status = "unknown" + if step.Error == "" { + step.Error = verifyErr.Error() + } + result.CompletedSteps = append(result.CompletedSteps, step) + result.CompletedCount = offset + result.FailedCount = len(records) - offset + result.Status = "unknown" + if offset > 0 { + result.Status = "partial_success" + } + result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset} + result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize} + if writeErr != nil { + result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error()) + } + retryable := true + if tool == "record_upsert" { + for _, record := range batch { + if recordID(record) == "" { + retryable = false + break + } + } + } + return compositeError(result, verifyErr, retryable) + } + if writeErr != nil { + step.Status = "recovered" + result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but its final state was proven by read-back", offset)) + } + step.Result = verification + result.CompletedSteps = append(result.CompletedSteps, step) + result.CompletedCount = end + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "offset": offset, "count": len(batch)}) + } + result.Verification = map[string]any{"status": "verified", "verifiedCount": len(records)} + result.Result = map[string]any{"processedCount": len(records), "batchCount": len(result.CompletedSteps)} + return rt.Output(result) +} + +func verifyUpdateBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, _ map[string]any) (map[string]any, error) { + ids := recordIDs(batch) + actual, err := queryRecordsByIDs(rt, baseID, tableID, ids) + if err != nil { + return nil, err + } + byID := make(map[string]map[string]any, len(actual)) + for _, record := range actual { + byID[recordID(record)] = record + } + for _, expected := range batch { + id := recordID(expected) + got := byID[id] + if got == nil { + return nil, fmt.Errorf("read-back is missing updated record %s", id) + } + if err := verifyRecordCells(got, expected["cells"].(map[string]any)); err != nil { + return nil, err + } + } + return map[string]any{"status": "verified", "recordIds": ids}, nil +} + +func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, writeData map[string]any) (map[string]any, error) { + updates := make([]map[string]any, 0) + creates := make([]map[string]any, 0) + for _, record := range batch { + if recordID(record) == "" { + creates = append(creates, record) + } else { + updates = append(updates, record) + } + } + verifiedIDs := make([]string, 0, len(batch)) + if len(updates) > 0 { + verified, err := verifyUpdateBatch(rt, baseID, tableID, updates, writeData) + if err != nil { + return nil, err + } + verifiedIDs = append(verifiedIDs, stringSlice(verified["recordIds"])...) + } + if len(creates) > 0 { + createdIDs := createdRecordIDs(writeData) + if len(createdIDs) != len(creates) { + return nil, fmt.Errorf("record_upsert returned %d created record IDs for %d creates", len(createdIDs), len(creates)) + } + actual, err := queryRecordsByIDs(rt, baseID, tableID, createdIDs) + if err != nil { + return nil, err + } + if err := matchCreatedCells(creates, actual); err != nil { + return nil, err + } + verifiedIDs = append(verifiedIDs, createdIDs...) + } + return map[string]any{"status": "verified", "recordIds": verifiedIDs}, nil +} + +func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) { + data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{ + "baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids), + }) + if err != nil { + return nil, err + } + records, found := findRecords(data) + if !found { + return nil, fmt.Errorf("query_records read-back is missing records") + } + if responseHasMore(data) { + return nil, fmt.Errorf("query_records read-back is incomplete") + } + return records, nil +} + +func recordIDs(records []map[string]any) []string { + out := make([]string, 0, len(records)) + seen := map[string]bool{} + for _, record := range records { + id := recordID(record) + if id != "" && !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} + +func createdRecordIDs(data map[string]any) []string { + seen := map[string]bool{} + out := make([]string, 0) + var walk func(any, bool) + walk = func(value any, createdContext bool) { + switch typed := value.(type) { + case map[string]any: + if createdContext { + if id := recordID(typed); id != "" && !seen[id] { + seen[id] = true + out = append(out, id) + } + } + for key, child := range typed { + lower := strings.ToLower(key) + nextContext := createdContext || strings.Contains(lower, "created") + walk(child, nextContext) + } + case []any: + for _, child := range typed { + if createdContext { + if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] { + seen[id] = true + out = append(out, strings.TrimSpace(id)) + continue + } + } + walk(child, createdContext) + } + } + } + walk(data, false) + return out +} + +func matchCreatedCells(expected, actual []map[string]any) error { + if len(actual) != len(expected) { + return fmt.Errorf("read-back returned %d created records, want %d", len(actual), len(expected)) + } + used := make([]bool, len(actual)) + for _, wantRecord := range expected { + wantCells := wantRecord["cells"].(map[string]any) + matched := false + for index, gotRecord := range actual { + if used[index] || verifyRecordCells(gotRecord, wantCells) != nil { + continue + } + used[index] = true + matched = true + break + } + if !matched { + return fmt.Errorf("no created read-back record matches cells %#v", wantCells) + } + } + return nil +} + +func stringSlice(value any) []string { + values, _ := value.([]string) + return values +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/shortcut/aitable/record_bulk_patch.go b/internal/shortcut/aitable/record_bulk_patch.go new file mode 100644 index 00000000..742cc583 --- /dev/null +++ b/internal/shortcut/aitable/record_bulk_patch.go @@ -0,0 +1,117 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var RecordBulkPatch = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-bulk-patch", + Product: serverMain, + Description: "完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证", + Intent: "当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。", + Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: aitableCompositeContract( + "+record-bulk-patch", + "完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证", + "当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。", + "单条已知 recordId 用 record update;每条记录写不同值用 record update/upsert;未明确范围时不要执行", + `dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`, + ), + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "patch", Type: shortcut.FlagString, Desc: "要合并到每条记录的非空 cells JSON 对象", Required: true}, + {Name: "filters", Type: shortcut.FlagString, Desc: "query_records filters JSON(选择条件之一)"}, + {Name: "query", Type: shortcut.FlagString, Desc: "全文关键词(选择条件之一)"}, + {Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "明确的 recordId 列表(选择条件之一)"}, + {Name: "view-id", Type: shortcut.FlagString, Desc: "可选视图上下文"}, + {Name: "all", Type: shortcut.FlagBool, Desc: "明确允许匹配整张表"}, + {Name: "max-matches", Type: shortcut.FlagInt, Default: "1000", Desc: "写入前允许匹配的最大记录数,1-10000"}, + }, + Tips: []string{`dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeRecordBulkPatch(rt) + }, +} + +func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error { + patch, err := parseJSONObject("patch", rt.Str("patch")) + if err != nil { + return err + } + if len(patch) == 0 { + return apperrors.NewValidation("--patch 必须是非空 JSON 对象") + } + selectorCount := 0 + for _, name := range []string{"filters", "query", "record-ids", "all"} { + if rt.Changed(name) { + selectorCount++ + } + } + if selectorCount == 0 { + return apperrors.NewValidation("必须显式提供 --filters、--query、--record-ids 或 --all") + } + if rt.Changed("all") && !rt.Bool("all") { + return apperrors.NewValidation("--all=false 不构成全表授权") + } + maxMatches := rt.Int("max-matches") + if maxMatches < 1 || maxMatches > maxCompositeRecordRun { + return apperrors.NewValidation(fmt.Sprintf("--max-matches 必须在 1..%d", maxCompositeRecordRun)) + } + params := map[string]any{"baseId": rt.Str("base-id"), "tableId": rt.Str("table-id")} + if rt.Changed("filters") { + filters, err := parseJSONAny("filters", rt.Str("filters")) + if err != nil { + return err + } + params["filters"] = filters + } + if rt.Changed("query") { + params["keyword"] = rt.Str("query") + } + if rt.Changed("record-ids") { + ids, err := parseRecordIDs(rt.StrSlice("record-ids")) + if err != nil { + return err + } + params["recordIds"] = ids + } + if rt.Changed("view-id") { + params["viewId"] = rt.Str("view-id") + } + records, err := queryAllRecords(rt, params, maxMatches) + if err != nil { + return err + } + updates := make([]map[string]any, 0, len(records)) + for _, record := range records { + id := recordID(record) + if id == "" { + return fmt.Errorf("query_records returned a matched record without recordId") + } + updates = append(updates, map[string]any{"recordId": id, "cells": cloneAnyMap(patch)}) + } + if len(updates) == 0 { + result := newCompositeResult("record_bulk_patch") + result.RequestedCount = 0 + result.Verification = map[string]any{"status": "verified_no_matches"} + result.Result = map[string]any{"matchedCount": 0, "updatedCount": 0} + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + } + return rt.Output(result) + } + return executeRecordBatches(rt, "record_bulk_patch", "update_records", serverMain, updates, verifyUpdateBatch) +} diff --git a/internal/shortcut/aitable/record_query_all.go b/internal/shortcut/aitable/record_query_all.go new file mode 100644 index 00000000..9e2970e5 --- /dev/null +++ b/internal/shortcut/aitable/record_query_all.go @@ -0,0 +1,78 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) { + all := make([]map[string]any, 0) + cursor := "" + seen := map[string]bool{} + for page := 0; ; page++ { + request := cloneAnyMap(params) + request["limit"] = recordBatchSize + if cursor != "" { + request["cursor"] = cursor + } + data, err := rt.CallMCPData(serverMain, "query_records", request) + if err != nil { + return nil, err + } + records, found := findRecords(data) + if !found { + return nil, fmt.Errorf("query_records page %d is missing the records collection", page+1) + } + all = append(all, records...) + if maxRecords > 0 && len(all) > maxRecords { + return nil, fmt.Errorf("query matched more than the allowed %d records", maxRecords) + } + if !responseHasMore(data) { + return all, nil + } + next := responseCursor(data) + if next == "" { + return nil, fmt.Errorf("query_records page %d reports more data but no next cursor", page+1) + } + if seen[next] || next == cursor { + return nil, fmt.Errorf("query_records cursor cycle detected at %q", next) + } + seen[next] = true + cursor = next + if page >= 9999 { + return nil, fmt.Errorf("query_records exceeded the 10000-page safety bound") + } + } +} + +func responseCursor(data map[string]any) string { + if data == nil { + return "" + } + for _, key := range []string{"nextCursor", "next_cursor"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for _, key := range []string{"data", "result", "pagination", "page"} { + if nested, ok := data[key].(map[string]any); ok { + if cursor := responseCursor(nested); cursor != "" { + return cursor + } + } + } + return "" +} + +func cloneAnyMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} diff --git a/internal/shortcut/aitable/record_upsert_by_key.go b/internal/shortcut/aitable/record_upsert_by_key.go new file mode 100644 index 00000000..fe44d7eb --- /dev/null +++ b/internal/shortcut/aitable/record_upsert_by_key.go @@ -0,0 +1,341 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// RecordUpsertByKey creates or updates exactly one record selected by a unique +// field value. It never writes when the key is ambiguous and always verifies +// the final record through a read-back query. +var RecordUpsertByKey = shortcut.Shortcut{ + Service: "aitable", + Command: "+record-upsert-by-key", + Product: serverMain, + Description: "按唯一字段值有则更新、无则创建记录,并读回验证", + Intent: "已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_record_upsert_by_key", + CanonicalPath: "aitable.shortcut_record_upsert_by_key", + CLIPath: "aitable +record-upsert-by-key", + PrimaryCLIPath: "aitable +record-upsert-by-key", + }, + Description: "按唯一字段值有则更新、无则创建记录,并读回验证。", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "The command composes query_records with create_records/update_records and a final read-back; no single RPC owns its uniqueness and verification contract.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按唯一字段值有则更新、无则创建记录,并读回验证", + UseWhen: []string{"已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。"}, + AvoidWhen: []string{"已经知道 recordId 时用 +record-update;一次处理多条不同键值时使用批量导入或分批调用"}, + Examples: []string{ + "dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{\"fldStatus\":\"进行中\"}'", + }, + ExampleDispositions: []contract.ExampleDisposition{{ + Index: recordUpsertExampleIndex(), + Mode: contract.ExampleDispositionModeContractOnly, + ReasonCode: contract.ExampleDispositionReasonStatefulPreflight, + Reason: "dry-run must query the live table to prove whether the unique key matches zero or one record; the isolated Agent example runner has no remote AITable fixture", + Reviewed: true, + }}, + }, + DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: true}, + }, + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "key-field-id", Type: shortcut.FlagString, Desc: "具有唯一语义的字段 ID", Required: true}, + {Name: "key-value", Type: shortcut.FlagString, Desc: "字符串键值;与 --key-value-json 二选一"}, + {Name: "key-value-json", Type: shortcut.FlagString, Desc: "JSON 类型键值;与 --key-value 二选一"}, + {Name: "cells", Type: shortcut.FlagString, Desc: "要写入的 cells JSON 对象", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"key-value", "key-value-json"}, Description: "必须且只能提供一种键值表示"}, + }, + Tips: []string{ + `dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{"fldStatus":"进行中"}'`, + }, + Execute: executeRecordUpsertByKey, +} + +func recordUpsertExampleIndex() *int { + index := 0 + return &index +} + +func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error { + keyValue, err := recordKeyValue(rt) + if err != nil { + return err + } + cells, err := parseJSONObject("cells", rt.Str("cells")) + if err != nil { + return err + } + keyFieldID := rt.Str("key-field-id") + if existing, present := cells[keyFieldID]; present && !reflect.DeepEqual(existing, keyValue) { + return apperrors.NewValidation("--cells 中的键字段值与 --key-value/--key-value-json 冲突", + apperrors.WithReason("key_value_conflict"), + apperrors.WithExecutionStarted(false), + ) + } + cells[keyFieldID] = keyValue + + baseID, tableID := rt.Str("base-id"), rt.Str("table-id") + preflight, err := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue) + if err != nil { + return err + } + action, tool := "create", "create_records" + writeRecord := map[string]any{"cells": cells} + if preflight != nil { + action, tool = "update", "update_records" + writeRecord["recordId"] = recordID(preflight) + } + params := map[string]any{ + "baseId": baseID, "tableId": tableID, + "records": []any{writeRecord}, + } + result := newCompositeResult("record_upsert_by_key") + result.Resolved = map[string]any{ + "baseId": baseID, "tableId": tableID, "keyFieldId": keyFieldID, + "keyValue": keyValue, "matchedCount": boolCount(preflight != nil), "action": action, + } + result.RequestedCount = 1 + result.Plan = []compositeStep{{Index: 1, Name: action + " record", Tool: tool, Status: "planned", Count: 1, Arguments: params}} + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + + writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + writeStep := compositeStep{Index: 1, Name: action + " record", Tool: tool, Status: "completed", Count: 1, Result: writeData} + if writeErr != nil { + writeStep.Status = "unknown" + writeStep.Error = writeErr.Error() + } + result.CompletedSteps = append(result.CompletedSteps, writeStep) + + verified, verifyErr := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue) + if verifyErr == nil && verified != nil { + verifyErr = verifyRecordCells(verified, cells) + } + if verifyErr != nil || verified == nil { + result.Status = "unknown" + result.FailedCount = 1 + // An update targets a known record ID and is safe to retry. A create whose + // effect could not be read back must be resolved by the unique-key query + // first; an immediate blind retry could duplicate the row under eventual + // consistency. + retryable := action == "update" + result.Retryable = retryable + if verifyErr == nil { + verifyErr = fmt.Errorf("read-back found no record for the unique key") + } + result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error()} + if writeErr != nil { + result.Warnings = append(result.Warnings, "write call also returned an error: "+writeErr.Error()) + } + result.Checkpoint = map[string]any{ + "nextStep": "query the unique key again and verify its cells before retrying", + "keyFieldId": keyFieldID, + "keyValue": keyValue, + } + return compositeError(result, verifyErr, retryable) + } + + verifiedID := recordID(verified) + result.CompletedCount = 1 + result.KnownEffects = append(result.KnownEffects, map[string]any{"action": action, "recordId": verifiedID, "keyFieldId": keyFieldID, "keyValue": keyValue}) + result.Verification = map[string]any{ + "status": "verified", "recordId": verifiedID, + "checkedFields": sortedMapKeys(cells), + } + result.Result = map[string]any{"action": action, "recordId": verifiedID, "record": verified} + if writeErr != nil { + result.Warnings = append(result.Warnings, "write response was an error, but the requested final state was proven by read-back") + result.CompletedSteps[0].Status = "recovered" + } + return rt.Output(result) +} + +func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) { + if rt.Changed("key-value") { + value := rt.Str("key-value") + if value == "" { + return nil, apperrors.NewValidation("--key-value 不能为空") + } + return value, nil + } + decoder := json.NewDecoder(strings.NewReader(rt.Str("key-value-json"))) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error()) + } + if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice { + return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量") + } + return value, nil +} + +func queryUniqueRecordByKey(rt *shortcut.RuntimeContext, baseID, tableID, fieldID string, value any) (map[string]any, error) { + filters := map[string]any{ + "operator": "and", + "operands": []any{map[string]any{ + "operator": "eq", "operands": []any{fieldID, value}, + }}, + } + data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{ + "baseId": baseID, "tableId": tableID, "filters": filters, "limit": 2, + }) + if err != nil { + return nil, err + } + records, found := findRecords(data) + if !found { + return nil, apperrors.NewAPI("query_records response is missing the records collection", + apperrors.WithOperation("aitable/query_records"), + apperrors.WithReason("target_invalid_response"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithExecutionStarted(false), + ) + } + if responseHasMore(data) { + return nil, apperrors.NewAPI("unique-key query is incomplete and cannot prove uniqueness", + apperrors.WithOperation("aitable/query_records"), + apperrors.WithReason("target_incomplete"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithDetails(map[string]any{"records": records}), + ) + } + switch len(records) { + case 0: + return nil, nil + case 1: + if recordID(records[0]) == "" { + return nil, apperrors.NewAPI("query_records returned a record without recordId", + apperrors.WithOperation("aitable/query_records"), + apperrors.WithReason("target_invalid_response"), + apperrors.WithExecutionStarted(false), + ) + } + return records[0], nil + default: + return nil, apperrors.NewValidation("唯一键匹配到多条记录,已在写入前停止", + apperrors.WithReason("target_ambiguous"), + apperrors.WithExecutionStarted(false), + apperrors.WithDetails(map[string]any{"records": records}), + ) + } +} + +func findRecords(data map[string]any) ([]map[string]any, bool) { + if data == nil { + return nil, false + } + if raw, exists := data["records"]; exists { + list, ok := raw.([]any) + if !ok { + return nil, false + } + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + record, ok := item.(map[string]any) + if !ok { + return nil, false + } + out = append(out, record) + } + return out, true + } + for _, key := range []string{"data", "result"} { + if nested, ok := data[key].(map[string]any); ok { + if records, found := findRecords(nested); found { + return records, true + } + } + } + return nil, false +} + +func responseHasMore(data map[string]any) bool { + if data == nil { + return false + } + if value, ok := data["hasMore"].(bool); ok && value { + return true + } + for _, key := range []string{"nextCursor", "cursor"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return true + } + } + for _, key := range []string{"data", "result", "pagination", "page"} { + if nested, ok := data[key].(map[string]any); ok && responseHasMore(nested) { + return true + } + } + return false +} + +func recordID(record map[string]any) string { + for _, key := range []string{"recordId", "record_id", "id"} { + if value, ok := record[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func verifyRecordCells(record map[string]any, expected map[string]any) error { + actual, ok := record["cells"].(map[string]any) + if !ok { + return fmt.Errorf("read-back record %q is missing cells", recordID(record)) + } + for fieldID, want := range expected { + got, exists := actual[fieldID] + if !exists || !reflect.DeepEqual(got, want) { + return fmt.Errorf("read-back mismatch for field %s: got %#v, want %#v", fieldID, got, want) + } + } + return nil +} + +func sortedMapKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func boolCount(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go new file mode 100644 index 00000000..1738f2d8 --- /dev/null +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -0,0 +1,460 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type upsertByKeyStep struct { + text string + err error +} + +type upsertByKeyCaller struct { + steps []upsertByKeyStep + calls []aitableURLCall + dryRun bool +} + +func (c *upsertByKeyCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return c.call(product, tool, args) +} + +func (c *upsertByKeyCaller) CallReadTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + return c.call(product, tool, args) +} + +func (c *upsertByKeyCaller) call(product, tool string, args map[string]any) (*edition.ToolResult, error) { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned}) + index := len(c.calls) - 1 + if index >= len(c.steps) { + return nil, errors.New("unexpected upsert-by-key call") + } + step := c.steps[index] + if step.err != nil { + return nil, step.err + } + return urlResult(step.text), nil +} + +func (*upsertByKeyCaller) Format() string { return "json" } +func (c *upsertByKeyCaller) DryRun() bool { return c.dryRun } +func (*upsertByKeyCaller) Fields() string { return "" } +func (*upsertByKeyCaller) JQ() string { return "" } + +func runUpsertByKeyCLI(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + args := []string{ + "aitable", "+record-upsert-by-key", + "--base-id", "base", "--table-id", "table", + "--key-field-id", "fldKey", "--key-value", "TASK-1", + "--cells", `{"fldStatus":"进行中"}`, + "--yes", + } + args = append(args, extra...) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageRecordUpsertByKeyCreateE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"records":[]}}`}, + {text: `{"data":{"records":[{"recordId":"r1"}]}}`}, + {text: `{"data":{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err != nil { + t.Fatalf("create upsert error = %v", err) + } + for _, want := range []string{`"status": "success"`, `"action": "create"`, `"recordId": "r1"`, `"status": "verified"`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("create output missing %s:\n%s", want, out) + } + } + if len(caller.calls) != 3 || caller.calls[1].tool != "create_records" || caller.calls[2].tool != "query_records" { + t.Fatalf("create call flow = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageRecordUpsertByKeyUpdateE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`}, + {text: `{"updatedRecords":[{"recordId":"r1"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err != nil || !bytes.Contains([]byte(out), []byte(`"action": "update"`)) { + t.Fatalf("update upsert = output:%q err:%v", out, err) + } + if caller.calls[1].tool != "update_records" { + t.Fatalf("update tool = %s", caller.calls[1].tool) + } +} + +func TestCrossPlatformCoverageRecordUpsertByKeyAmbiguousStopsBeforeWriteE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[ + {"recordId":"r1","cells":{"fldKey":"TASK-1"}}, + {"recordId":"r2","cells":{"fldKey":"TASK-1"}} + ]}`}}} + out, err := runUpsertByKeyCLI(t, caller) + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("ambiguous upsert = output:%q err:%v calls:%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "target_ambiguous" || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("ambiguous error = %#v", err) + } +} + +func TestCrossPlatformCoverageRecordUpsertByKeyDryRunReadsAndPlansE2E(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}} + out, err := runUpsertByKeyCLI(t, caller, "--dry-run") + if err != nil { + t.Fatalf("dry-run upsert error = %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].tool != "query_records" { + t.Fatalf("dry-run calls = %#v", caller.calls) + } + for _, want := range []string{`"status": "planned"`, `"executed": false`, `"tool": "create_records"`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("dry-run output missing %s: %s", want, out) + } + } +} + +func TestCrossPlatformCoverageRecordUpsertByKeyWriteReplyNeedsReadBackE2E(t *testing.T) { + t.Run("empty write recovered by proven state", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[]}`}, + {text: ""}, + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) || !bytes.Contains([]byte(out), []byte("proven by read-back")) { + t.Fatalf("recovered write = output:%q err:%v", out, err) + } + }) + + t.Run("empty write and absent state is unknown", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[]}`}, + {text: ""}, + {text: `{"records":[]}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err == nil || out != "" { + t.Fatalf("unknown write = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("unknown write error = %#v", err) + } + if typed.Details == nil || !strings.Contains(fmt.Sprint(typed.Details), "query the unique key again") { + t.Fatalf("unknown create is missing safe recovery checkpoint: %#v", typed.Details) + } + }) + + t.Run("unknown update remains retryable because record id is known", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`}, + {text: ""}, + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err == nil || out != "" { + t.Fatalf("unknown update = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || !typed.Retryable { + t.Fatalf("unknown update error = %#v", err) + } + }) +} + +func TestCrossPlatformCoverageRecordUpsertByKeyVerificationMismatchFailsE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"records":[]}`}, + {text: `{"createdRecords":[{"recordId":"r1"}]}`}, + {text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"错误值"}}]}`}, + }} + out, err := runUpsertByKeyCLI(t, caller) + if err == nil || out != "" { + t.Fatalf("verification mismatch = output:%q err:%v", out, err) + } +} + +func runRecordBatchCLI(t *testing.T, caller *upsertByKeyCaller, command string, records []map[string]any, extra ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + raw, err := json.Marshal(records) + if err != nil { + t.Fatal(err) + } + args := []string{"aitable", command, "--base-id", "base", "--table-id", "table", "--records", string(raw), "--yes"} + args = append(args, extra...) + root.SetArgs(args) + err = root.Execute() + return stdout.String(), err +} + +func updateFixtureRecords(start, count int, status string) []map[string]any { + records := make([]map[string]any, 0, count) + for index := start; index < start+count; index++ { + records = append(records, map[string]any{ + "recordId": fmt.Sprintf("r%03d", index), + "cells": map[string]any{"fldStatus": status}, + }) + } + return records +} + +func recordListJSON(t *testing.T, records []map[string]any) string { + t.Helper() + items := make([]any, 0, len(records)) + for _, record := range records { + items = append(items, record) + } + raw, err := json.Marshal(map[string]any{"data": map[string]any{"records": items}}) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) { + records := updateFixtureRecords(0, 101, "完成") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"updatedCount":100}`}, + {text: recordListJSON(t, records[:100])}, + {text: `{"updatedCount":1}`}, + {text: recordListJSON(t, records[100:])}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-update", records) + if err != nil { + t.Fatalf("record update batches error = %v", err) + } + for _, want := range []string{`"processedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("batch output missing %s: %s", want, out) + } + } + if len(caller.calls) != 4 || caller.calls[0].tool != "update_records" || caller.calls[1].tool != "query_records" || caller.calls[2].tool != "update_records" { + t.Fatalf("batch call sequence = %#v", caller.calls) + } + firstBatch := caller.calls[0].args["records"].([]any) + secondBatch := caller.calls[2].args["records"].([]any) + if len(firstBatch) != 100 || len(secondBatch) != 1 { + t.Fatalf("batch sizes = %d/%d", len(firstBatch), len(secondBatch)) + } +} + +func TestCrossPlatformCoverageRecordUpdatePartialStopsWithCheckpointE2E(t *testing.T) { + records := updateFixtureRecords(0, 101, "完成") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"updatedCount":100}`}, + {text: recordListJSON(t, records[:100])}, + {err: errors.New("connection reset after send")}, + {text: `{"records":[]}`}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-update", records) + if err == nil || out != "" { + t.Fatalf("partial record update = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" { + t.Fatalf("partial error = %#v", err) + } + result := typed.Details["result"].(compositeResult) + if result.CompletedCount != 100 || result.Checkpoint["nextOffset"] != 100 { + t.Fatalf("partial result = %#v", result) + } +} + +func TestCrossPlatformCoverageRecordUpdateEmptyWriteReplyRecoveredOnlyByReadBackE2E(t *testing.T) { + records := updateFixtureRecords(0, 1, "完成") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: ""}, + {text: recordListJSON(t, records)}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-update", records) + if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) { + t.Fatalf("empty write reply recovery = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageRecordUpsertRequiresCreatedIDsAndReadBackE2E(t *testing.T) { + records := []map[string]any{ + {"recordId": "existing", "cells": map[string]any{"fldStatus": "完成"}}, + {"cells": map[string]any{"fldKey": "new"}}, + } + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"createdRecords":[{"recordId":"created"}]}}`}, + {text: recordListJSON(t, []map[string]any{records[0]})}, + {text: recordListJSON(t, []map[string]any{{"recordId": "created", "cells": map[string]any{"fldKey": "new"}}})}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-upsert", records) + if err != nil || !bytes.Contains([]byte(out), []byte(`"verifiedCount": 2`)) { + t.Fatalf("mixed upsert = output:%q err:%v", out, err) + } + + caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true}`}}} + out, err = runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"fldKey": "new"}}}) + if err == nil || out != "" { + t.Fatalf("upsert without created IDs = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Retryable { + t.Fatalf("upsert create with unknown effect must not be blindly retryable: %#v", err) + } +} + +func TestCrossPlatformCoverageRecordBatchDryRunPlansWithoutWritesE2E(t *testing.T) { + records := updateFixtureRecords(0, 101, "完成") + caller := &upsertByKeyCaller{dryRun: true} + out, err := runRecordBatchCLI(t, caller, "+record-update", records, "--dry-run") + if err != nil || len(caller.calls) != 0 { + t.Fatalf("batch dry-run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + if !bytes.Contains([]byte(out), []byte(`"status": "planned"`)) || !bytes.Contains([]byte(out), []byte(`"count": 100`)) || !bytes.Contains([]byte(out), []byte(`"count": 1`)) { + t.Fatalf("batch dry-run output = %s", out) + } +} + +func runRecordDeleteCLI(t *testing.T, caller *upsertByKeyCaller, ids []string, extra ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + args := []string{"aitable", "+record-delete", "--base-id", "base", "--table-id", "table", "--record-ids", strings.Join(ids, ","), "--yes"} + args = append(args, extra...) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), err +} + +func recordIDFixtures(count int) []string { + ids := make([]string, 0, count) + for index := 0; index < count; index++ { + ids = append(ids, fmt.Sprintf("r%03d", index)) + } + return ids +} + +func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) { + ids := recordIDFixtures(101) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"deletedCount":100}`}, + {text: `{"data":{"records":[]}}`}, + {text: `{"deletedCount":1}`}, + {text: `{"data":{"records":[]}}`}, + }} + out, err := runRecordDeleteCLI(t, caller, ids) + if err != nil { + t.Fatalf("record delete batches error = %v", err) + } + for _, want := range []string{`"deletedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`, `"status": "verified_absent"`} { + if !strings.Contains(out, want) { + t.Fatalf("delete output missing %s: %s", want, out) + } + } + if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" { + t.Fatalf("delete call sequence = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) { + t.Run("legal empty collection proves deletion", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err != nil || !strings.Contains(out, `"status": "recovered"`) { + t.Fatalf("delete recovered = output:%q err:%v", out, err) + } + }) + + t.Run("missing records contract is unknown", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" { + t.Fatalf("delete missing records = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" { + t.Fatalf("delete missing records error = %#v", err) + } + }) + + t.Run("remaining record is not success", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"deletedCount":0}`}, + {text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`}, + }} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" { + t.Fatalf("delete remaining record = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) { + ids := recordIDFixtures(101) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"deletedCount":100}`}, + {text: `{"records":[]}`}, + {text: `{"deletedCount":0}`}, + {text: `{"records":[{"recordId":"r100","cells":{}}]}`}, + }} + out, err := runRecordDeleteCLI(t, caller, ids) + if err == nil || out != "" { + t.Fatalf("partial delete = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" { + t.Fatalf("partial delete error = %#v", err) + } + result := typed.Details["result"].(compositeResult) + if result.Checkpoint["nextOffset"] != 100 || result.CompletedCount != 100 { + t.Fatalf("partial delete result = %#v", result) + } +} diff --git a/internal/shortcut/aitable/schema_contracts.go b/internal/shortcut/aitable/schema_contracts.go new file mode 100644 index 00000000..33ef5e56 --- /dev/null +++ b/internal/shortcut/aitable/schema_contracts.go @@ -0,0 +1,147 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package aitable + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const reviewedAITableShortcutInterfaceReason = "Reviewed AI Table Shortcut adapter: the executable CLI owns validation, orchestration, output projection, and confirmation; no single pinned MCP interface represents the complete command contract." + +// reviewedAITableShortcutContractCommands is the exact migration ledger for +// the 53 executable AI Table shortcuts that were hidden only because they +// predated typed Contract declarations. New shortcuts must declare Safety and +// Contract directly and must never be added to this migration ledger. +var reviewedAITableShortcutContractCommands = map[string]struct{}{ + "+advperm-disable": {}, + "+advperm-enable": {}, + "+attachment-upload": {}, + "+base-copy": {}, + "+base-delete": {}, + "+base-get-primary-doc-id": {}, + "+base-update": {}, + "+chart-delete": {}, + "+chart-share-get": {}, + "+chart-share-update": {}, + "+chart-update": {}, + "+dashboard-arrange": {}, + "+dashboard-delete": {}, + "+dashboard-share-get": {}, + "+dashboard-share-update": {}, + "+dashboard-update": {}, + "+export-data": {}, + "+field-delete": {}, + "+field-update": {}, + "+form-delete": {}, + "+form-field-hide": {}, + "+form-field-update": {}, + "+form-share-update": {}, + "+form-update": {}, + "+import-data": {}, + "+import-upload": {}, + "+record-delete": {}, + "+record-primary-doc-create": {}, + "+record-primary-doc-get": {}, + "+record-update": {}, + "+record-upsert": {}, + "+role-create": {}, + "+role-delete": {}, + "+role-get": {}, + "+role-update": {}, + "+section-create": {}, + "+section-delete": {}, + "+section-move-node": {}, + "+section-rename": {}, + "+section-reorder": {}, + "+table-delete": {}, + "+table-update": {}, + "+view-delete": {}, + "+view-duplicate": {}, + "+view-lock": {}, + "+view-set-fill-color-rule": {}, + "+view-set-frozen-cols": {}, + "+view-set-row-height": {}, + "+view-update": {}, + "+workflow-disable": {}, + "+workflow-enable": {}, + "+workflow-get": {}, + "+workflow-list": {}, +} + +func withReviewedAITableShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut { + out := make([]shortcut.Shortcut, len(values)) + for index, value := range values { + out[index] = value + if !value.Contract.Empty() { + continue + } + if _, reviewed := reviewedAITableShortcutContractCommands[value.Command]; !reviewed { + continue + } + out[index].Safety = reviewedAITableShortcutSafety(value.Risk) + out[index].Contract = reviewedAITableShortcutContract(value) + } + return out +} + +func reviewedAITableShortcutSafety(risk shortcut.Risk) contract.SafetySpec { + switch risk { + case shortcut.RiskWrite: + return contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } + case shortcut.RiskHighWrite: + return contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } + default: + return contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } + } +} + +func reviewedAITableShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_") + cliPath := "aitable " + value.Command + description := strings.TrimSpace(value.Intent) + if description == "" { + description = strings.TrimSpace(value.Description) + } + examples := append([]string(nil), value.Tips...) + if len(examples) > 2 { + examples = examples[:2] + } + return corecmd.ContractDecl{ + Title: value.Description, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: reviewedAITableShortcutInterfaceReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: value.Description, + UseWhen: []string{description}, + AvoidWhen: []string{ + "只需未经适配的底层参数或原始响应时改用对应 aitable 原子命令;电子表格单元格操作改用 sheet", + }, + Examples: examples, + }, + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: name, + CanonicalPath: "aitable." + name, + CLIPath: cliPath, + PrimaryCLIPath: cliPath, + }, + } +} diff --git a/internal/shortcut/aitable/schema_contracts_test.go b/internal/shortcut/aitable/schema_contracts_test.go new file mode 100644 index 00000000..cdadc541 --- /dev/null +++ b/internal/shortcut/aitable/schema_contracts_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAITableLegacyContractLedgerIsExact(t *testing.T) { + if got, want := len(reviewedAITableShortcutContractCommands), 53; got != want { + t.Fatalf("legacy contract migration ledger = %d, want %d", got, want) + } + for command := range reviewedAITableShortcutContractCommands { + found := false + for _, item := range shortcut.All() { + if item.Service == "aitable" && item.Command == command { + found = true + if item.Contract.Empty() { + t.Errorf("%s: migration did not deliver Contract", command) + } + if item.Safety.Confirmation == "" { + t.Errorf("%s: migration did not deliver explicit Safety", command) + } + break + } + } + if !found { + t.Errorf("stale migration ledger entry %s", command) + } + } +} diff --git a/internal/shortcut/aitable/table_copy.go b/internal/shortcut/aitable/table_copy.go new file mode 100644 index 00000000..03cfd5fe --- /dev/null +++ b/internal/shortcut/aitable/table_copy.go @@ -0,0 +1,247 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var TableCopy = shortcut.Shortcut{ + Service: "aitable", + Command: "+table-copy", + Product: serverMain, + Description: "跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录", + Intent: "当服务端没有 table copy/task 接口、但你需要在目标 Base 重建一张表时使用;本地编排字段分片、fieldId 映射、记录分片和读回验证。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: aitableCompositeContract( + "+table-copy", + "跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录", + "当服务端没有 table copy/task 接口、但你需要在目标 Base 重建一张表时使用;本地编排字段分片、fieldId 映射、记录分片和读回验证。", + "复制整个 Base 用 base copy;需要复制公式、查找引用、关联字段或所有视图时先用 schema snapshot 手工处理这些无法安全重映射的依赖", + `dws aitable +table-copy --source-base-id B1 --source-table-id T1 --target-base-id B2 --new-name "任务副本"`, + ), + Flags: []shortcut.Flag{ + {Name: "source-base-id", Type: shortcut.FlagString, Desc: "源 Base ID", Required: true}, + {Name: "source-table-id", Type: shortcut.FlagString, Desc: "源 Table ID", Required: true}, + {Name: "target-base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true}, + {Name: "new-name", Type: shortcut.FlagString, Desc: "目标表名", Required: true}, + {Name: "include-records", Type: shortcut.FlagBool, Desc: "复制全部记录;默认只复制可安全重建的字段结构"}, + {Name: "max-records", Type: shortcut.FlagInt, Default: "10000", Desc: "复制记录的写前上限,1-10000"}, + }, + Tips: []string{`dws aitable +table-copy --source-base-id B1 --source-table-id T1 --target-base-id B2 --new-name "任务副本"`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeTableCopy(rt) + }, +} + +var tableCopyUnsupportedTypes = map[string]string{ + "creator": "system field", "lastModifier": "system field", "createdTime": "system field", "lastModifiedTime": "system field", + "formula": "formula references field names and may be unsupported by the service", + "filterUp": "lookup configuration contains source field/table IDs", + "lookup": "lookup configuration contains source field IDs", + "unidirectionalLink": "link configuration contains a source table ID", + "bidirectionalLink": "link configuration contains a source table ID and creates a reverse field", +} + +func executeTableCopy(rt *shortcut.RuntimeContext) error { + sourceBase, sourceTable := rt.Str("source-base-id"), rt.Str("source-table-id") + targetBase := rt.Str("target-base-id") + maxRecords := rt.Int("max-records") + if maxRecords < 1 || maxRecords > maxCompositeRecordRun { + return apperrors.NewValidation(fmt.Sprintf("--max-records 必须在 1..%d", maxCompositeRecordRun)) + } + sourceFieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": sourceBase, "tableId": sourceTable}) + if err != nil { + return err + } + sourceFields, found := findNamedObjectList(sourceFieldsData, "fields", "fieldList") + if !found { + return fmt.Errorf("source get_fields response is missing the fields collection") + } + createFields := make([]any, 0, len(sourceFields)) + copiedSourceFields := make([]map[string]any, 0, len(sourceFields)) + warnings := make([]string, 0) + for _, field := range sourceFields { + fieldID := stringValue(field, "fieldId", "id") + name := stringValue(field, "fieldName", "name") + typeName := stringValue(field, "type", "fieldType") + if fieldID == "" || name == "" || typeName == "" { + return fmt.Errorf("source field is missing fieldId, fieldName, or type") + } + if reason := tableCopyUnsupportedTypes[normalizedFieldType(typeName)]; reason != "" { + warnings = append(warnings, fmt.Sprintf("skipped field %q (%s): %s", name, typeName, reason)) + continue + } + declaration := map[string]any{"fieldName": name, "type": typeName} + for _, key := range []string{"config", "aiConfig"} { + if value, exists := field[key]; exists { + declaration[key] = value + } + } + createFields = append(createFields, declaration) + copiedSourceFields = append(copiedSourceFields, field) + } + var sourceRecords []map[string]any + if rt.Bool("include-records") { + sourceRecords, err = queryAllRecords(rt, map[string]any{"baseId": sourceBase, "tableId": sourceTable}, maxRecords) + if err != nil { + return err + } + } + result := newCompositeResult("table_copy") + result.RequestedCount = len(sourceRecords) + result.Resolved = map[string]any{"sourceBaseId": sourceBase, "sourceTableId": sourceTable, "targetBaseId": targetBase} + result.Warnings = warnings + result.Plan = []compositeStep{{Index: 1, Name: "create target table", Tool: "create_table", Status: "planned", Count: len(createFields)}} + if len(sourceRecords) > 0 { + result.Plan = append(result.Plan, compositeStep{Index: 2, Name: "copy records", Tool: "create_records", Status: "planned", Count: len(sourceRecords)}) + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + result.Result = map[string]any{"fieldCount": len(createFields), "recordCount": len(sourceRecords)} + return rt.Output(result) + } + + initialEnd := minInt(15, len(createFields)) + createData, err := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{ + "baseId": targetBase, "tableName": rt.Str("new-name"), "fields": createFields[:initialEnd], + }) + targetTable := findStringByKeys(createData, "tableId", "sheetId") + if err != nil || targetTable == "" { + if err == nil { + err = fmt.Errorf("create_table response is missing target tableId") + } + result.Status = "unknown" + result.Checkpoint = map[string]any{"nextStep": "locate the target table by exact name before retrying"} + return compositeError(result, err, false) + } + result.Resolved["targetTableId"] = targetTable + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": targetBase, "tableId": targetTable}) + for offset := initialEnd; offset < len(createFields); offset += 15 { + end := minInt(offset+15, len(createFields)) + _, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]}) + if fieldErr != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final schema verification decides success: %v", offset, fieldErr)) + } + } + targetFieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": targetBase, "tableId": targetTable}) + targetFields, found := findNamedObjectList(targetFieldsData, "fields", "fieldList") + if err != nil || !found || !containsAllFieldNames(targetFields, createFields) { + if err == nil { + err = fmt.Errorf("target field read-back does not contain the copied field set") + } + result.Status = "partial_success" + result.Checkpoint = map[string]any{"targetTableId": targetTable, "step": "repair target fields"} + return compositeError(result, err, false) + } + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create target table", Tool: "create_table", Status: "completed", Count: len(createFields), Result: map[string]any{"tableId": targetTable}}) + + fieldMap, err := mapCopiedFieldIDs(copiedSourceFields, targetFields) + if err != nil { + result.Status = "partial_success" + result.Checkpoint = map[string]any{"targetTableId": targetTable, "step": "resolve field mapping"} + return compositeError(result, err, false) + } + createdCount := 0 + for offset := 0; offset < len(sourceRecords); offset += recordBatchSize { + end := minInt(offset+recordBatchSize, len(sourceRecords)) + batch := make([]map[string]any, 0, end-offset) + wire := make([]any, 0, end-offset) + for _, sourceRecord := range sourceRecords[offset:end] { + sourceCells, ok := sourceRecord["cells"].(map[string]any) + if !ok { + return compositeError(result, fmt.Errorf("source record %s is missing cells", recordID(sourceRecord)), false) + } + targetCells := map[string]any{} + for sourceFieldID, value := range sourceCells { + if targetFieldID := fieldMap[sourceFieldID]; targetFieldID != "" { + targetCells[targetFieldID] = value + } + } + record := map[string]any{"cells": targetCells} + batch = append(batch, record) + wire = append(wire, record) + } + writeData, writeErr := rt.CallMCPWriteData(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire}) + createdIDs := createdRecordIDs(writeData) + if len(createdIDs) == 0 { + if returned, found := findRecords(writeData); found { + createdIDs = recordIDs(returned) + } + } + if len(createdIDs) != len(batch) { + if writeErr == nil { + writeErr = fmt.Errorf("create_records returned %d record IDs for %d copied records", len(createdIDs), len(batch)) + } + result.Status = "partial_success" + result.CompletedCount = createdCount + result.FailedCount = len(sourceRecords) - createdCount + result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset} + return compositeError(result, writeErr, false) + } + actual, verifyErr := queryRecordsByIDs(rt, targetBase, targetTable, createdIDs) + if verifyErr == nil { + verifyErr = matchCreatedCells(batch, actual) + } + if verifyErr != nil { + result.Status = "partial_success" + result.CompletedCount = createdCount + result.FailedCount = len(sourceRecords) - createdCount + result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset} + return compositeError(result, verifyErr, false) + } + if writeErr != nil { + result.Warnings = append(result.Warnings, fmt.Sprintf("create_records offset %d returned an error, but returned IDs and read-back proved the copied state: %v", offset, writeErr)) + } + createdCount = end + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_records", "offset": offset, "recordIds": createdIDs}) + } + result.CompletedCount = createdCount + result.Verification = map[string]any{"status": "verified", "fieldCount": len(createFields), "recordCount": createdCount} + result.Result = map[string]any{"targetBaseId": targetBase, "targetTableId": targetTable, "fieldCount": len(createFields), "recordCount": createdCount} + return rt.Output(result) +} + +func mapCopiedFieldIDs(source, target []map[string]any) (map[string]string, error) { + targetByName := map[string]string{} + for _, field := range target { + name := stringValue(field, "fieldName", "name") + id := stringValue(field, "fieldId", "id") + if name != "" && id != "" { + if targetByName[name] != "" { + return nil, fmt.Errorf("target has duplicate field name %q", name) + } + targetByName[name] = id + } + } + out := map[string]string{} + for _, field := range source { + name := stringValue(field, "fieldName", "name") + sourceID := stringValue(field, "fieldId", "id") + if targetID := targetByName[name]; targetID != "" { + out[sourceID] = targetID + } else { + return nil, fmt.Errorf("target is missing copied field %q", name) + } + } + return out, nil +} + +func normalizedFieldType(value string) string { + for key := range tableCopyUnsupportedTypes { + if strings.EqualFold(key, value) { + return key + } + } + return value +} diff --git a/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go b/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go new file mode 100644 index 00000000..5bb1ca9f --- /dev/null +++ b/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func TestCrossPlatformCoverageRecordBulkPatchPaginatesChunksAndVerifiesE2E(t *testing.T) { + records := updateFixtureRecords(0, 101, "旧") + firstPage := map[string]any{"records": records[:100], "hasMore": true, "nextCursor": "next"} + secondPage := map[string]any{"records": records[100:], "hasMore": false} + patchedFirst := updateFixtureRecords(0, 100, "新") + patchedSecond := updateFixtureRecords(100, 1, "新") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, firstPage)}, + {text: mustJSONText(t, secondPage)}, + {text: `{"updatedCount":100}`}, + {text: recordListJSON(t, patchedFirst)}, + {text: `{"updatedCount":1}`}, + {text: recordListJSON(t, patchedSecond)}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes") + if err != nil { + t.Fatalf("bulk patch error = %v", err) + } + for _, want := range []string{`"verifiedCount": 101`, `"processedCount": 101`, `"batchCount": 2`} { + if !strings.Contains(out, want) { + t.Fatalf("bulk patch output missing %s: %s", want, out) + } + } + if len(caller.calls) != 6 || caller.calls[1].args["cursor"] != "next" || caller.calls[2].tool != "update_records" { + t.Fatalf("bulk patch calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageRecordBulkPatchSelectionBoundsAndEmptyE2E(t *testing.T) { + t.Run("explicit no matches is success", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"fld":"x"}`, "--yes") + if err != nil || len(caller.calls) != 1 || !strings.Contains(out, `"status": "verified_no_matches"`) { + t.Fatalf("empty patch = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("missing selector stops before reads", func(t *testing.T) { + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--patch", `{"fld":"x"}`, "--yes") + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("missing selector = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("max matches stops before writes", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: recordListJSON(t, updateFixtureRecords(0, 2, "旧"))}}} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fld":"x"}`, "--max-matches", "1", "--yes") + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("bounded patch = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) +} + +func mustJSONText(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func sourceFieldFixture() map[string]any { + return map[string]any{"fieldId": "sf1", "fieldName": "状态", "type": "text"} +} + +func targetFieldFixture() map[string]any { + return map[string]any{"fieldId": "tf1", "fieldName": "状态", "type": "text"} +} + +func TestCrossPlatformCoverageTableCopyStructureAndRecordsE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})}, + {text: `{"records":[{"recordId":"sr1","cells":{"sf1":"完成"}}]}`}, + {text: `{"data":{"tableId":"target-table"}}`}, + {text: mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})}, + {text: `{"createdRecords":[{"recordId":"tr1"}]}`}, + {text: `{"records":[{"recordId":"tr1","cells":{"tf1":"完成"}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "source-base", "--source-table-id", "source-table", + "--target-base-id", "target-base", "--new-name", "任务副本", "--include-records", "--yes") + if err != nil { + t.Fatalf("table copy error = %v", err) + } + for _, want := range []string{`"targetTableId": "target-table"`, `"fieldCount": 1`, `"recordCount": 1`, `"status": "verified"`} { + if !strings.Contains(out, want) { + t.Fatalf("table copy output missing %s: %s", want, out) + } + } + if len(caller.calls) != 6 || caller.calls[4].tool != "create_records" { + t.Fatalf("table copy calls = %#v", caller.calls) + } + written := caller.calls[4].args["records"].([]any)[0].(map[string]any)["cells"].(map[string]any) + if written["tf1"] != "完成" || written["sf1"] != nil { + t.Fatalf("mapped record cells = %#v", written) + } +} + +func TestCrossPlatformCoverageTableCopyUnknownResponsesAreNotSuccessE2E(t *testing.T) { + t.Run("missing target table id", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})}, + {text: `{}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err == nil || out != "" { + t.Fatalf("missing target id = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("missing target id error = %#v", err) + } + }) + + t.Run("created records without ids", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})}, + {text: `{"records":[{"recordId":"sr1","cells":{"sf1":"完成"}}]}`}, + {text: `{"tableId":"target"}`}, + {text: mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})}, + {text: `{}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes") + if err == nil || out != "" { + t.Fatalf("missing record ids = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" { + t.Fatalf("missing record ids error = %#v", err) + } + }) +} diff --git a/internal/shortcut/aitable/url_resolve.go b/internal/shortcut/aitable/url_resolve.go new file mode 100644 index 00000000..e99fe5b5 --- /dev/null +++ b/internal/shortcut/aitable/url_resolve.go @@ -0,0 +1,187 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitabletarget" +) + +// URLResolve parses a documented DingTalk AI Table URL into stable target IDs. +var URLResolve = shortcut.Shortcut{ + Service: "aitable", + Command: "+url-resolve", + Product: serverMain, + Description: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId", + Intent: "已有钉钉 AI 表格链接、需要提取后续命令使用的稳定 ID 时使用;默认只做严格本地解析,--verify 会调用对应只读接口确认最深层目标真实存在。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "aitable", + Name: "shortcut_url_resolve", + CanonicalPath: "aitable.shortcut_url_resolve", + CLIPath: "aitable +url-resolve", + PrimaryCLIPath: "aitable +url-resolve", + }, + Description: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId。", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "The command performs strict local URL decoding and can optionally verify the deepest target through a read-only AI Table interface.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId", + UseWhen: []string{"已有钉钉 AI 表格链接、需要提取后续命令使用的稳定 ID 时使用;默认只做严格本地解析,--verify 会调用对应只读接口确认最深层目标真实存在。"}, + AvoidWhen: []string{"只有资源名称而没有 URL 时改用 +resolve-base / +resolve-table;普通文档 URL 用 doc/drive"}, + Examples: []string{ + "dws aitable +url-resolve --url https://alidocs.dingtalk.com/i/nodes/BASE_ID", + "dws aitable +url-resolve --url 'https://alidocs.dingtalk.com/i/nodes/BASE_ID?iframeQuery=sheetId%3DTABLE_ID%26viewId%3DVIEW_ID' --verify", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "url", Description: "钉钉 AI 表格节点 URL"}, + {Name: "verify", Description: "通过只读接口验证最深层目标存在"}, + }, + }, + Flags: []shortcut.Flag{ + {Name: "url", Type: shortcut.FlagString, Desc: "AI 表格 URL", Required: true}, + {Name: "verify", Type: shortcut.FlagBool, Default: "false", Desc: "调用只读接口验证最深层目标存在"}, + }, + Tips: []string{ + `dws aitable +url-resolve --url "https://alidocs.dingtalk.com/i/nodes/BASE_ID?iframeQuery=sheetId%3DTABLE_ID"`, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + target, err := aitabletarget.ParseURL(rt.Str("url")) + if err != nil { + return err + } + output := map[string]any{ + "status": "resolved", + "target": target, + "verified": false, + "verificationStatus": "not_requested", + } + if rt.Bool("verify") { + verification, err := verifyURLTarget(rt, target) + if err != nil { + return err + } + output["verified"] = true + output["verificationStatus"] = "verified" + output["verification"] = verification + } + return rt.Output(output) + }, +} + +func verifyURLTarget(rt *shortcut.RuntimeContext, target aitabletarget.Target) (map[string]any, error) { + var ( + tool string + params map[string]any + idKeys []string + expectedID string + ) + switch target.Kind { + case "record": + tool = "query_records" + params = map[string]any{"baseId": target.BaseID, "tableId": target.TableID, "recordIds": []string{target.RecordID}} + idKeys, expectedID = []string{"recordId", "record_id", "id"}, target.RecordID + case "view": + tool = "get_views" + params = map[string]any{"baseId": target.BaseID, "tableId": target.TableID, "viewIds": []string{target.ViewID}} + idKeys, expectedID = []string{"viewId", "view_id", "id"}, target.ViewID + case "table": + tool = "get_tables" + params = map[string]any{"baseId": target.BaseID, "tableIds": []string{target.TableID}} + idKeys, expectedID = []string{"tableId", "table_id", "id"}, target.TableID + default: + tool = "get_base" + params = map[string]any{"baseId": target.BaseID} + idKeys, expectedID = []string{"baseId", "base_id", "id"}, target.BaseID + } + data, err := rt.CallMCPData(serverMain, tool, params) + if err != nil { + return nil, err + } + matched := responseContainsID(data, expectedID, idKeys...) + if target.Kind == "base" && !matched { + matched = responseHasAnyKey(data, "baseName", "base_name", "tables", "dashboards") + } + if !matched { + return nil, apperrors.NewAPI(fmt.Sprintf("%s did not prove that %s %q exists", tool, target.Kind, expectedID), + apperrors.WithOperation("aitable/"+tool), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("target_verification"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithReason("target_verification_failed"), + apperrors.WithHint("接口返回成功但缺少目标 ID/资源结构,不能把未知响应当作已验证"), + apperrors.WithDetails(map[string]any{"target": target}), + ) + } + return map[string]any{ + "tool": tool, + "entityType": target.Kind, + "id": expectedID, + "status": "verified", + }, nil +} + +func responseContainsID(value any, expected string, keys ...string) bool { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + for _, candidateKey := range keys { + if key == candidateKey { + if text, ok := child.(string); ok && strings.TrimSpace(text) == expected { + return true + } + } + } + if responseContainsID(child, expected, keys...) { + return true + } + } + case []any: + for _, child := range typed { + if responseContainsID(child, expected, keys...) { + return true + } + } + } + return false +} + +func responseHasAnyKey(value any, keys ...string) bool { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + for _, candidateKey := range keys { + if key == candidateKey { + return true + } + } + if responseHasAnyKey(child, keys...) { + return true + } + } + case []any: + for _, child := range typed { + if responseHasAnyKey(child, keys...) { + return true + } + } + } + return false +} diff --git a/internal/shortcut/aitable/url_resolve_e2e_test.go b/internal/shortcut/aitable/url_resolve_e2e_test.go new file mode 100644 index 00000000..8a1cd2cb --- /dev/null +++ b/internal/shortcut/aitable/url_resolve_e2e_test.go @@ -0,0 +1,152 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "bytes" + "context" + "errors" + "reflect" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type urlResolveE2ECaller struct { + results []*edition.ToolResult + errors []error + calls []aitableURLCall +} + +type aitableURLCall struct { + product string + tool string + args map[string]any +} + +func (c *urlResolveE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned}) + index := len(c.calls) - 1 + if index < len(c.errors) && c.errors[index] != nil { + return nil, c.errors[index] + } + if index >= len(c.results) { + return nil, errors.New("unexpected URL resolver tool call") + } + return c.results[index], nil +} + +func (*urlResolveE2ECaller) Format() string { return "json" } +func (*urlResolveE2ECaller) DryRun() bool { return false } +func (*urlResolveE2ECaller) Fields() string { return "" } +func (*urlResolveE2ECaller) JQ() string { return "" } + +func urlResult(text string) *edition.ToolResult { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} +} + +func runURLResolveCLI(t *testing.T, caller *urlResolveE2ECaller, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(append([]string{"aitable", "+url-resolve"}, args...)) + err := root.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageURLResolveCLIParseOnlyE2E(t *testing.T) { + caller := &urlResolveE2ECaller{} + out, err := runURLResolveCLI(t, caller, + "--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1%26viewId%3Dview-1", + ) + if err != nil { + t.Fatalf("URL resolver returned error: %v", err) + } + for _, want := range []string{`"status": "resolved"`, `"baseId": "base-1"`, `"tableId": "table-1"`, `"viewId": "view-1"`, `"verified": false`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("output missing %s:\n%s", want, out) + } + } + if len(caller.calls) != 0 { + t.Fatalf("parse-only resolver made MCP calls: %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageURLResolveCLIVerifiesDeepestTargetE2E(t *testing.T) { + caller := &urlResolveE2ECaller{results: []*edition.ToolResult{ + urlResult(`{"data":{"tables":[{"tableId":"table-1","tableName":"任务"}]}}`), + }} + out, err := runURLResolveCLI(t, caller, + "--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1", + "--verify", + ) + if err != nil { + t.Fatalf("verified URL resolver returned error: %v", err) + } + if !bytes.Contains([]byte(out), []byte(`"verificationStatus": "verified"`)) || !bytes.Contains([]byte(out), []byte(`"tool": "get_tables"`)) { + t.Fatalf("verified output = %s", out) + } + if len(caller.calls) != 1 || caller.calls[0].product != "aitable" || caller.calls[0].tool != "get_tables" { + t.Fatalf("verification calls = %#v", caller.calls) + } + wantArgs := map[string]any{"baseId": "base-1", "tableIds": []string{"table-1"}} + if !reflect.DeepEqual(caller.calls[0].args, wantArgs) { + t.Fatalf("verification args = %#v, want %#v", caller.calls[0].args, wantArgs) + } +} + +func TestCrossPlatformCoverageURLResolveCLIDoesNotMistakeUnknownForVerified(t *testing.T) { + tests := []struct { + name string + result *edition.ToolResult + }{ + {name: "nil result", result: nil}, + {name: "empty content", result: &edition.ToolResult{}}, + {name: "empty text", result: urlResult("")}, + {name: "unknown object", result: urlResult(`{"success":true}`)}, + {name: "legal empty table list", result: urlResult(`{"data":{"tables":[]}}`)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &urlResolveE2ECaller{results: []*edition.ToolResult{test.result}} + out, err := runURLResolveCLI(t, caller, + "--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1", + "--verify", + ) + if err == nil { + t.Fatalf("%s was treated as verified; output=%s", test.name, out) + } + if out != "" { + t.Fatalf("%s emitted success output: %q", test.name, out) + } + }) + } +} + +func TestCrossPlatformCoverageURLResolveCLIInvalidURLFailsBeforeMCP(t *testing.T) { + caller := &urlResolveE2ECaller{} + out, err := runURLResolveCLI(t, caller, "--url", "https://example.com/i/nodes/base", "--verify") + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("invalid URL result = output:%q err:%v calls:%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "invalid_aitable_url" { + t.Fatalf("invalid URL error = %#v", err) + } +} diff --git a/internal/shortcut/aitable/view_preset.go b/internal/shortcut/aitable/view_preset.go new file mode 100644 index 00000000..ef53e0df --- /dev/null +++ b/internal/shortcut/aitable/view_preset.go @@ -0,0 +1,179 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "reflect" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ViewPresetApply = shortcut.Shortcut{ + Service: "aitable", + Command: "+view-preset-apply", + Product: serverMain, + Description: "按视图精确名称幂等创建或更新预设,并读回校验类型和 config", + Intent: "当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: aitableCompositeContract( + "+view-preset-apply", + "按视图精确名称幂等创建或更新预设,并读回校验类型和 config", + "当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。", + "只做一次性新建可用 view create;同名视图不唯一或现有视图类型不同必须人工处理", + `dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`, + ), + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "预设视图精确名称", Required: true}, + {Name: "view-type", Type: shortcut.FlagString, Desc: "视图类型", Required: true, Enum: []string{"Grid", "Kanban", "Gantt", "Calendar", "Gallery"}}, + {Name: "config", Type: shortcut.FlagString, Desc: "目标 config JSON 对象", Required: true}, + }, + Tips: []string{`dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeViewPresetApply(rt) + }, +} + +func executeViewPresetApply(rt *shortcut.RuntimeContext) error { + config, err := parseJSONObject("config", rt.Str("config")) + if err != nil { + return err + } + if len(config) == 0 { + return apperrors.NewValidation("--config 必须是非空 JSON 对象") + } + baseID, tableID := rt.Str("base-id"), rt.Str("table-id") + name, viewType := strings.TrimSpace(rt.Str("name")), rt.Str("view-type") + preflight, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID}) + if err != nil { + return err + } + views, found := findNamedObjectList(preflight, "views", "viewList") + if !found { + return fmt.Errorf("get_views preflight is missing the views collection") + } + matches := viewsByExactName(views, name) + if len(matches) > 1 { + return apperrors.NewValidation(fmt.Sprintf("精确名称 %q 匹配到 %d 个视图,拒绝选择", name, len(matches)), apperrors.WithReason("target_ambiguous"), apperrors.WithExecutionStarted(false)) + } + action, tool := "create", "create_view" + params := map[string]any{"baseId": baseID, "tableId": tableID, "viewName": name, "viewType": viewType, "config": config} + viewID := "" + if len(matches) == 1 { + action, tool = "update", "update_view" + viewID = stringValue(matches[0], "viewId", "id") + if viewID == "" { + return fmt.Errorf("matched view is missing viewId") + } + actualType := stringValue(matches[0], "viewType", "type") + if actualType != "" && actualType != viewType { + return apperrors.NewValidation(fmt.Sprintf("同名视图类型为 %s,不能原地改为 %s", actualType, viewType), apperrors.WithReason("target_type_conflict"), apperrors.WithExecutionStarted(false)) + } + params = map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "newViewName": name, "config": config} + if presetViewMatches(matches[0], viewType, config) { + result := newCompositeResult("view_preset_apply") + result.Status = "unchanged" + result.Executed = false + result.Resolved = map[string]any{"action": "unchanged", "viewId": viewID, "name": name} + result.Verification = map[string]any{"status": "verified", "viewId": viewID} + return rt.Output(result) + } + } + result := newCompositeResult("view_preset_apply") + result.Resolved = map[string]any{"action": action, "name": name, "viewId": viewID} + result.Plan = []compositeStep{{Index: 1, Name: action + " view preset", Tool: tool, Status: "planned", Arguments: params}} + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + if action == "create" { + viewID = findStringByKeys(writeData, "viewId") + } + readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID}) + verifiedViews, found := findNamedObjectList(readBack, "views", "viewList") + if verifyErr == nil && !found { + verifyErr = fmt.Errorf("get_views read-back is missing the views collection") + } + verifiedMatches := viewsByExactName(verifiedViews, name) + if verifyErr == nil && len(verifiedMatches) != 1 { + verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches)) + } + if verifyErr == nil { + actualID := stringValue(verifiedMatches[0], "viewId", "id") + if actualID == "" || (viewID != "" && actualID != viewID) { + verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID) + } else if !presetViewMatches(verifiedMatches[0], viewType, config) { + verifyErr = fmt.Errorf("view read-back does not contain the declared type/config") + } else { + viewID = actualID + } + } + if verifyErr != nil { + result.Status = "unknown" + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "viewId": viewID, "name": name}) + if writeErr != nil { + result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error()) + } + return compositeError(result, verifyErr, action == "update") + } + result.Resolved["viewId"] = viewID + result.CompletedCount = 1 + result.Verification = map[string]any{"status": "verified", "viewId": viewID, "viewType": viewType} + result.Result = map[string]any{"action": action, "viewId": viewID, "view": verifiedMatches[0]} + if writeErr != nil { + result.Status = "recovered" + result.Warnings = append(result.Warnings, "write response was an error, but the exact view preset was proven by read-back") + } + return rt.Output(result) +} + +func viewsByExactName(views []map[string]any, name string) []map[string]any { + out := make([]map[string]any, 0) + for _, view := range views { + if stringValue(view, "viewName", "name", "title") == name { + out = append(out, view) + } + } + return out +} + +func presetViewMatches(view map[string]any, viewType string, config map[string]any) bool { + actualType := stringValue(view, "viewType", "type") + if actualType != "" && actualType != viewType { + return false + } + actualConfig, ok := view["config"].(map[string]any) + return ok && mapContains(actualConfig, config) +} + +func mapContains(actual, expected map[string]any) bool { + for key, want := range expected { + got, exists := actual[key] + if !exists { + return false + } + wantMap, wantIsMap := want.(map[string]any) + gotMap, gotIsMap := got.(map[string]any) + if wantIsMap { + if !gotIsMap || !mapContains(gotMap, wantMap) { + return false + } + continue + } + if !reflect.DeepEqual(got, want) { + return false + } + } + return true +} diff --git a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go new file mode 100644 index 00000000..ee437148 --- /dev/null +++ b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "errors" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}` + +func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) { + t.Run("create and verify exact config", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, + {text: `{"data":{"viewId":"v1"}}`}, + {text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"],"extra":true}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "待处理", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes") + if err != nil || !strings.Contains(out, `"viewId": "v1"`) || !strings.Contains(out, `"status": "verified"`) { + t.Fatalf("view preset create = output:%q err:%v", out, err) + } + if len(caller.calls) != 3 || caller.calls[1].tool != "create_view" { + t.Fatalf("view create calls = %#v", caller.calls) + } + }) + + t.Run("unchanged preset makes no write", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "待处理", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes") + if err != nil || len(caller.calls) != 1 || !strings.Contains(out, `"status": "unchanged"`) || !strings.Contains(out, `"executed": false`) { + t.Fatalf("unchanged view = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("empty write response recovers only from read-back", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, {text: ""}, + {text: `{"views":[{"viewId":"v2","viewName":"看板","viewType":"Kanban","config":{"groupFieldId":"f1"}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "看板", "--view-type", "Kanban", "--config", `{"groupFieldId":"f1"}`, "--yes") + if err != nil || !strings.Contains(out, `"status": "recovered"`) { + t.Fatalf("recovered view = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) { + t.Run("duplicate name stops before write", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes") + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("ambiguous view = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("read-back config mismatch is unknown", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"views":[]}`}, {text: `{"viewId":"v1"}`}, + {text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["wrong"]}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes") + if err == nil || out != "" { + t.Fatalf("mismatched view = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("mismatched view error = %#v", err) + } + }) +} + +func TestCrossPlatformCoverageWorkflowDeployCreateEnableAndVerifyE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"data":{"valid":true,"flowId":"w1","issues":[]}}`}, + {text: `{"data":{"flowId":"w1","flowSchema":{"name":"提醒"}}}`}, + {text: `{"workflowId":"w1","enabled":true}`}, + {text: `{"data":{"list":[{"flowId":"w1","name":"提醒","status":"RUNNING"}]}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err != nil { + t.Fatalf("workflow deploy error = %v", err) + } + for _, want := range []string{`"workflowId": "w1"`, `"valid": true`, `"running": true`, `"status": "verified"`} { + if !strings.Contains(out, want) { + t.Fatalf("workflow output missing %s: %s", want, out) + } + } + if len(caller.calls) != 4 || caller.calls[0].product != serverMain || caller.calls[2].product != serverHelper || caller.calls[3].tool != "list_workflows" { + t.Fatalf("workflow call routing = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageWorkflowDeployUpdateAndInvalidResponsesE2E(t *testing.T) { + t.Run("update preflights and reads back", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"flowId":"w1","flowSchema":{}}`}, + {text: `{"valid":true,"flowId":"w1"}`}, + {text: `{"flowId":"w1","flowSchema":{"name":"提醒"}}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--workflow-id", "w1", "--dsl", workflowDSLFixture, "--yes") + if err != nil || !strings.Contains(out, `"action": "update"`) || len(caller.calls) != 3 || caller.calls[1].tool != "update_workflow" { + t.Fatalf("workflow update = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("status success with valid false is rejected", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"status":"success","data":{"valid":false,"flowId":"w1","issues":[{"message":"bad dsl"}]}}`}}} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes") + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("invalid workflow = output:%q err:%v calls:%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_rejected" { + t.Fatalf("invalid workflow error = %#v", err) + } + }) + + for name, response := range map[string]string{ + "conflicting valid": `{"valid":true,"data":{"valid":false,"flowId":"w1"}}`, + "conflicting id": `{"valid":true,"flowId":"w1","data":{"workflowId":"w2"}}`, + "valid wrong type": `{"valid":"true","flowId":"w1"}`, + } { + t.Run(name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: response}}} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes") + if err == nil || out != "" || len(caller.calls) != 1 { + t.Fatalf("conflicting workflow response = output:%q err:%v calls:%#v", out, err, caller.calls) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_rejected" || typed.Retryable { + t.Fatalf("conflicting workflow response error = %#v", err) + } + }) + } + + t.Run("empty create response is unknown", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes") + if err == nil || out != "" { + t.Fatalf("empty workflow = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable { + t.Fatalf("empty workflow error = %#v", err) + } + }) + + t.Run("enable reply is not enough when list says STOP", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"valid":true,"flowId":"w1"}`}, {text: `{"flowId":"w1"}`}, + {text: `{"enabled":true,"workflowId":"w1"}`}, + {text: `{"list":[{"flowId":"w1","status":"STOP"}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err == nil || out != "" { + t.Fatalf("stopped workflow = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" || typed.Retryable { + t.Fatalf("stopped workflow error = %#v", err) + } + }) +} diff --git a/internal/shortcut/aitable/workflow_deploy.go b/internal/shortcut/aitable/workflow_deploy.go new file mode 100644 index 00000000..d4f8f9c4 --- /dev/null +++ b/internal/shortcut/aitable/workflow_deploy.go @@ -0,0 +1,243 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var WorkflowDeploy = shortcut.Shortcut{ + Service: "aitable", + Command: "+workflow-deploy", + Product: serverMain, + Description: "创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态", + Intent: "当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: aitableCompositeContract( + "+workflow-deploy", + "创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态", + "当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。", + "只查看定义用 workflow get;只启停已有流程用 enable/disable;创建回包不确定时不要盲目重试", + `dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`, + ), + Flags: []shortcut.Flag{ + {Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}, + {Name: "workflow-id", Type: shortcut.FlagString, Desc: "已有 Workflow ID;为空表示创建"}, + {Name: "dsl", Type: shortcut.FlagString, Desc: "完整 workflow-dsl/v1 JSON 对象", Required: true}, + {Name: "locale", Type: shortcut.FlagString, Default: "zh-CN", Desc: "发布 locale"}, + {Name: "enable", Type: shortcut.FlagBool, Desc: "发布后启用并从 workflow list 验证 RUNNING"}, + }, + Tips: []string{`dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`}, + Execute: func(rt *shortcut.RuntimeContext) error { + return executeWorkflowDeploy(rt) + }, +} + +func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error { + dsl, err := parseJSONObject("dsl", rt.Str("dsl")) + if err != nil { + return err + } + if stringValue(dsl, "version") != "workflow-dsl/v1" { + return apperrors.NewValidation("--dsl.version 必须是 workflow-dsl/v1") + } + baseID, workflowID := rt.Str("base-id"), strings.TrimSpace(rt.Str("workflow-id")) + action, tool := "create", "create_workflow" + params := map[string]any{"baseId": baseID, "dsl": dsl, "locale": rt.Str("locale")} + if workflowID != "" { + action, tool = "update", "update_workflow" + params["workflowId"] = workflowID + preflight, preflightErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID}) + if preflightErr != nil { + return preflightErr + } + if !deepContainsString(preflight, workflowID) { + return apperrors.NewValidation("workflow get preflight does not identify --workflow-id", apperrors.WithReason("target_not_found"), apperrors.WithExecutionStarted(false)) + } + } + result := newCompositeResult("workflow_deploy") + result.Resolved = map[string]any{"action": action, "baseId": baseID, "workflowId": workflowID, "enable": rt.Bool("enable")} + result.Plan = []compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "planned"}} + if rt.Bool("enable") { + result.Plan = append(result.Plan, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "planned"}) + } + if rt.DryRun() { + result.Status = "planned" + result.Executed = false + return rt.Output(result) + } + writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + if writeErr != nil { + result.Status = "unknown" + result.Checkpoint = map[string]any{"nextStep": "resolve workflow existence and definition before retrying", "workflowId": workflowID} + return compositeError(result, writeErr, false) + } + valid, validFound, responseID, publishErr := workflowPublishResult(writeData) + if publishErr != nil { + result.Status = "rejected" + result.Result = writeData + return compositeError(result, publishErr, false) + } + if !validFound || !valid || responseID == "" || (workflowID != "" && responseID != workflowID) { + result.Status = "rejected" + result.Result = writeData + return compositeError(result, fmt.Errorf("%s response must contain valid=true and the expected flowId", tool), false) + } + workflowID = responseID + result.Resolved["workflowId"] = workflowID + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "workflowId": workflowID}) + readBack, verifyErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID}) + if verifyErr != nil || !deepContainsString(readBack, workflowID) { + if verifyErr == nil { + verifyErr = fmt.Errorf("get_workflow read-back does not identify workflowId %s", workflowID) + } + result.Status = "partial_success" + result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "verify published workflow"} + return compositeError(result, verifyErr, action == "update") + } + if rt.Bool("enable") { + enableData, enableErr := rt.CallMCPWriteData(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID}) + if enableErr != nil { + result.Warnings = append(result.Warnings, "enable response error: "+enableErr.Error()) + } + workflow, listErr := readWorkflowFromList(rt, baseID, workflowID) + if listErr != nil || !workflowIsRunning(workflow) { + if listErr == nil { + listErr = fmt.Errorf("workflow list does not show %s as RUNNING", workflowID) + } + result.Status = "partial_success" + result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "enable_workflow", "workflowId": workflowID, "response": enableData}) + result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "inspect and enable workflow"} + return compositeError(result, listErr, action == "update") + } + result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "completed", Result: workflow}) + } + result.CompletedCount = 1 + result.CompletedSteps = append([]compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "completed", Result: writeData}}, result.CompletedSteps...) + result.Verification = map[string]any{"status": "verified", "valid": true, "workflowId": workflowID, "running": rt.Bool("enable")} + result.Result = map[string]any{"action": action, "workflowId": workflowID, "valid": true} + return rt.Output(result) +} + +// workflowPublishResult reads only the reviewed response envelopes rather than +// recursively accepting an unrelated nested valid/flowId field. Conflicting +// values are protocol errors; an outer "success" marker never overrides +// valid=false. +func workflowPublishResult(data map[string]any) (valid bool, validFound bool, workflowID string, err error) { + var visit func(map[string]any) error + visit = func(object map[string]any) error { + if raw, exists := object["valid"]; exists { + value, ok := raw.(bool) + if !ok { + return fmt.Errorf("workflow publish valid must be boolean, got %T", raw) + } + if validFound && valid != value { + return fmt.Errorf("workflow publish response contains conflicting valid values") + } + valid, validFound = value, true + } + for _, key := range []string{"flowId", "workflowId"} { + raw, exists := object[key] + if !exists { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return fmt.Errorf("workflow publish %s must be a non-empty string", key) + } + if workflowID != "" && workflowID != value { + return fmt.Errorf("workflow publish response contains conflicting workflow IDs") + } + workflowID = value + } + for _, envelope := range []string{"data", "result", "response"} { + if nested, ok := object[envelope].(map[string]any); ok { + if err := visit(nested); err != nil { + return err + } + } + } + return nil + } + if data == nil { + return false, false, "", fmt.Errorf("workflow publish response is empty") + } + if err := visit(data); err != nil { + return false, false, "", err + } + return valid, validFound, workflowID, nil +} + +func findBoolByKeys(value any, keys ...string) (bool, bool) { + keySet := map[string]bool{} + for _, key := range keys { + keySet[strings.ToLower(key)] = true + } + var walk func(any) (bool, bool) + walk = func(current any) (bool, bool) { + switch typed := current.(type) { + case map[string]any: + for key, child := range typed { + if keySet[strings.ToLower(key)] { + if value, ok := child.(bool); ok { + return value, true + } + } + } + for _, child := range typed { + if value, found := walk(child); found { + return value, true + } + } + case []any: + for _, child := range typed { + if value, found := walk(child); found { + return value, true + } + } + } + return false, false + } + return walk(value) +} + +func readWorkflowFromList(rt *shortcut.RuntimeContext, baseID, workflowID string) (map[string]any, error) { + for offset := 0; offset < 10000; offset += 100 { + data, err := rt.CallMCPData(serverHelper, "list_workflows", map[string]any{"baseId": baseID, "limit": 100, "offset": offset}) + if err != nil { + return nil, err + } + workflows, found := findNamedObjectList(data, "workflows", "list", "items") + if !found { + return nil, fmt.Errorf("list_workflows response is missing the workflow list") + } + for _, workflow := range workflows { + if stringValue(workflow, "flowId", "workflowId", "id") == workflowID { + return workflow, nil + } + } + if len(workflows) < 100 { + return nil, fmt.Errorf("workflow %s is not present in list_workflows", workflowID) + } + } + return nil, fmt.Errorf("workflow list exceeded 10000 entries") +} + +func workflowIsRunning(workflow map[string]any) bool { + status := strings.ToUpper(stringValue(workflow, "status", "state")) + if status == "RUNNING" || status == "ENABLED" || status == "ACTIVE" { + return true + } + enabled, found := findBoolByKeys(workflow, "enabled", "isEnabled") + return found && enabled +} diff --git a/internal/shortcut/aitabletarget/resolver.go b/internal/shortcut/aitabletarget/resolver.go new file mode 100644 index 00000000..25baf098 --- /dev/null +++ b/internal/shortcut/aitabletarget/resolver.go @@ -0,0 +1,438 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +// Package aitabletarget provides deterministic AI Table URL and name +// resolution shared by read and write shortcuts. It never guesses among +// multiple candidates and treats an incomplete/unknown response as an error. +package aitabletarget + +import ( + "fmt" + "net/url" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + maxResolutionPages = 50 + aliDocsHost = "alidocs.dingtalk.com" +) + +// Reader is the read-only transport required for name resolution. +type Reader interface { + CallMCPData(product, tool string, params map[string]any) (map[string]any, error) +} + +// Target is the normalized identity carried by an AI Table URL. +type Target struct { + SchemaVersion string `json:"schemaVersion"` + Source string `json:"source"` + Kind string `json:"kind"` + BaseID string `json:"baseId"` + TableID string `json:"tableId,omitempty"` + ViewID string `json:"viewId,omitempty"` + RecordID string `json:"recordId,omitempty"` +} + +// Candidate is a stable ID/name pair returned by name resolution. +type Candidate struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Resolution is returned only after exactly one candidate is selected. +type Resolution struct { + Status string `json:"status"` + EntityType string `json:"entityType"` + Query string `json:"query"` + MatchType string `json:"matchType"` + Selected Candidate `json:"selected"` +} + +// ParseURL parses the documented DingTalk AI Table URL forms: +// +// https://alidocs.dingtalk.com/i/nodes/{baseId} +// https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId} +func ParseURL(raw string) (Target, error) { + raw = strings.TrimSpace(raw) + parsed, err := url.ParseRequestURI(raw) + if err != nil { + return Target{}, invalidURL(raw, fmt.Sprintf("URL 解析失败: %v", err)) + } + if !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), aliDocsHost) || parsed.User != nil { + return Target{}, invalidURL(raw, "仅接受 https://alidocs.dingtalk.com 的无凭据 URL") + } + + segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") + if len(segments) != 3 || segments[0] != "i" || segments[1] != "nodes" { + return Target{}, invalidURL(raw, "路径必须是 /i/nodes/{baseId}") + } + baseID, err := url.PathUnescape(segments[2]) + if err != nil || !validID(baseID) { + return Target{}, invalidURL(raw, "URL 路径中的 baseId 无效") + } + + values := parsed.Query() + nested, err := parseIframeQuery(values.Get("iframeQuery")) + if err != nil { + return Target{}, invalidURL(raw, err.Error()) + } + for key, nestedValues := range nested { + for _, value := range nestedValues { + values.Add(key, value) + } + } + + tableID, err := uniqueQueryID(values, "sheetId", "tableId") + if err != nil { + return Target{}, invalidURL(raw, err.Error()) + } + viewID, err := uniqueQueryID(values, "viewId") + if err != nil { + return Target{}, invalidURL(raw, err.Error()) + } + recordID, err := uniqueQueryID(values, "recordId") + if err != nil { + return Target{}, invalidURL(raw, err.Error()) + } + if viewID != "" && tableID == "" { + return Target{}, invalidURL(raw, "viewId 缺少所属 sheetId/tableId") + } + if recordID != "" && tableID == "" { + return Target{}, invalidURL(raw, "recordId 缺少所属 sheetId/tableId") + } + + kind := "base" + if tableID != "" { + kind = "table" + } + if viewID != "" { + kind = "view" + } + if recordID != "" { + kind = "record" + } + return Target{ + SchemaVersion: "aitable.target.v1", + Source: "url", + Kind: kind, + BaseID: strings.TrimSpace(baseID), + TableID: tableID, + ViewID: viewID, + RecordID: recordID, + }, nil +} + +func parseIframeQuery(raw string) (url.Values, error) { + if strings.TrimSpace(raw) == "" { + return url.Values{}, nil + } + value := raw + for attempt := 0; attempt < 2 && !strings.Contains(value, "="); attempt++ { + decoded, err := url.QueryUnescape(value) + if err != nil { + return nil, fmt.Errorf("iframeQuery 解码失败: %w", err) + } + if decoded == value { + break + } + value = decoded + } + values, err := url.ParseQuery(value) + if err != nil { + return nil, fmt.Errorf("iframeQuery 不是合法查询串: %w", err) + } + return values, nil +} + +func uniqueQueryID(values url.Values, keys ...string) (string, error) { + unique := map[string]struct{}{} + for _, key := range keys { + for _, raw := range values[key] { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if !validID(value) { + return "", fmt.Errorf("%s 包含无效 ID", key) + } + unique[value] = struct{}{} + } + } + if len(unique) > 1 { + return "", fmt.Errorf("%s 出现互相冲突的多个值", strings.Join(keys, "/")) + } + for value := range unique { + return value, nil + } + return "", nil +} + +func validID(value string) bool { + value = strings.TrimSpace(value) + if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") { + return false + } + for _, r := range value { + if r <= 0x20 || r == 0x7f { + return false + } + } + return true +} + +// ResolveBaseName resolves one Base by exact name. A substring match is used +// only when allowFuzzy is true. Zero/multiple candidates and incomplete pages +// are structured non-zero errors. +func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, error) { + query := strings.TrimSpace(name) + if query == "" { + return Resolution{}, apperrors.NewValidation("Base 名称不能为空") + } + all := make([]Candidate, 0) + cursor := "" + seen := map[string]bool{} + for page := 1; page <= maxResolutionPages; page++ { + params := map[string]any{"query": query} + if cursor != "" { + params["cursor"] = cursor + } + data, err := reader.CallMCPData("aitable", "search_bases", params) + if err != nil { + return Resolution{}, err + } + items, found, listErr := findObjectList(data, "bases", "items", "list", "result", "records") + if listErr != nil { + return Resolution{}, invalidResponse("base", query, listErr.Error()) + } + if !found { + return Resolution{}, invalidResponse("base", query, "search_bases 响应缺少候选列表") + } + for index, item := range items { + id := firstString(item, "baseId", "base_id", "id") + candidateName := firstString(item, "baseName", "name", "title") + if id == "" || candidateName == "" { + return Resolution{}, invalidResponse("base", query, + fmt.Sprintf("search_bases 候选 %d 缺少 baseId 或名称", index)) + } + all = append(all, Candidate{ID: id, Name: candidateName}) + } + next, hasMore, hasMoreKnown := pagination(data) + if next == "" { + if hasMoreKnown && hasMore { + return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor") + } + return selectCandidate("base", query, dedupe(all), allowFuzzy) + } + if seen[next] || next == cursor { + return Resolution{}, incomplete("base", query, all, "search_bases cursor 停滞或成环") + } + seen[next] = true + cursor = next + } + return Resolution{}, incomplete("base", query, all, fmt.Sprintf("达到 %d 页安全上限", maxResolutionPages)) +} + +// ResolveTableName resolves one table within a known Base using get_tables. +func ResolveTableName(reader Reader, baseID, name string, allowFuzzy bool) (Resolution, error) { + baseID = strings.TrimSpace(baseID) + query := strings.TrimSpace(name) + if !validID(baseID) || query == "" { + return Resolution{}, apperrors.NewValidation("解析数据表名称需要合法 baseId 和非空名称") + } + data, err := reader.CallMCPData("aitable", "get_tables", map[string]any{"baseId": baseID}) + if err != nil { + return Resolution{}, err + } + items, found, listErr := findObjectList(data, "tables", "items", "list", "result", "records") + if listErr != nil { + return Resolution{}, invalidResponse("table", query, listErr.Error()) + } + if !found { + return Resolution{}, invalidResponse("table", query, "get_tables 响应缺少 tables 列表") + } + candidates := make([]Candidate, 0, len(items)) + for index, item := range items { + id := firstString(item, "tableId", "table_id", "id") + candidateName := firstString(item, "tableName", "name", "title") + if id == "" || candidateName == "" { + return Resolution{}, invalidResponse("table", query, + fmt.Sprintf("get_tables 候选 %d 缺少 tableId 或名称", index)) + } + candidates = append(candidates, Candidate{ID: id, Name: candidateName}) + } + return selectCandidate("table", query, dedupe(candidates), allowFuzzy) +} + +func selectCandidate(entityType, query string, candidates []Candidate, allowFuzzy bool) (Resolution, error) { + exact := make([]Candidate, 0) + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(candidate.Name), query) { + exact = append(exact, candidate) + } + } + selected := exact + matchType := "exact" + if len(selected) == 0 && allowFuzzy { + matchType = "fuzzy" + needle := strings.ToLower(query) + for _, candidate := range candidates { + if strings.Contains(strings.ToLower(candidate.Name), needle) { + selected = append(selected, candidate) + } + } + } + if len(selected) == 0 { + return Resolution{}, resolutionError("not_found", entityType, query, candidates) + } + if len(selected) > 1 { + return Resolution{}, resolutionError("ambiguous", entityType, query, selected) + } + return Resolution{ + Status: "resolved", EntityType: entityType, Query: query, + MatchType: matchType, Selected: selected[0], + }, nil +} + +func findObjectList(data map[string]any, keys ...string) ([]map[string]any, bool, error) { + if data == nil { + return nil, false, nil + } + invalid := make([]string, 0) + for _, key := range keys { + value, exists := data[key] + if !exists { + continue + } + if list, ok := value.([]any); ok { + out := make([]map[string]any, 0, len(list)) + for index, row := range list { + object, ok := row.(map[string]any) + if !ok { + return nil, false, fmt.Errorf("候选列表 %s[%d] 必须是对象,got %T", key, index, row) + } + out = append(out, object) + } + return out, true, nil + } + if object, ok := value.(map[string]any); ok { + if list, found, err := findObjectList(object, keys...); found || err != nil { + return list, found, err + } + continue + } + invalid = append(invalid, fmt.Sprintf("%s=%T", key, value)) + } + for _, envelope := range []string{"data", "result", "response"} { + if object, ok := data[envelope].(map[string]any); ok { + if list, found, err := findObjectList(object, keys...); found || err != nil { + return list, found, err + } + } + } + if len(invalid) > 0 { + return nil, false, fmt.Errorf("候选列表字段类型无效: %v", invalid) + } + return nil, false, nil +} + +func firstString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func pagination(data map[string]any) (cursor string, hasMore bool, hasMoreKnown bool) { + if data == nil { + return "", false, false + } + for _, key := range []string{"nextCursor", "cursor"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + cursor = strings.TrimSpace(value) + break + } + } + if value, ok := data["hasMore"].(bool); ok { + hasMore, hasMoreKnown = value, true + } + for _, key := range []string{"data", "result", "page", "pagination", "meta"} { + if nested, ok := data[key].(map[string]any); ok { + next, more, known := pagination(nested) + if cursor == "" { + cursor = next + } + if !hasMoreKnown && known { + hasMore, hasMoreKnown = more, true + } + } + } + return cursor, hasMore, hasMoreKnown +} + +func dedupe(values []Candidate) []Candidate { + seen := map[string]bool{} + out := make([]Candidate, 0, len(values)) + for _, value := range values { + if value.ID == "" || seen[value.ID] { + continue + } + seen[value.ID] = true + out = append(out, value) + } + return out +} + +func invalidURL(raw, reason string) error { + return apperrors.NewValidation("无法解析 AI 表格 URL", + apperrors.WithReason("invalid_aitable_url"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithHint(reason), + apperrors.WithDetails(map[string]any{"url": raw}), + ) +} + +func resolutionError(status, entityType, query string, candidates []Candidate) error { + return apperrors.NewValidation(fmt.Sprintf("%s target %s: %q", entityType, status, query), + apperrors.WithReason("target_"+status), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithDetails(map[string]any{ + "status": status, "entityType": entityType, "query": query, + "candidates": candidates, + }), + ) +} + +func incomplete(entityType, query string, candidates []Candidate, cause string) error { + return apperrors.NewAPI("目标解析候选集不完整,不能安全选择", + apperrors.WithReason("target_incomplete"), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithHint(cause), + apperrors.WithDetails(map[string]any{ + "status": "incomplete", "entityType": entityType, "query": query, + "candidates": dedupe(candidates), + }), + ) +} + +func invalidResponse(entityType, query, cause string) error { + return apperrors.NewAPI("目标解析接口返回未知结构,不能把它当作未找到", + apperrors.WithReason("target_invalid_response"), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint(cause), + apperrors.WithDetails(map[string]any{"entityType": entityType, "query": query}), + ) +} diff --git a/internal/shortcut/aitabletarget/resolver_test.go b/internal/shortcut/aitabletarget/resolver_test.go new file mode 100644 index 00000000..8a011a2a --- /dev/null +++ b/internal/shortcut/aitabletarget/resolver_test.go @@ -0,0 +1,219 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitabletarget + +import ( + "errors" + "reflect" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +type resolverStep struct { + data map[string]any + err error +} + +type resolverReader struct { + steps []resolverStep + calls []map[string]any + tools []string + server []string +} + +func (r *resolverReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + cloned := make(map[string]any, len(params)) + for key, value := range params { + cloned[key] = value + } + r.calls = append(r.calls, cloned) + r.tools = append(r.tools, tool) + r.server = append(r.server, product) + index := len(r.calls) - 1 + if index >= len(r.steps) { + return nil, errors.New("unexpected resolver call") + } + return r.steps[index].data, r.steps[index].err +} + +func TestCrossPlatformCoverageParseAITableURL(t *testing.T) { + tests := []struct { + name string + url string + want Target + }{ + { + name: "base", + url: "https://alidocs.dingtalk.com/i/nodes/base-1", + want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "base", BaseID: "base-1"}, + }, + { + name: "table and view nested", + url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1%26viewId%3Dview-1", + want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "view", BaseID: "base-1", TableID: "table-1", ViewID: "view-1"}, + }, + { + name: "double encoded iframe", + url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%253Dtable-1%2526recordId%253Drecord-1", + want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "record", BaseID: "base-1", TableID: "table-1", RecordID: "record-1"}, + }, + { + name: "direct table query", + url: "https://alidocs.dingtalk.com/i/nodes/base-1?tableId=table-1", + want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "table", BaseID: "base-1", TableID: "table-1"}, + }, + { + name: "unrelated iframe query stays base", + url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=preview%3Dcompact", + want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "base", BaseID: "base-1"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ParseURL(test.url) + if err != nil { + t.Fatalf("ParseURL() error = %v", err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("ParseURL() = %#v, want %#v", got, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageParseAITableURLRejectsUnsafeOrAmbiguousInput(t *testing.T) { + for _, raw := range []string{ + "", + "http://alidocs.dingtalk.com/i/nodes/base", + "https://@alidocs.dingtalk.com/i/nodes/base", + "https://example.com/i/nodes/base", + "https://alidocs.dingtalk.com/i/nodes/", + "https://alidocs.dingtalk.com/i/nodes/base/extra", + "https://alidocs.dingtalk.com/i/nodes/base?viewId=view", + "https://alidocs.dingtalk.com/i/nodes/base?recordId=record", + "https://alidocs.dingtalk.com/i/nodes/base?sheetId=one&tableId=two", + } { + if _, err := ParseURL(raw); err == nil { + t.Errorf("ParseURL(%q) succeeded, want error", raw) + } + } +} + +func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{ + {data: map[string]any{"data": map[string]any{ + "bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理归档"}}, + "nextCursor": "next", + "hasMore": true, + }}}, + {data: map[string]any{"data": map[string]any{ + "bases": []any{map[string]any{"baseId": "b2", "baseName": "项目管理"}}, + "hasMore": false, + }}}, + }} + got, err := ResolveBaseName(reader, "项目管理", false) + if err != nil { + t.Fatalf("ResolveBaseName() error = %v", err) + } + if got.MatchType != "exact" || got.Selected.ID != "b2" { + t.Fatalf("resolution = %#v", got) + } + if len(reader.calls) != 2 || reader.calls[1]["cursor"] != "next" { + t.Fatalf("pagination calls = %#v", reader.calls) + } +} + +func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) { + data := map[string]any{"bases": []any{ + map[string]any{"baseId": "b1", "baseName": "项目管理归档"}, + map[string]any{"baseId": "b2", "baseName": "财务"}, + }} + reader := &resolverReader{steps: []resolverStep{{data: data}}} + if _, err := ResolveBaseName(reader, "项目", false); errorReason(err) != "target_not_found" { + t.Fatalf("fuzzy-disabled error = %v", err) + } + reader = &resolverReader{steps: []resolverStep{{data: data}}} + got, err := ResolveBaseName(reader, "项目", true) + if err != nil || got.MatchType != "fuzzy" || got.Selected.ID != "b1" { + t.Fatalf("fuzzy resolution = %#v, %v", got, err) + } + + reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{ + map[string]any{"baseId": "b1", "baseName": "项目"}, + map[string]any{"baseId": "b2", "baseName": "项目"}, + }}}}} + if _, err := ResolveBaseName(reader, "项目", false); errorReason(err) != "target_ambiguous" { + t.Fatalf("ambiguous error = %v", err) + } +} + +func TestCrossPlatformCoverageResolveNameDistinguishesEmptyFromUnknown(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{}}}}} + if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_not_found" { + t.Fatalf("legal empty list error = %v", err) + } + reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"success": true}}}} + if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_invalid_response" { + t.Fatalf("unknown response error = %v", err) + } + reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{}, "hasMore": true}}}} + if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_incomplete" { + t.Fatalf("incomplete response error = %v", err) + } + for name, data := range map[string]map[string]any{ + "wrong collection type": {"bases": map[string]any{}}, + "scalar candidate": {"bases": []any{"bad"}}, + "candidate missing id": {"bases": []any{map[string]any{"baseName": "missing"}}}, + } { + t.Run(name, func(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{{data: data}}} + if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_invalid_response" { + t.Fatalf("malformed candidate response error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageResolveTableNameE2E(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{{data: map[string]any{"data": map[string]any{"tables": []any{ + map[string]any{"tableId": "t1", "tableName": "任务"}, + map[string]any{"tableId": "t2", "tableName": "任务归档"}, + }}}}}} + got, err := ResolveTableName(reader, "base", "任务", false) + if err != nil || got.Selected.ID != "t1" || got.MatchType != "exact" { + t.Fatalf("table resolution = %#v, %v", got, err) + } + if reader.server[0] != "aitable" || reader.tools[0] != "get_tables" || reader.calls[0]["baseId"] != "base" { + t.Fatalf("table resolution call = %v/%v %#v", reader.server, reader.tools, reader.calls) + } +} + +func TestCrossPlatformCoverageResolveTableNameMalformedCandidateIsInvalidResponse(t *testing.T) { + for name, data := range map[string]map[string]any{ + "explicit empty is not found": {"tables": []any{}}, + "scalar candidate": {"tables": []any{1}}, + "candidate missing name": {"tables": []any{map[string]any{"tableId": "t1"}}}, + } { + t.Run(name, func(t *testing.T) { + reader := &resolverReader{steps: []resolverStep{{data: data}}} + _, err := ResolveTableName(reader, "base", "missing", false) + want := "target_invalid_response" + if name == "explicit empty is not found" { + want = "target_not_found" + } + if errorReason(err) != want { + t.Fatalf("resolution error = %v, want reason %s", err, want) + } + }) + } +} + +func errorReason(err error) string { + var typed *apperrors.Error + if errors.As(err, &typed) { + return typed.Reason + } + return "" +} diff --git a/internal/shortcut/builtin/aitable_semantic_catalog_test.go b/internal/shortcut/builtin/aitable_semantic_catalog_test.go new file mode 100644 index 00000000..f1010075 --- /dev/null +++ b/internal/shortcut/builtin/aitable_semantic_catalog_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_aitable.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "aitable" { + t.Fatalf("semantic catalog service = %q, want aitable", source.Service) + } + + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "aitable" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate registered AI Table Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 92 || len(source.Shortcuts) != 92 { + t.Fatalf("registered/catalog = %d/%d, want 92/92", len(registered), len(source.Shortcuts)) + } + + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed { + t.Errorf("%s: semantic decision is not reviewed", command) + } + if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic delta was not delivered exactly", command) + } + if item.Disposition != record.Disposition { + t.Errorf("%s: disposition = %q, want %q", command, item.Disposition, record.Disposition) + } + if item.Risk != record.Risk { + t.Errorf("%s: runtime risk = %q, reviewed risk = %q", command, item.Risk, record.Risk) + } + if !record.Public || item.Hidden || !shortcut.InPublicCatalog("aitable", command) { + t.Errorf("%s: reviewed available AI Table Shortcut must be public", command) + } + if item.Contract.Empty() { + t.Errorf("%s: public AI Table Shortcut has empty Contract", command) + continue + } + if strings.TrimSpace(item.Safety.Effect) == "" || strings.TrimSpace(item.Safety.Risk) == "" || + strings.TrimSpace(item.Safety.Confirmation) == "" || strings.TrimSpace(item.Safety.Idempotency) == "" { + t.Errorf("%s: public AI Table Shortcut has incomplete explicit Safety: %#v", command, item.Safety) + } + wantCLIPath := "aitable " + command + if item.Contract.Identity.CLIPath != wantCLIPath || item.Contract.Identity.PrimaryCLIPath != wantCLIPath { + t.Errorf("%s: identity paths = %q/%q, want %q", command, + item.Contract.Identity.CLIPath, item.Contract.Identity.PrimaryCLIPath, wantCLIPath) + } + if len(item.Contract.Selection.UseWhen) == 0 || len(item.Contract.Selection.AvoidWhen) == 0 { + t.Errorf("%s: selection contract must contain positive and negative routing", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 9c264261..f5db8b38 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -6,35 +6,98 @@ package shortcut // command discovery and skill generation. func generatedPublicShortcutCatalog() map[string]struct{} { return map[string]struct{}{ + "aitable\u0000+advperm-disable": {}, + "aitable\u0000+advperm-enable": {}, + "aitable\u0000+attachment-put": {}, + "aitable\u0000+attachment-remove": {}, + "aitable\u0000+attachment-upload": {}, + "aitable\u0000+base-bootstrap": {}, + "aitable\u0000+base-copy": {}, + "aitable\u0000+base-delete": {}, "aitable\u0000+base-get": {}, + "aitable\u0000+base-get-primary-doc-id": {}, "aitable\u0000+base-list": {}, + "aitable\u0000+base-schema-snapshot": {}, "aitable\u0000+base-search": {}, + "aitable\u0000+base-update": {}, + "aitable\u0000+chart-delete": {}, "aitable\u0000+chart-get": {}, + "aitable\u0000+chart-share-get": {}, + "aitable\u0000+chart-share-update": {}, + "aitable\u0000+chart-update": {}, "aitable\u0000+chart-widgets-example": {}, + "aitable\u0000+dashboard-arrange": {}, "aitable\u0000+dashboard-config-example": {}, + "aitable\u0000+dashboard-delete": {}, "aitable\u0000+dashboard-get": {}, + "aitable\u0000+dashboard-share-get": {}, + "aitable\u0000+dashboard-share-update": {}, + "aitable\u0000+dashboard-update": {}, + "aitable\u0000+export-data": {}, + "aitable\u0000+field-delete": {}, "aitable\u0000+field-get": {}, + "aitable\u0000+field-update": {}, "aitable\u0000+find-record": {}, + "aitable\u0000+form-delete": {}, + "aitable\u0000+form-field-hide": {}, "aitable\u0000+form-field-list": {}, + "aitable\u0000+form-field-update": {}, "aitable\u0000+form-list": {}, "aitable\u0000+form-share-get": {}, + "aitable\u0000+form-share-update": {}, + "aitable\u0000+form-update": {}, + "aitable\u0000+import-data": {}, + "aitable\u0000+import-upload": {}, "aitable\u0000+list-tables": {}, + "aitable\u0000+record-bulk-patch": {}, + "aitable\u0000+record-delete": {}, "aitable\u0000+record-history-list": {}, + "aitable\u0000+record-primary-doc-create": {}, + "aitable\u0000+record-primary-doc-get": {}, "aitable\u0000+record-query": {}, "aitable\u0000+record-query-empty": {}, "aitable\u0000+record-share-links": {}, "aitable\u0000+record-share-url": {}, + "aitable\u0000+record-update": {}, + "aitable\u0000+record-upsert": {}, + "aitable\u0000+record-upsert-by-key": {}, "aitable\u0000+resolve-base": {}, "aitable\u0000+resolve-table": {}, + "aitable\u0000+role-create": {}, + "aitable\u0000+role-delete": {}, + "aitable\u0000+role-get": {}, "aitable\u0000+role-list": {}, + "aitable\u0000+role-update": {}, + "aitable\u0000+section-create": {}, + "aitable\u0000+section-delete": {}, "aitable\u0000+section-list-empty": {}, "aitable\u0000+section-list-nodes": {}, + "aitable\u0000+section-move-node": {}, + "aitable\u0000+section-rename": {}, + "aitable\u0000+section-reorder": {}, + "aitable\u0000+table-copy": {}, + "aitable\u0000+table-delete": {}, "aitable\u0000+table-get": {}, + "aitable\u0000+table-update": {}, "aitable\u0000+template-search": {}, + "aitable\u0000+url-resolve": {}, + "aitable\u0000+view-delete": {}, + "aitable\u0000+view-duplicate": {}, "aitable\u0000+view-get": {}, "aitable\u0000+view-get-frozen-cols": {}, "aitable\u0000+view-get-lock": {}, "aitable\u0000+view-get-row-height": {}, + "aitable\u0000+view-lock": {}, + "aitable\u0000+view-preset-apply": {}, + "aitable\u0000+view-set-fill-color-rule": {}, + "aitable\u0000+view-set-frozen-cols": {}, + "aitable\u0000+view-set-row-height": {}, + "aitable\u0000+view-update": {}, + "aitable\u0000+workflow-deploy": {}, + "aitable\u0000+workflow-disable": {}, + "aitable\u0000+workflow-enable": {}, + "aitable\u0000+workflow-get": {}, + "aitable\u0000+workflow-list": {}, "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, "attendance\u0000+get-adjustment-rule": {}, diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index f208e667..df1ed6bd 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -160,7 +160,7 @@ func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[stri if rt.DryRun() { return nil, dryRunWriteError(product, tool) } - return rt.callMCPData(product, tool, params) + return rt.callMCPWriteData(product, tool, params) } func dryRunWriteError(product, tool string) error { @@ -209,6 +209,30 @@ func (rt *RuntimeContext) callMCPReadData(product, tool string, params map[strin return out, nil } +func (rt *RuntimeContext) callMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) { + if params == nil { + params = map[string]any{} + } + text, err := helpers.CallMCPToolTextOnServer(product, tool, params) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return nil, apperrors.NewAPI("MCP write tool returned no business result; the remote effect is unknown", + apperrors.WithOperation(product+"/"+tool), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason("empty_tool_response"), + ) + } + var out map[string]any + if err := json.Unmarshal([]byte(text), &out); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return out, nil +} + // Output prints a (typically reshaped/projected) payload honouring the root // --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean, // composed result instead of the raw MCP response — the output-projection diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index edf86236..fc28cc29 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -16,6 +16,9 @@ var semanticCatalogJSON []byte //go:embed semantic_catalog_doc.json var docSemanticCatalogJSON []byte +//go:embed semantic_catalog_aitable.json +var aitableSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -36,6 +39,7 @@ type semanticCatalogRecord struct { var reviewedSemanticCatalog = mustLoadSemanticCatalogs( semanticCatalogJSON, docSemanticCatalogJSON, + aitableSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_aitable.json b/internal/shortcut/semantic_catalog_aitable.json new file mode 100644 index 00000000..f824e8c8 --- /dev/null +++ b/internal/shortcut/semantic_catalog_aitable.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "service": "aitable", + "default_availability": "available", + "shortcuts": { + "+base-list": {"disposition":"semantic_adapter","semantic_delta":"把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。","risk":"read","public":true,"reviewed":true}, + "+base-search": {"disposition":"semantic_adapter","semantic_delta":"按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。","risk":"read","public":true,"reviewed":true}, + "+base-get": {"disposition":"schema_leaf","semantic_delta":"保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。","risk":"read","public":true,"reviewed":true}, + "+base-get-primary-doc-id": {"disposition":"schema_leaf","semantic_delta":"已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+base-update": {"disposition":"schema_leaf","semantic_delta":"更新 Base 名称和备注的一对一写入,复用统一确认门。","risk":"write","public":true,"reviewed":true}, + "+base-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+base-copy": {"disposition":"semantic_adapter","semantic_delta":"统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。","risk":"write","public":true,"reviewed":true}, + "+base-schema-snapshot": {"disposition":"primary_smart","semantic_delta":"完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。","risk":"read","public":true,"reviewed":true}, + "+base-bootstrap": {"disposition":"primary_smart","semantic_delta":"声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。","risk":"write","public":true,"reviewed":true}, + "+url-resolve": {"disposition":"primary_smart","semantic_delta":"严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。","risk":"read","public":true,"reviewed":true}, + + "+table-get": {"disposition":"semantic_adapter","semantic_delta":"批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。","risk":"read","public":true,"reviewed":true}, + "+table-copy": {"disposition":"primary_smart","semantic_delta":"在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。","risk":"write","public":true,"reviewed":true}, + "+table-update": {"disposition":"schema_leaf","semantic_delta":"统一表名、备注和行命名规则的 PATCH 风格更新。","risk":"write","public":true,"reviewed":true}, + "+table-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除数据表的一对一入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+field-get": {"disposition":"semantic_adapter","semantic_delta":"批量获取字段完整类型配置,避免仅返回名称的损失投影。","risk":"read","public":true,"reviewed":true}, + "+field-update": {"disposition":"semantic_adapter","semantic_delta":"统一字段名称、类型配置和 AI 配置的增量更新输入。","risk":"write","public":true,"reviewed":true}, + "+field-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除字段的一对一入口,显式发布数据丢失风险。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+record-query": {"disposition":"semantic_adapter","semantic_delta":"统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。","risk":"read","public":true,"reviewed":true}, + "+record-update": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。","risk":"write","public":true,"reviewed":true}, + "+record-delete": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。","risk":"high-risk-write","public":true,"reviewed":true}, + "+record-query-empty": {"disposition":"semantic_adapter","semantic_delta":"扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。","risk":"read","public":true,"reviewed":true}, + "+record-history-list": {"disposition":"schema_leaf","semantic_delta":"按 recordId 查询变更历史的一对一只读入口。","risk":"read","public":true,"reviewed":true}, + "+record-share-url": {"disposition":"semantic_adapter","semantic_delta":"将最多 20 个 recordId 统一映射为可分享链接列表。","risk":"read","public":true,"reviewed":true}, + "+record-upsert": {"disposition":"primary_smart","semantic_delta":"按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。","risk":"write","public":true,"reviewed":true}, + "+record-upsert-by-key": {"disposition":"primary_smart","semantic_delta":"先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。","risk":"write","public":true,"reviewed":true}, + "+record-bulk-patch": {"disposition":"primary_smart","semantic_delta":"先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。","risk":"high-risk-write","public":true,"reviewed":true}, + "+record-primary-doc-get": {"disposition":"schema_leaf","semantic_delta":"读取记录主键文档 nodeId 的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+record-primary-doc-create": {"disposition":"semantic_adapter","semantic_delta":"以幂等意图为 primaryDoc 字段创建关联文档。","risk":"write","public":true,"reviewed":true}, + + "+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。","risk":"read","public":true,"reviewed":true}, + "+attachment-upload": {"disposition":"schema_leaf","semantic_delta":"申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。","risk":"write","public":true,"reviewed":true}, + "+attachment-put": {"disposition":"primary_smart","semantic_delta":"完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。","risk":"write","public":true,"reviewed":true}, + "+attachment-remove": {"disposition":"primary_smart","semantic_delta":"清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+view-get": {"disposition":"semantic_adapter","semantic_delta":"读取视图完整列顺序、筛选、排序和分组配置。","risk":"read","public":true,"reviewed":true}, + "+view-update": {"disposition":"semantic_adapter","semantic_delta":"统一视图名称、描述、可见列、筛选、排序和分组配置更新。","risk":"write","public":true,"reviewed":true}, + "+view-preset-apply": {"disposition":"primary_smart","semantic_delta":"按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。","risk":"write","public":true,"reviewed":true}, + "+view-duplicate": {"disposition":"schema_leaf","semantic_delta":"复制现有视图配置并生成新视图的一对一入口。","risk":"write","public":true,"reviewed":true}, + "+view-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除视图的一对一入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+view-get-lock": {"disposition":"schema_leaf","semantic_delta":"读取视图锁定状态的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+view-lock": {"disposition":"semantic_adapter","semantic_delta":"用默认锁定与 --off 解锁统一表达视图锁状态切换。","risk":"write","public":true,"reviewed":true}, + "+view-get-frozen-cols": {"disposition":"schema_leaf","semantic_delta":"读取 Grid 视图冻结列数的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+view-set-frozen-cols": {"disposition":"semantic_adapter","semantic_delta":"设置冻结列数并以 0 明确表达取消冻结。","risk":"write","public":true,"reviewed":true}, + "+view-get-row-height": {"disposition":"schema_leaf","semantic_delta":"读取视图行高像素值的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+view-set-row-height": {"disposition":"semantic_adapter","semantic_delta":"把行高限制为服务支持的 32/56/88/128 像素档位后写入。","risk":"write","public":true,"reviewed":true}, + "+view-set-fill-color-rule": {"disposition":"semantic_adapter","semantic_delta":"以全量覆盖语义设置条件填色规则,并用空数组明确清空。","risk":"write","public":true,"reviewed":true}, + + "+form-list": {"disposition":"semantic_adapter","semantic_delta":"从表的视图中稳定筛出表单视图并返回表单目录。","risk":"read","public":true,"reviewed":true}, + "+form-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除表单视图的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true}, + "+form-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新表单标题和描述。","risk":"write","public":true,"reviewed":true}, + "+form-field-list": {"disposition":"semantic_adapter","semantic_delta":"列出表单当前字段顺序、可见性、必填和描述配置。","risk":"read","public":true,"reviewed":true}, + "+form-field-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新表单字段必填状态与描述。","risk":"write","public":true,"reviewed":true}, + "+form-field-hide": {"disposition":"semantic_adapter","semantic_delta":"用隐藏/显示布尔语义切换表单字段可见性。","risk":"write","public":true,"reviewed":true}, + "+form-share-get": {"disposition":"schema_leaf","semantic_delta":"读取当前视图分享表单配置的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+form-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启或关闭指定视图的分享表单。","risk":"write","public":true,"reviewed":true}, + + "+workflow-enable": {"disposition":"schema_leaf","semantic_delta":"启用指定自动化工作流的一对一入口。","risk":"write","public":true,"reviewed":true}, + "+workflow-disable": {"disposition":"schema_leaf","semantic_delta":"禁用工作流会影响业务自动化,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+workflow-get": {"disposition":"schema_leaf","semantic_delta":"读取单个自动化工作流详情的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+workflow-list": {"disposition":"schema_leaf","semantic_delta":"分页列出 Base 自动化工作流的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+workflow-deploy": {"disposition":"primary_smart","semantic_delta":"创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。","risk":"write","public":true,"reviewed":true}, + + "+dashboard-config-example": {"disposition":"schema_leaf","semantic_delta":"返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。","risk":"read","public":true,"reviewed":true}, + "+dashboard-get": {"disposition":"semantic_adapter","semantic_delta":"读取仪表盘详情及 charts summary,作为图表操作导航入口。","risk":"read","public":true,"reviewed":true}, + "+dashboard-update": {"disposition":"schema_leaf","semantic_delta":"更新仪表盘配置的一对一入口。","risk":"write","public":true,"reviewed":true}, + "+dashboard-delete": {"disposition":"schema_leaf","semantic_delta":"级联删除仪表盘及图表,显式发布不可逆高风险事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+dashboard-arrange": {"disposition":"semantic_adapter","semantic_delta":"触发服务端智能布局重排并保留统一写入确认。","risk":"write","public":true,"reviewed":true}, + "+dashboard-share-get": {"disposition":"schema_leaf","semantic_delta":"读取仪表盘分享配置的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+dashboard-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启、关闭和设置仪表盘分享类型。","risk":"write","public":true,"reviewed":true}, + + "+chart-widgets-example": {"disposition":"schema_leaf","semantic_delta":"返回所有图表类型的 widget config 示例,供构造合法图表配置。","risk":"read","public":true,"reviewed":true}, + "+chart-get": {"disposition":"schema_leaf","semantic_delta":"读取指定图表完整配置的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+chart-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新图表配置或布局,并要求显式 config 输入。","risk":"write","public":true,"reviewed":true}, + "+chart-delete": {"disposition":"schema_leaf","semantic_delta":"删除图表及布局项,显式发布不可逆高风险事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+chart-share-get": {"disposition":"schema_leaf","semantic_delta":"读取图表分享配置的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+chart-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启、关闭和设置图表分享类型。","risk":"write","public":true,"reviewed":true}, + + "+export-data": {"disposition":"primary_smart","semantic_delta":"统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。","risk":"read","public":true,"reviewed":true}, + "+import-upload": {"disposition":"schema_leaf","semantic_delta":"申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。","risk":"write","public":true,"reviewed":true}, + "+import-data": {"disposition":"semantic_adapter","semantic_delta":"统一将已上传文件导入新表或追加到已有表的目标语义。","risk":"write","public":true,"reviewed":true}, + + "+advperm-enable": {"disposition":"schema_leaf","semantic_delta":"开启 Base 高级权限总开关的一对一入口。","risk":"write","public":true,"reviewed":true}, + "+advperm-disable": {"disposition":"schema_leaf","semantic_delta":"关闭高级权限会使自定义角色失效,显式发布高风险事实。","risk":"high-risk-write","public":true,"reviewed":true}, + "+role-list": {"disposition":"schema_leaf","semantic_delta":"列出 Base 全部自定义角色的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+role-get": {"disposition":"schema_leaf","semantic_delta":"读取单个角色完整权限配置的一对一入口。","risk":"read","public":true,"reviewed":true}, + "+role-create": {"disposition":"semantic_adapter","semantic_delta":"统一角色名称、成员与权限配置的创建输入。","risk":"write","public":true,"reviewed":true}, + "+role-update": {"disposition":"semantic_adapter","semantic_delta":"按 PATCH 语义增量更新角色配置,未提供字段保持不变。","risk":"write","public":true,"reviewed":true}, + "+role-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除自定义角色的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true}, + + "+section-create": {"disposition":"semantic_adapter","semantic_delta":"在 Base 目录树创建用于组织表和仪表盘的文件夹。","risk":"write","public":true,"reviewed":true}, + "+section-rename": {"disposition":"schema_leaf","semantic_delta":"重命名目录文件夹的一对一入口。","risk":"write","public":true,"reviewed":true}, + "+section-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除目录文件夹的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true}, + "+section-reorder": {"disposition":"semantic_adapter","semantic_delta":"按当前父目录语义调整文件夹展示顺序。","risk":"write","public":true,"reviewed":true}, + "+section-list-empty": {"disposition":"semantic_adapter","semantic_delta":"扫描目录树并只返回没有子节点的空文件夹。","risk":"read","public":true,"reviewed":true}, + "+section-list-nodes": {"disposition":"semantic_adapter","semantic_delta":"列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。","risk":"read","public":true,"reviewed":true}, + "+section-move-node": {"disposition":"semantic_adapter","semantic_delta":"统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。","risk":"write","public":true,"reviewed":true}, + + "+find-record": {"disposition":"semantic_adapter","semantic_delta":"将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。","risk":"read","public":true,"reviewed":true}, + "+list-tables": {"disposition":"semantic_adapter","semantic_delta":"把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。","risk":"read","public":true,"reviewed":true}, + "+record-share-links": {"disposition":"primary_smart","semantic_delta":"对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。","risk":"read","public":true,"reviewed":true}, + "+resolve-base": {"disposition":"primary_smart","semantic_delta":"按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。","risk":"read","public":true,"reviewed":true}, + "+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/aitable_resolver_e2e_test.go b/internal/shortcut/smart/aitable_resolver_e2e_test.go new file mode 100644 index 00000000..00821dbb --- /dev/null +++ b/internal/shortcut/smart/aitable_resolver_e2e_test.go @@ -0,0 +1,124 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package smart + +import ( + "bytes" + "context" + "errors" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type aitableResolverCaller struct { + text string + calls int + tool string + args map[string]any +} + +func (c *aitableResolverCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + c.calls++ + c.tool = tool + c.args = args + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil +} +func (*aitableResolverCaller) Format() string { return "json" } +func (*aitableResolverCaller) DryRun() bool { return false } +func (*aitableResolverCaller) Fields() string { return "" } +func (*aitableResolverCaller) JQ() string { return "" } + +func runAITableResolverCLI(t *testing.T, caller *aitableResolverCaller, args ...string) (string, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(shortcut.Commands()...) + stdout := &bytes.Buffer{} + root.SetOut(stdout) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(args) + err := root.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageResolveBaseCLIExactMatchE2E(t *testing.T) { + caller := &aitableResolverCaller{text: `{"data":{"bases":[{"baseId":"b1","baseName":"项目归档"},{"baseId":"b2","baseName":"项目"}],"hasMore":false}}`} + out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目") + if err != nil { + t.Fatalf("resolve base CLI error = %v", err) + } + for _, want := range []string{`"resolved": true`, `"matchType": "exact"`, `"baseId": "b2"`} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Fatalf("output missing %s: %s", want, out) + } + } + if caller.tool != "search_bases" || caller.args["query"] != "项目" { + t.Fatalf("resolver call = %s %#v", caller.tool, caller.args) + } +} + +func TestCrossPlatformCoverageResolveBaseCLIAmbiguityIsFailureE2E(t *testing.T) { + caller := &aitableResolverCaller{text: `{"bases":[{"baseId":"b1","baseName":"项目"},{"baseId":"b2","baseName":"项目"}]}`} + out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目") + if err == nil || out != "" { + t.Fatalf("ambiguous resolver = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "target_ambiguous" { + t.Fatalf("ambiguous resolver error = %#v", err) + } +} + +func TestCrossPlatformCoverageResolveTableCLIFuzzyRequiresOptInE2E(t *testing.T) { + payload := `{"tables":[{"tableId":"t1","tableName":"任务归档"}]}` + caller := &aitableResolverCaller{text: payload} + out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-table", "--base", "base", "--name", "任务") + if err == nil || out != "" { + t.Fatalf("implicit fuzzy resolver = output:%q err:%v", out, err) + } + caller = &aitableResolverCaller{text: payload} + out, err = runAITableResolverCLI(t, caller, "aitable", "+resolve-table", "--base", "base", "--name", "任务", "--fuzzy") + if err != nil || !bytes.Contains([]byte(out), []byte(`"matchType": "fuzzy"`)) || !bytes.Contains([]byte(out), []byte(`"tableId": "t1"`)) { + t.Fatalf("explicit fuzzy resolver = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageResolveCLIUnknownResponseIsNotNotFoundE2E(t *testing.T) { + for name, payload := range map[string]string{ + "empty response": "", + "missing collection": `{"success":true}`, + "wrong collection type": `{"bases":{}}`, + "malformed candidate": `{"bases":[{"baseName":"项目"}]}`, + } { + t.Run(name, func(t *testing.T) { + caller := &aitableResolverCaller{text: payload} + out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目") + if err == nil || out != "" { + t.Fatalf("unknown response = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "target_invalid_response" { + t.Fatalf("unknown response error = %#v", err) + } + }) + } + + caller := &aitableResolverCaller{text: `{"bases":[]}`} + out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目") + if err == nil || out != "" { + t.Fatalf("explicit empty candidates = output:%q err:%v", out, err) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "target_not_found" { + t.Fatalf("explicit empty candidates error = %#v", err) + } +} diff --git a/internal/shortcut/smart/resolve_base.go b/internal/shortcut/smart/resolve_base.go index 0a45580d..478f6ad4 100644 --- a/internal/shortcut/smart/resolve_base.go +++ b/internal/shortcut/smart/resolve_base.go @@ -16,8 +16,8 @@ package smart import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitabletarget" ) // ResolveBase: resolve a 多维表 Base by name keyword into a single baseId. @@ -41,9 +41,9 @@ var ResolveBase = shortcut.Shortcut{ Command: "+resolve-base", Product: "aitable", Description: "按名称搜索多维表 Base 并解析出唯一 baseId(只读)", - Intent: "当你只知道某个多维表 Base 的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 baseId 时使用;" + - "内部按 --name 关键词调用 search_bases 搜索 Base,再在本地投影出每个候选的 baseId 和 name。" + - "如果只命中一个 Base 就直接返回它的 baseId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" + + Intent: "当你只知道某个多维表 Base 的名称、想把它解析成可直接用于后续工具的 baseId 时使用;" + + "内部完整分页搜索并优先做大小写不敏感的精确名称匹配,只有显式 --fuzzy 才允许关键词包含匹配。" + + "0 个或多个候选都会以结构化错误失败并返回候选,绝不替你猜选。" + "这是纯只读操作,只做搜索与本地投影,不会修改任何 Base。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ @@ -66,53 +66,30 @@ var ResolveBase = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "按名称搜索多维表 Base 并解析出唯一 baseId(只读)", - UseWhen: []string{"当你只知道某个多维表 Base 的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 baseId 时使用;内部按 --name 关键词调用 search_bases 搜索 Base,再在本地投影出每个候选的 baseId 和 name。如果只命中一个 Base 就直接返回它的 baseId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何 Base。"}, + UseWhen: []string{"当你只知道某个多维表 Base 的名称、想把它解析成可直接用于后续工具的 baseId 时使用;内部完整分页搜索并优先做大小写不敏感的精确名称匹配,只有显式 --fuzzy 才允许关键词包含匹配。0 个或多个候选都会以结构化错误失败并返回候选,绝不替你猜选。这是纯只读操作,只做搜索与本地投影,不会修改任何 Base。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws aitable +resolve-base --name 项目管理"}, }, }, Flags: []shortcut.Flag{ - {Name: "name", Type: shortcut.FlagString, Desc: "要搜索的 Base 名称关键词(必填)", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "要解析的 Base 名称", Required: true}, + {Name: "fuzzy", Type: shortcut.FlagBool, Default: "false", Desc: "精确名称无结果时允许包含匹配"}, }, Tips: []string{ `dws aitable +resolve-base --name 项目管理`, }, Execute: func(rt *shortcut.RuntimeContext) error { - // Search Bases by name. tool "search_bases" + arg "query" are taken - // verbatim from helpers base search (callAitableTool → server aitable). - data, err := rt.CallMCPData("aitable", "search_bases", map[string]any{ - "query": rt.Str("name"), - }) + resolution, err := aitabletarget.ResolveBaseName(rt, rt.Str("name"), rt.Bool("fuzzy")) if err != nil { return err } - - // Project candidates to {baseId, name}, defensively unwrapping the list. - items := resolveBaseItems(data) - candidates := make([]map[string]any, 0, len(items)) - for _, b := range items { - candidates = append(candidates, map[string]any{ - "baseId": resolveBaseID(b), - "name": resolveBaseName(b), - }) - } - - switch len(candidates) { - case 0: - return apperrors.NewValidation("没有找到名称包含 " + rt.Str("name") + " 的 Base") - case 1: - return rt.Output(map[string]any{ - "resolved": true, - "baseId": candidates[0]["baseId"], - "name": candidates[0]["name"], - }) - default: - return rt.Output(map[string]any{ - "resolved": false, - "count": len(candidates), - "candidates": candidates, - }) - } + return rt.Output(map[string]any{ + "resolved": true, + "status": resolution.Status, + "matchType": resolution.MatchType, + "baseId": resolution.Selected.ID, + "name": resolution.Selected.Name, + }) }, } diff --git a/internal/shortcut/smart/resolve_table.go b/internal/shortcut/smart/resolve_table.go index df4f3fe7..5791952b 100644 --- a/internal/shortcut/smart/resolve_table.go +++ b/internal/shortcut/smart/resolve_table.go @@ -20,8 +20,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitabletarget" ) // ResolveTable: resolve a 数据表 (table) inside one Base by name keyword into a @@ -47,11 +47,10 @@ var ResolveTable = shortcut.Shortcut{ Command: "+resolve-table", Product: "aitable", Description: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)", - Intent: "当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称或名称关键词、" + + Intent: "当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称、" + "想把它解析成可直接用于后续工具的 tableId 时使用;" + - "内部先用 get_tables(只传 baseId)列出该 Base 下的全部数据表,再在本地把每张表投影成 tableId、name," + - "并按 --name 关键词做大小写不敏感的包含匹配来筛选候选。" + - "如果只命中一张表就直接返回它的 tableId;如果命中多张则列出全部候选让你消歧,绝不替你瞎猜;如果一张都没命中则提示未找到。" + + "内部先列出全部数据表并优先做大小写不敏感的精确名称匹配,只有显式 --fuzzy 才允许包含匹配。" + + "0 个或多个候选都会以结构化错误失败并返回候选,绝不替你猜选。" + "这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ @@ -74,65 +73,32 @@ var ResolveTable = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读)", - UseWhen: []string{"当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称或名称关键词、想把它解析成可直接用于后续工具的 tableId 时使用;内部先用 get_tables(只传 baseId)列出该 Base 下的全部数据表,再在本地把每张表投影成 tableId、name,并按 --name 关键词做大小写不敏感的包含匹配来筛选候选。如果只命中一张表就直接返回它的 tableId;如果命中多张则列出全部候选让你消歧,绝不替你瞎猜;如果一张都没命中则提示未找到。这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。"}, + UseWhen: []string{"当你已经知道某个多维表 Base 的 baseId、又只记得里面某张数据表(table)的名称、想把它解析成可直接用于后续工具的 tableId 时使用;内部先列出全部数据表并优先做大小写不敏感的精确名称匹配,只有显式 --fuzzy 才允许包含匹配。0 个或多个候选都会以结构化错误失败并返回候选,绝不替你猜选。这是纯只读操作,只做列举、本地匹配与投影,不会创建、修改或删除任何数据表。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws aitable +resolve-table --base B --name 任务"}, }, }, Flags: []shortcut.Flag{ {Name: "base", Type: shortcut.FlagString, Desc: "Base ID(要在其内解析数据表的多维表)", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "要匹配的数据表名称关键词(必填)", Required: true}, + {Name: "name", Type: shortcut.FlagString, Desc: "要解析的数据表名称", Required: true}, + {Name: "fuzzy", Type: shortcut.FlagBool, Default: "false", Desc: "精确名称无结果时允许包含匹配"}, }, Tips: []string{ `dws aitable +resolve-table --base B --name 任务`, }, Execute: func(rt *shortcut.RuntimeContext) error { - // baseId mirrors list_tables / helpers tableGetCmd (get_tables). Omitting - // tableIds asks the server for every table in the base. - data, err := rt.CallMCPData("aitable", "get_tables", map[string]any{ - "baseId": rt.Str("base"), - }) + resolution, err := aitabletarget.ResolveTableName(rt, rt.Str("base"), rt.Str("name"), rt.Bool("fuzzy")) if err != nil { return err } - - // Project every table to {tableId, name}, defensively unwrapping the list. - items := resolveTableItems(data) - all := make([]map[string]any, 0, len(items)) - for _, t := range items { - all = append(all, map[string]any{ - "tableId": resolveTableID(t), - "name": resolveTableName(t), - }) - } - - // Filter locally by case-insensitive substring match on name. - needle := strings.ToLower(rt.Str("name")) - candidates := make([]map[string]any, 0, len(all)) - for _, c := range all { - name, _ := c["name"].(string) - if strings.Contains(strings.ToLower(name), needle) { - candidates = append(candidates, c) - } - } - - switch len(candidates) { - case 0: - return apperrors.NewValidation("Base 内没有名称包含 " + rt.Str("name") + " 的数据表") - case 1: - return rt.Output(map[string]any{ - "resolved": true, - "tableId": candidates[0]["tableId"], - "name": candidates[0]["name"], - "base": rt.Str("base"), - }) - default: - return rt.Output(map[string]any{ - "resolved": false, - "count": len(candidates), - "candidates": candidates, - }) - } + return rt.Output(map[string]any{ + "resolved": true, + "status": resolution.Status, + "matchType": resolution.MatchType, + "tableId": resolution.Selected.ID, + "name": resolution.Selected.Name, + "base": rt.Str("base"), + }) }, } diff --git a/pkg/paging/paging.go b/pkg/paging/paging.go index ecc4fcd0..e0d05ce3 100644 --- a/pkg/paging/paging.go +++ b/pkg/paging/paging.go @@ -15,9 +15,9 @@ // 继续取"循环。设计目标: // // 1. 调用方只需提供 fetcher(拿单页)和 cursor 提取函数,循环逻辑由本包处理 -// 2. 支持 --page-limit 安全阀(默认 50 页 ≈ 5000 条,0 = 无限制) +// 2. 支持 --page-limit 安全阀(调用方可用 DefaultPageLimit;0 = 无限制) // 3. 支持页间退避,避免触发上游限流(默认 200ms) -// 4. 任何一页失败时优雅降级:已取数据保留 + 返回最后一页 cursor,不抛错 +// 4. 任何一页失败时保留已取数据和可重试 cursor,并显式标记结果不完整 // // 当前已知调用方:dws aitable record query --all(PR-H);后续 chat message list // / sheet range query / mail search 也将复用本工具。 @@ -36,6 +36,9 @@ import ( type Page struct { Records []any NextCursor string + // TotalCount 是服务端报告的完整结果总数。未知时保持 nil;FetchAll + // 不会用 len(Records) 猜测或覆盖它。 + TotalCount *int } // Fetcher 是调用方需要实现的"取单页"函数。 @@ -47,12 +50,12 @@ type Page struct { // 返回给调用方,不直接中断流程。 type Fetcher func(ctx context.Context, cursor string) (Page, error) -// Options 控制 FetchAll 的行为。零值即为合理默认。 +// Options 控制 FetchAll 的行为。零值表示不限页数并使用默认页间退避。 type Options struct { // PageLimit 最大翻页次数。 // - 0 表示无限制(取到 NextCursor 为空为止) // - >0 达到该次数后立即停止,返回 LastCursor 让调用方手动续拉 - // 默认(DefaultPageLimit)= 50 页。 + // 面向终端用户的调用方可显式传 DefaultPageLimit 作为安全默认值。 PageLimit int // InterPageDelay 是页间退避时长。默认 200ms。 @@ -72,12 +75,24 @@ const ( DefaultInterPageDelay = 200 * time.Millisecond ) +// StopReason 是 FetchAll 停止翻页的稳定原因。 +type StopReason string + +const ( + StopComplete StopReason = "complete" + StopPageLimit StopReason = "page_limit" + StopFetchError StopReason = "fetch_error" + StopCanceled StopReason = "canceled" + StopCursorCycle StopReason = "cursor_cycle" +) + // Result 是 FetchAll 的最终返回。 // // - Records 已累计的所有数据 // - HasMore 触发 PageLimit 或被 fetcher 中断时为 true,可凭 LastCursor 续拉 // - LastCursor 最后成功取得的下一页 cursor(仅 HasMore=true 时有意义) -// - Pages 本次实际翻了多少页 +// - Pages 成功取得的页数;Attempts 包含失败的最后一次请求 +// - Complete 仅在服务端明确返回空 NextCursor 时为 true // - Partial 中途遇到错误时为 true,Records 包含错误发生前的数据 // - Err 中途错误(调用方应根据业务决定是否报警;本包不主动失败) type Result struct { @@ -85,7 +100,11 @@ type Result struct { HasMore bool LastCursor string Pages int + Attempts int + Complete bool Partial bool + StopReason StopReason + TotalCount *int Err error } @@ -98,14 +117,12 @@ type Result struct { // a) NextCursor == "" → 拉完,HasMore=false 返回 // b) 翻页数达 PageLimit → HasMore=true, LastCursor 保留断点 // c) ctx.Done() → HasMore=true, LastCursor 保留 -// d) fetcher 返回 error → Partial=true, Err 保留,HasMore 依赖最后一次成功的 cursor +// d) fetcher 返回 error → Partial=true, Err 保留,LastCursor 为失败请求的 cursor +// e) 服务端 cursor 停滞/成环 → Partial=true,避免无限循环和重复记录 // // 不会主动抛 error;上游可基于 Result.Err / Result.Partial 决定如何向用户报告。 func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { pageLimit := opts.PageLimit - if pageLimit == 0 { - pageLimit = DefaultPageLimit - } delay := opts.InterPageDelay if delay == 0 { delay = DefaultInterPageDelay @@ -115,7 +132,13 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { allRecords []any cursor = opts.InitialCursor pages int + attempts int + totalCount *int + seen = map[string]struct{}{} ) + if cursor != "" { + seen[cursor] = struct{}{} + } for { // ctx 已取消:保留断点 cursor 优雅退出 @@ -125,7 +148,10 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { HasMore: cursor != "", LastCursor: cursor, Pages: pages, + Attempts: attempts, Partial: true, + StopReason: StopCanceled, + TotalCount: totalCount, Err: err, } } @@ -139,15 +165,19 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { HasMore: cursor != "", LastCursor: cursor, Pages: pages, + Attempts: attempts, Partial: true, + StopReason: StopCanceled, + TotalCount: totalCount, Err: ctx.Err(), } case <-time.After(delay): } } - page, err := fetcher(ctx, cursor) - pages++ + requestCursor := cursor + page, err := fetcher(ctx, requestCursor) + attempts++ if err != nil { // 优雅降级:保留已累计数据 + 记录错误 @@ -155,14 +185,22 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { return Result{ Records: allRecords, HasMore: true, - LastCursor: cursor, + LastCursor: requestCursor, Pages: pages, + Attempts: attempts, Partial: true, + StopReason: StopFetchError, + TotalCount: totalCount, Err: err, } } allRecords = append(allRecords, page.Records...) + pages++ + if page.TotalCount != nil && totalCount == nil { + value := *page.TotalCount + totalCount = &value + } cursor = page.NextCursor // 终止条件:服务端已无更多数据 @@ -172,17 +210,52 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { HasMore: false, LastCursor: "", Pages: pages, + Attempts: attempts, + Complete: true, + StopReason: StopComplete, + TotalCount: totalCount, } } - // 终止条件:达到安全阀 - if pages >= pageLimit { + if cursor == requestCursor { return Result{ Records: allRecords, HasMore: true, LastCursor: cursor, Pages: pages, + Attempts: attempts, + Partial: true, + StopReason: StopCursorCycle, + TotalCount: totalCount, + Err: ErrCursorCycle, + } + } + if _, exists := seen[cursor]; exists { + return Result{ + Records: allRecords, + HasMore: true, + LastCursor: cursor, + Pages: pages, + Attempts: attempts, + Partial: true, + StopReason: StopCursorCycle, + TotalCount: totalCount, + Err: ErrCursorCycle, + } + } + seen[cursor] = struct{}{} + + // 终止条件:达到安全阀 + if pageLimit > 0 && pages >= pageLimit { + return Result{ + Records: allRecords, + HasMore: true, + LastCursor: cursor, + Pages: pages, + Attempts: attempts, Partial: false, + StopReason: StopPageLimit, + TotalCount: totalCount, Err: ErrPageLimitReached, } } @@ -193,3 +266,7 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { // 上游可通过 errors.Is(result.Err, ErrPageLimitReached) 判定,决定提示用户 // "数据被截断,请用 --cursor 续拉"。 var ErrPageLimitReached = errors.New("paging: page-limit reached, more data available via LastCursor") + +// ErrCursorCycle 表示服务端返回了当前或曾用过的 cursor。继续翻页会无限循环 +// 或重复累计记录,因此 FetchAll 会保留当前结果并以不完整状态停止。 +var ErrCursorCycle = errors.New("paging: next cursor stalled or formed a cycle") diff --git a/pkg/paging/paging_test.go b/pkg/paging/paging_test.go index 478f619f..2a7e5080 100644 --- a/pkg/paging/paging_test.go +++ b/pkg/paging/paging_test.go @@ -39,7 +39,7 @@ func TestFetchAll_SinglePage(t *testing.T) { }, } got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: 1 * time.Millisecond}) - if got.HasMore || got.Pages != 1 || len(got.Records) != 3 { + if got.HasMore || !got.Complete || got.StopReason != StopComplete || got.Pages != 1 || got.Attempts != 1 || len(got.Records) != 3 { t.Fatalf("single page: got=%+v", got) } } @@ -53,7 +53,7 @@ func TestFetchAll_MultiPage(t *testing.T) { }, } got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: 1 * time.Millisecond}) - if got.HasMore || got.Pages != 3 || len(got.Records) != 5 { + if got.HasMore || !got.Complete || got.Pages != 3 || got.Attempts != 3 || len(got.Records) != 5 { t.Fatalf("multi page: got=%+v", got) } } @@ -80,6 +80,9 @@ func TestFetchAll_PageLimit(t *testing.T) { if len(got.Records) != 2 { t.Fatalf("page limit records: got=%v", got.Records) } + if got.Complete || got.Partial || got.StopReason != StopPageLimit { + t.Fatalf("page limit completeness: got=%+v", got) + } } func TestFetchAll_MidErrorPartial(t *testing.T) { @@ -100,6 +103,9 @@ func TestFetchAll_MidErrorPartial(t *testing.T) { if !got.HasMore { t.Fatalf("partial should signal HasMore=true so caller can retry") } + if got.Pages != 1 || got.Attempts != 2 || got.StopReason != StopFetchError { + t.Fatalf("partial counters: got=%+v", got) + } } func TestFetchAll_InitialCursorResume(t *testing.T) { @@ -131,10 +137,9 @@ func TestFetchAll_ContextCancel(t *testing.T) { } func TestFetchAll_UnlimitedPageLimit(t *testing.T) { - // PageLimit 显式置 -1 时(约定为"不限"),翻完为止 - // (当前实现 PageLimit==0 用默认值 50;要真正无限制需另设特殊值, - // 这里测试默认 50 上限内能完成全量) - pages := make([]Page, 10) + // PageLimit=0 是公开契约中的“无限制”。用超过默认安全阀的页数防止 + // 实现再次偷偷把 0 改写成 DefaultPageLimit。 + pages := make([]Page, DefaultPageLimit+1) for i := range pages { next := "" if i < len(pages)-1 { @@ -144,10 +149,33 @@ func TestFetchAll_UnlimitedPageLimit(t *testing.T) { } s := &stubFetcher{pages: pages} got := FetchAll(context.Background(), s.Fetch, Options{ - PageLimit: 100, // 高于实际页数 + PageLimit: 0, InterPageDelay: 1 * time.Millisecond, }) - if got.HasMore || got.Pages != 10 || len(got.Records) != 10 { + if got.HasMore || !got.Complete || got.Pages != len(pages) || len(got.Records) != len(pages) { t.Fatalf("unlimited: got=%+v", got) } } + +func TestFetchAll_PreservesServerTotalCount(t *testing.T) { + total := 123 + s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}} + got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond}) + if got.TotalCount == nil || *got.TotalCount != total { + t.Fatalf("server total count lost: got=%+v", got) + } +} + +func TestFetchAll_StopsOnCursorCycle(t *testing.T) { + s := &stubFetcher{pages: []Page{ + {Records: []any{1}, NextCursor: "c1"}, + {Records: []any{2}, NextCursor: "c1"}, + }} + got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond}) + if !got.Partial || got.Complete || got.StopReason != StopCursorCycle || !errors.Is(got.Err, ErrCursorCycle) { + t.Fatalf("cursor cycle: got=%+v", got) + } + if got.Pages != 2 || got.Attempts != 2 || got.LastCursor != "c1" || len(got.Records) != 2 { + t.Fatalf("cursor cycle progress: got=%+v", got) + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index d8cb52ae..a720e76b 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -21,6 +21,7 @@ FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json" SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog.json", ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json", ] diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 2d50f025..dec4fa79 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -43,7 +43,7 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| -| `aitable` | 29 | `dingtalk-aitable` | +| `aitable` | 92 | `dingtalk-aitable` | | `attendance` | 19 | `dingtalk-misc` | | `calendar` | 20 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index e5b1d1c2..29b50eb5 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -24,35 +24,98 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| +| `dws aitable +advperm-disable` | high-risk-write | 关闭指定 Base 的高级权限总开关(所有自定义角色失效) | +| `dws aitable +advperm-enable` | write | 开启指定 Base 的高级权限总开关 | +| `dws aitable +attachment-put` | write | 准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证 | +| `dws aitable +attachment-remove` | high-risk-write | 从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证 | +| `dws aitable +attachment-upload` | write | 为 attachment 字段申请 OSS 直传地址(uploadUrl / fileToken) | +| `dws aitable +base-bootstrap` | write | 一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用 | +| `dws aitable +base-copy` | write | 复制 AI 表格到指定目录(可仅复制结构) | +| `dws aitable +base-delete` | high-risk-write | 删除指定 Base(不可逆) | | `dws aitable +base-get` | read | 获取指定 Base 的目录信息(tables / dashboards summary) | +| `dws aitable +base-get-primary-doc-id` | read | 根据 baseId/tableId/recordId 获取主键文档的 dentryUuid | | `dws aitable +base-list` | read | 获取当前用户可访问的 AI 表格 Base 列表(最近访问,支持游标分页) | +| `dws aitable +base-schema-snapshot` | read | 读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应 | | `dws aitable +base-search` | read | 按名称关键词搜索 AI 表格 Base | +| `dws aitable +base-update` | write | 更新 Base 名称(可选备注) | +| `dws aitable +chart-delete` | high-risk-write | 删除指定 chart 及其布局项(不可逆) | | `dws aitable +chart-get` | read | 获取指定 chart 的详细信息 | +| `dws aitable +chart-share-get` | read | 查询 chart 的分享配置 | +| `dws aitable +chart-share-update` | write | 开启/关闭 chart 分享并可设置分享类型 | +| `dws aitable +chart-update` | write | 更新指定 chart 的配置或布局(--config 必填) | | `dws aitable +chart-widgets-example` | read | 获取所有图表类型的 widget config 示例 | +| `dws aitable +dashboard-arrange` | write | 对指定仪表盘做服务端智能布局重排 | | `dws aitable +dashboard-config-example` | read | 获取 dashboard config 的结构示例 | +| `dws aitable +dashboard-delete` | high-risk-write | 删除指定 dashboard(级联删除其 chart,不可逆) | | `dws aitable +dashboard-get` | read | 获取指定 dashboard 的详细信息(含 charts summary) | +| `dws aitable +dashboard-share-get` | read | 查询 dashboard 的分享配置 | +| `dws aitable +dashboard-share-update` | write | 开启/关闭 dashboard 分享并可设置分享类型 | +| `dws aitable +dashboard-update` | write | 更新指定 dashboard 的配置 | +| `dws aitable +export-data` | read | 导出 AI 表格数据(创建导出任务或按 taskId 续等) | +| `dws aitable +field-delete` | high-risk-write | 删除指定字段(不可逆) | | `dws aitable +field-get` | read | 批量获取字段详情(含类型相关完整配置) | +| `dws aitable +field-update` | write | 更新字段名称 / 配置 / AI 配置(类型不可改) | | `dws aitable +find-record` | read | 在指定多维表里按关键词查记录(只读) | +| `dws aitable +form-delete` | high-risk-write | 删除指定表单视图(不可逆) | +| `dws aitable +form-field-hide` | write | 切换表单字段的隐藏/显示状态 | | `dws aitable +form-field-list` | read | 列出表单视图当前可见的字段及其配置 | +| `dws aitable +form-field-update` | write | 更新表单字段的必填状态或描述 | | `dws aitable +form-list` | read | 列出指定数据表下的所有表单视图 | | `dws aitable +form-share-get` | read | 读取视图当前的分享表单配置 | +| `dws aitable +form-share-update` | write | 开启或关闭指定视图的分享表单 | +| `dws aitable +form-update` | write | 更新表单标题 / 描述 | +| `dws aitable +import-data` | write | 将已上传文件导入 AI 表格(新建表或追加到已有表) | +| `dws aitable +import-upload` | write | 为导入任务申请 OSS 直传地址(uploadUrl / importId) | | `dws aitable +list-tables` | read | 列出某个多维表(base)里的所有数据表(只读,投影 tableId/tableName) | +| `dws aitable +record-bulk-patch` | high-risk-write | 完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证 | +| `dws aitable +record-delete` | high-risk-write | 批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在 | | `dws aitable +record-history-list` | read | 按 recordId 查询单条记录的变更历史 | +| `dws aitable +record-primary-doc-create` | write | 为记录创建主键文档(幂等),fieldId 须为 primaryDoc 类型 | +| `dws aitable +record-primary-doc-get` | read | 查询记录关联的主键文档 nodeId | | `dws aitable +record-query` | read | 查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页) | | `dws aitable +record-query-empty` | read | 扫描并过滤出完全没填用户字段的空行 | | `dws aitable +record-share-links` | read | 批量(可 >20 条)获取多维表记录分享链接:去重+分片+合并 | | `dws aitable +record-share-url` | read | 按 recordId 批量获取记录分享链接,单次最多 20 条 | +| `dws aitable +record-update` | write | 批量更新记录,自动按 100 条分片并逐批读回验证 | +| `dws aitable +record-upsert` | write | 按 recordId 自动拆分 create/update,按 100 条分片并读回验证 | +| `dws aitable +record-upsert-by-key` | write | 按唯一字段值有则更新、无则创建记录,并读回验证 | | `dws aitable +resolve-base` | read | 按名称搜索多维表 Base 并解析出唯一 baseId(只读) | | `dws aitable +resolve-table` | read | 在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读) | +| `dws aitable +role-create` | write | 在指定 Base 下创建自定义角色 | +| `dws aitable +role-delete` | high-risk-write | 删除 Base 下指定的自定义角色(不可逆) | +| `dws aitable +role-get` | read | 获取单个角色的完整配置 | | `dws aitable +role-list` | read | 列出指定 Base 下的全部角色 | +| `dws aitable +role-update` | write | 按 PATCH 语义增量更新自定义角色 | +| `dws aitable +section-create` | write | 在指定 Base 下创建文件夹(组织 table / dashboard) | +| `dws aitable +section-delete` | high-risk-write | 删除指定文件夹(不可逆) | | `dws aitable +section-list-empty` | read | 列出指定 Base 下所有没有子节点的空文件夹 | | `dws aitable +section-list-nodes` | read | 列出指定 Base 当前版本下的全部 nsheet 节点 | +| `dws aitable +section-move-node` | write | 把任意 nsheet 节点移动到目标文件夹下(可选调整位置) | +| `dws aitable +section-rename` | write | 重命名指定文件夹 | +| `dws aitable +section-reorder` | write | 在当前父文件夹下调整文件夹的展示顺序 | +| `dws aitable +table-copy` | write | 跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录 | +| `dws aitable +table-delete` | high-risk-write | 删除指定数据表(不可逆) | | `dws aitable +table-get` | read | 批量获取指定数据表的表级信息、字段目录与视图目录 | +| `dws aitable +table-update` | write | 更新数据表名称 / 备注 / 行命名规则 | | `dws aitable +template-search` | read | 按名称关键词搜索 AI 表格模板 | +| `dws aitable +url-resolve` | read | 解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId | +| `dws aitable +view-delete` | high-risk-write | 删除指定视图(不可逆) | +| `dws aitable +view-duplicate` | write | 复制视图,生成配置相同的新视图 | | `dws aitable +view-get` | read | 获取视图完整信息(列顺序、筛选、排序、分组等) | | `dws aitable +view-get-frozen-cols` | read | 获取视图当前冻结的左侧列数 | | `dws aitable +view-get-lock` | read | 获取视图锁定状态 | | `dws aitable +view-get-row-height` | read | 获取视图单元格行高(像素) | +| `dws aitable +view-lock` | write | 锁定视图(默认)或解锁(--off) | +| `dws aitable +view-preset-apply` | write | 按视图精确名称幂等创建或更新预设,并读回校验类型和 config | +| `dws aitable +view-set-fill-color-rule` | write | 全量覆盖 Grid 视图的条件填色规则(传 '[]' 清空) | +| `dws aitable +view-set-frozen-cols` | write | 设置视图冻结列数(0 表示取消冻结) | +| `dws aitable +view-set-row-height` | write | 设置视图单元格行高(像素,合法档位 32/56/88/128) | +| `dws aitable +view-update` | write | 更新视图名称 / 描述 / 配置(visibleFieldIds、filter、sort、group 等) | +| `dws aitable +workflow-deploy` | write | 创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态 | +| `dws aitable +workflow-disable` | high-risk-write | 禁用指定 Base 中的自动化工作流(影响业务自动化) | +| `dws aitable +workflow-enable` | write | 启用指定 Base 中的自动化工作流 | +| `dws aitable +workflow-get` | read | 获取单个自动化工作流的详细信息 | +| `dws aitable +workflow-list` | read | 列出指定 Base 中的自动化工作流(分页) | ## 意图表 From 07ce090eeb9f340a2541bfbb31dc570a704f9b1b Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 11:58:24 +0800 Subject: [PATCH 03/10] test(aitable): close shortcut coverage gaps --- internal/helpers/aitable_coverage_gap_test.go | 68 ++++ .../shortcut/aitable/attachment_composite.go | 7 +- .../aitable/attachment_composite_e2e_test.go | 273 +++++++++++++ .../aitable/base_composite_e2e_test.go | 132 +++++++ internal/shortcut/aitable/record_batch.go | 11 +- .../aitable/record_coverage_gap_test.go | 336 ++++++++++++++++ .../shortcut/aitable/record_upsert_by_key.go | 7 +- .../aitable/record_upsert_by_key_e2e_test.go | 8 + .../aitable/remaining_coverage_gap_test.go | 367 ++++++++++++++++++ internal/shortcut/aitable/table_copy.go | 3 - .../aitable/table_copy_coverage_gap_test.go | 172 ++++++++ .../resolver_coverage_gap_test.go | 111 ++++++ internal/shortcut/runner_coverage_test.go | 16 + pkg/paging/paging_test.go | 43 +- 14 files changed, 1529 insertions(+), 25 deletions(-) create mode 100644 internal/helpers/aitable_coverage_gap_test.go create mode 100644 internal/shortcut/aitable/record_coverage_gap_test.go create mode 100644 internal/shortcut/aitable/remaining_coverage_gap_test.go create mode 100644 internal/shortcut/aitable/table_copy_coverage_gap_test.go create mode 100644 internal/shortcut/aitabletarget/resolver_coverage_gap_test.go diff --git a/internal/helpers/aitable_coverage_gap_test.go b/internal/helpers/aitable_coverage_gap_test.go new file mode 100644 index 00000000..009e60c3 --- /dev/null +++ b/internal/helpers/aitable_coverage_gap_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package helpers + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging" +) + +func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) { + for name, raw := range map[string]string{ + "trailing invalid JSON": `{"records":[]} {`, + "data not object": `{"data":[]}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseRecordQueryPage(raw); err == nil { + t.Fatalf("parseRecordQueryPage(%q) succeeded", raw) + } + }) + } +} + +func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) { + cases := []struct { + name string + value any + want int + wantErr bool + }{ + {name: "bad number", value: json.Number("1.5"), wantErr: true}, + {name: "fractional float", value: float64(1.5), wantErr: true}, + {name: "whole float", value: float64(2), want: 2}, + {name: "int", value: int(3), want: 3}, + {name: "int64", value: int64(4), want: 4}, + {name: "wrong type", value: "5", wantErr: true}, + {name: "negative", value: int64(-1), wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseOptionalNonNegativeInt(tc.value) + if tc.wantErr { + if err == nil { + t.Fatalf("value %#v succeeded: %#v", tc.value, got) + } + return + } + if err != nil || got == nil || *got != tc.want { + t.Fatalf("value %#v = %#v, %v", tc.value, got, err) + } + }) + } +} + +func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) { + total := 9 + err := recordQueryIncompleteError(paging.Result{ + Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1, + HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit, + TotalCount: &total, + }, 1) + if err == nil || !strings.Contains(err.Error(), "pagination incomplete") { + t.Fatalf("incomplete result error = %v", err) + } +} diff --git a/internal/shortcut/aitable/attachment_composite.go b/internal/shortcut/aitable/attachment_composite.go index 219e907d..3060f7f8 100644 --- a/internal/shortcut/aitable/attachment_composite.go +++ b/internal/shortcut/aitable/attachment_composite.go @@ -136,10 +136,9 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error { result.Status = "unknown" return compositeError(result, err, false) } - request, err := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file) - if err != nil { - return compositeError(result, err, false) - } + // validateAttachmentUploadURL has already rejected every URL shape for + // which NewRequestWithContext can fail; PUT is a fixed valid method. + request, _ := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file) request.Header.Set("Content-Type", mimeType) request.ContentLength = info.Size() response, err := attachmentHTTPDo(request) diff --git a/internal/shortcut/aitable/attachment_composite_e2e_test.go b/internal/shortcut/aitable/attachment_composite_e2e_test.go index 24163c6e..37b257d5 100644 --- a/internal/shortcut/aitable/attachment_composite_e2e_test.go +++ b/internal/shortcut/aitable/attachment_composite_e2e_test.go @@ -14,6 +14,7 @@ import ( "testing" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func writeAttachmentFixture(t *testing.T, content string) string { @@ -157,3 +158,275 @@ func TestCrossPlatformCoverageAttachmentRemoveExplainsTokenBoundaryE2E(t *testin t.Fatalf("token boundary = output:%q err:%v calls:%#v", out, err, caller.calls) } } + +func TestCrossPlatformCoverageAttachmentPutRemainingOutcomesE2E(t *testing.T) { + filePath := writeAttachmentFixture(t, "bytes") + + t.Run("file open failure", func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filepath.Join(t.TempDir(), "missing"), "--yes") + if err == nil || out != "" { + t.Fatalf("file open = output:%q err:%v", out, err) + } + }) + + t.Run("initial read failure", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes") + if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("initial read = output:%q err:%v", out, err) + } + }) + + t.Run("append dry run preserves token", func(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"file_token": "old-token", "filename": "old.txt"}, + })}}} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 { + t.Fatalf("append dry run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("prepare call failure", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {err: errors.New("prepare failed")}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" { + t.Fatalf("prepare failure = output:%q err:%v", out, err) + } + }) + + t.Run("invalid upload URL", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: `{"uploadUrl":"http://example.com/upload","fileToken":"ft1"}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" { + t.Fatalf("invalid upload URL = output:%q err:%v", out, err) + } + }) + + t.Run("HTTP transport failure", func(t *testing.T) { + testseam.Swap(t, &attachmentHTTPDo, func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport failed") + }) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: `{"uploadUrl":"https://uploads.example.test/put","fileToken":"ft1"}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" { + t.Fatalf("transport failure = output:%q err:%v", out, err) + } + }) + + t.Run("write and read-back both fail", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) + defer server.Close() + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})}, + {err: errors.New("write reply failed")}, + {err: errors.New("verification read failed")}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + if err == nil || out != "" { + t.Fatalf("verification failure = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageAttachmentRemoveRemainingOutcomesE2E(t *testing.T) { + run := func(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) { + args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"} + args = append(args, extra...) + args = append(args, "--yes") + return runAITableCompositeCLI(t, caller, "+attachment-remove", args...) + } + + t.Run("initial read failure", func(t *testing.T) { + out, err := run(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}, "--clear-all") + if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("initial read = output:%q err:%v", out, err) + } + }) + + t.Run("unchanged selection", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"fileToken": "keep", "filename": "keep.pdf"}, + })}}} + out, err := run(t, caller, "--remove-name", "missing.pdf") + if err != nil || !strings.Contains(out, `"status": "unchanged"`) || len(caller.calls) != 1 { + t.Fatalf("unchanged = output:%q err:%v", out, err) + } + }) + + t.Run("dry run", func(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"fileToken": "remove", "filename": "remove.pdf"}, + })}}} + out, err := run(t, caller, "--remove-name", "remove.pdf", "--dry-run") + if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 { + t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + + t.Run("read-back count mismatch", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})}, + {text: `{"updatedCount":1}`}, + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "other.pdf"}})}, + }} + out, err := run(t, caller, "--clear-all") + if err == nil || out != "" { + t.Fatalf("count mismatch = output:%q err:%v", out, err) + } + }) + + t.Run("removed name remains", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{ + map[string]any{"fileToken": "remove", "filename": "remove.pdf"}, + map[string]any{"fileToken": "keep", "filename": "keep.pdf"}, + })}, + {text: `{"updatedCount":1}`}, + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "remove.pdf"}})}, + }} + out, err := run(t, caller, "--remove-name", "remove.pdf") + if err == nil || out != "" { + t.Fatalf("removed item remains = output:%q err:%v", out, err) + } + }) + + t.Run("write and verification fail", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})}, + {err: errors.New("write reply failed")}, + {err: errors.New("verification failed")}, + }} + out, err := run(t, caller, "--clear-all") + if err == nil || out != "" { + t.Fatalf("verification failure = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageAttachmentParsingAndValidation(t *testing.T) { + t.Run("file validation and MIME sniffing", func(t *testing.T) { + if file, _, _, err := openAttachmentFile(filepath.Join(t.TempDir(), "missing"), ""); err == nil || file != nil { + t.Fatalf("missing file must be rejected: file:%v err:%v", file, err) + } + if file, _, _, err := openAttachmentFile(t.TempDir(), ""); err == nil || file != nil { + t.Fatalf("directory must be rejected: file:%v err:%v", file, err) + } + empty := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + if file, _, _, err := openAttachmentFile(empty, ""); err == nil || file != nil { + t.Fatalf("empty file must be rejected: file:%v err:%v", file, err) + } + unknown := filepath.Join(t.TempDir(), "payload.unknown-extension") + if err := os.WriteFile(unknown, []byte("plain text"), 0o600); err != nil { + t.Fatal(err) + } + file, _, mimeType, err := openAttachmentFile(unknown, "") + if err != nil { + t.Fatal(err) + } + defer file.Close() + if mimeType == "" { + t.Fatal("sniffed MIME type is empty") + } + }) + + for name, raw := range map[string]string{ + "missing host": "https:///upload", + "credentials": "https://user:pass@example.com/upload", + "wrong scheme": "ftp://example.com/upload", + } { + t.Run(name, func(t *testing.T) { + if err := validateAttachmentUploadURL(raw); err == nil { + t.Fatalf("%q must be rejected", raw) + } + }) + } + for _, raw := range []string{"http://localhost/upload", "http://127.0.0.1/upload", "https://example.com/upload"} { + if err := validateAttachmentUploadURL(raw); err != nil { + t.Fatalf("%q = %v", raw, err) + } + } + + if err := verifyAttachmentPut(nil, 1, "token", "name", 1); err == nil { + t.Fatal("count mismatch must fail") + } + if err := verifyAttachmentPut([]map[string]any{{"fileToken": "token"}}, 1, "token", "name", 1); err != nil { + t.Fatal(err) + } + if err := verifyAttachmentPut([]map[string]any{{"filename": "other", "size": 1}}, 1, "token", "name", 1); err == nil { + t.Fatal("identity mismatch must fail") + } + for value, want := range map[any]bool{int(1): true, int64(1): true, "1": false} { + _, ok := numericInt64(value) + if ok != want { + t.Fatalf("numericInt64(%T) = %v, want %v", value, ok, want) + } + } +} + +func TestCrossPlatformCoverageAttachmentRemoveSelectionValidationE2E(t *testing.T) { + for name, extra := range map[string][]string{ + "neither": nil, + "both": {"--remove-name", "a.pdf", "--clear-all"}, + } { + t.Run(name, func(t *testing.T) { + args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"} + args = append(args, extra...) + args = append(args, "--yes") + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-remove", args...) + if err == nil || out != "" { + t.Fatalf("selection validation = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageAttachmentReadBackShapesE2E(t *testing.T) { + cases := []struct { + name string + step upsertByKeyStep + want string + }{ + {name: "query error", step: upsertByKeyStep{err: errors.New("query failed")}, want: "query failed"}, + {name: "wrong record", step: upsertByKeyStep{text: `{"records":[{"recordId":"other","cells":{}}]}`}, want: "exact records"}, + {name: "missing cells", step: upsertByKeyStep{text: `{"records":[{"recordId":"record"}]}`}, want: "missing cells"}, + {name: "non-array field", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":"bad"}}]}`}, want: "not an array"}, + {name: "non-object item", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":["bad"]}}]}`}, want: "not an object"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{tc.step}} + out, err := runAITableCompositeCLI(t, caller, "+attachment-remove", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes") + if err == nil || out != "" || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("shape = output:%q err:%v", out, err) + } + }) + } + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[{"recordId":"record","cells":{}}]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+attachment-remove", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes") + if err != nil || !strings.Contains(out, `"status": "unchanged"`) { + t.Fatalf("missing field = output:%q err:%v", out, err) + } +} diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index 7ef86924..dbf7515a 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -149,3 +149,135 @@ func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) { } }) } + +func TestCrossPlatformCoverageBaseBootstrapInputValidation(t *testing.T) { + tooMany := make([]any, 101) + for index := range tooMany { + tooMany[index] = map[string]any{"name": fmt.Sprintf("T%d", index)} + } + tooManyJSON, err := json.Marshal(tooMany) + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "invalid JSON": `{`, + "not an array": `{}`, + "empty array": `[]`, + "too many tables": string(tooManyJSON), + "non-object table": `[1]`, + "empty name": `[{"name":" "}]`, + "duplicate name": `[{"name":"T"},{"name":"T"}]`, + "fields not array": `[{"name":"T","fields":{}}]`, + "field not object": `[{"name":"T","fields":[1]}]`, + "field missing type": `[{"name":"T","fields":[{"fieldName":"F"}]}]`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if tables, err := parseBootstrapTables(raw); err == nil || tables != nil { + t.Fatalf("parseBootstrapTables(%s) = %#v, %v", raw, tables, err) + } + }) + } +} + +func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) { + base := `{"baseId":"base","tables":[{"tableId":"t1","name":"T"}]}` + cases := []struct { + name string + steps []upsertByKeyStep + }{ + {name: "get base error", steps: []upsertByKeyStep{{err: errors.New("get base failed")}}}, + {name: "get base wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"other","tables":[]}`}}}, + {name: "table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[{"name":"T"}]}`}}}, + {name: "get tables error", steps: []upsertByKeyStep{{text: base}, {err: errors.New("get tables failed")}}}, + {name: "get tables wrong id", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"other"}]}`}}}, + {name: "get fields error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {err: errors.New("get fields failed")}}}, + {name: "get fields missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{}`}}}, + {name: "get views error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {err: errors.New("get views failed")}}}, + {name: "get views missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {text: `{}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-schema-snapshot", "--base-id", "base") + if err == nil || out != "" { + t.Fatalf("snapshot failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`) + if err == nil || out != "" { + t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) { + tables := marshalBootstrapTables(t, nil) + cases := []struct { + name string + steps []upsertByKeyStep + extra []string + withField bool + }{ + {name: "missing base id", steps: []upsertByKeyStep{{text: `{}`}}}, + {name: "base verification error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {err: errors.New("verify base failed")}}}, + {name: "base verification wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"other"}`}}}, + {name: "create table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {err: errors.New("create table failed")}}}, + {name: "create table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{}`}}}, + {name: "verify table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {err: errors.New("verify table failed")}}}, + {name: "verify table wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[]}`}}}, + {name: "verify fields error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {err: errors.New("verify fields failed")}}}, + {name: "verify fields mismatch", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{"fields":[]}`}}, extra: []string{"--folder-id", "folder", "--template-id", "template"}, withField: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inputTables := tables + if tc.withField { + inputTables = marshalBootstrapTables(t, bootstrapFields(1)) + } + args := []string{"--name", "Project", "--tables", inputTables} + args = append(args, tc.extra...) + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...) + if err == nil || out != "" { + t.Fatalf("bootstrap failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.T) { + fields := bootstrapFields(16) + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"baseId":"b"}`}, + {text: `{"baseId":"b"}`}, + {text: `{"tableId":"t"}`}, + {err: errors.New("create fields reply failed")}, + {text: `{"tables":[{"tableId":"t"}]}`}, + {text: fieldReadBackJSON(t, fields)}, + }} + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", + "--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template") + if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") { + t.Fatalf("field recovery = output:%q err:%v", out, err) + } + if caller.calls[0].args["folderId"] != "folder" || caller.calls[0].args["templateId"] != "template" { + t.Fatalf("create base args = %#v", caller.calls[0].args) + } +} + +func TestCrossPlatformCoverageBaseCompositeShapeHelpers(t *testing.T) { + if _, ok := findNamedObjectList(map[string]any{"tables": "bad"}, "tables"); ok { + t.Fatal("non-array named child must fail") + } + if _, ok := findNamedObjectList(map[string]any{"tables": []any{"bad"}}, "tables"); ok { + t.Fatal("non-object list item must fail") + } + if containsAllFieldNames([]map[string]any{{"fieldName": "A"}}, []any{map[string]any{"fieldName": "B"}}) { + t.Fatal("missing field name must fail") + } + if got := findStringByKeys(map[string]any{"items": []any{map[string]any{"nested": " value "}}}, "nested"); got != "value" { + t.Fatalf("nested array string = %q", got) + } +} diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 6ad8faca..9ad7f9ad 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -353,10 +353,13 @@ func createdRecordIDs(data map[string]any) []string { case []any: for _, child := range typed { if createdContext { - if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] { - seen[id] = true - out = append(out, strings.TrimSpace(id)) - continue + if id, ok := child.(string); ok { + id = strings.TrimSpace(id) + if id != "" && !seen[id] { + seen[id] = true + out = append(out, id) + continue + } } } walk(child, createdContext) diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go new file mode 100644 index 00000000..df470d64 --- /dev/null +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -0,0 +1,336 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "errors" + "fmt" + "strconv" + "strings" + "testing" +) + +func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) { + tooMany := make([]any, maxCompositeRecordRun+1) + for index := range tooMany { + tooMany[index] = map[string]any{"cells": map[string]any{"f": index}} + } + cases := []struct { + name string + raw string + requireID bool + }{ + {name: "invalid JSON", raw: `{`}, + {name: "not array", raw: `{}`}, + {name: "empty array", raw: `[]`}, + {name: "too many", raw: mustJSONText(t, tooMany)}, + {name: "non-object", raw: `[1]`}, + {name: "missing cells", raw: `[{"recordId":"r"}]`}, + {name: "empty cells", raw: `[{"recordId":"r","cells":{}}]`}, + {name: "missing record id", raw: `[{"cells":{"f":1}}]`, requireID: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if records, err := parseRecordObjects(tc.raw, tc.requireID); err == nil || records != nil { + t.Fatalf("parseRecordObjects = %#v, %v", records, err) + } + }) + } + if ids, err := parseRecordIDs([]string{" ", "b", "a", "a"}); err != nil || strings.Join(ids, ",") != "a,b" { + t.Fatalf("parseRecordIDs = %#v, %v", ids, err) + } + tooManyIDs := make([]string, maxCompositeRecordRun+1) + for index := range tooManyIDs { + tooManyIDs[index] = strconv.Itoa(index) + } + if ids, err := parseRecordIDs(tooManyIDs); err == nil || ids != nil { + t.Fatalf("too many IDs = %#v, %v", ids, err) + } +} + +func TestCrossPlatformCoverageRecordBatchEntryValidationE2E(t *testing.T) { + for _, command := range []string{"+record-update", "+record-upsert"} { + t.Run(command, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, command, + "--base-id", "base", "--table-id", "table", "--records", `{`, "--yes") + if err == nil || out != "" { + t.Fatalf("invalid records = output:%q err:%v", out, err) + } + }) + } + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-delete", + "--base-id", "base", "--table-id", "table", "--record-ids", ` `, "--yes") + if err == nil || out != "" { + t.Fatalf("invalid record IDs = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true} + out, err := runRecordDeleteCLI(t, caller, []string{"r1"}, "--dry-run") + if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) { + t.Fatalf("delete dry run = output:%q err:%v calls:%#v", out, err, caller.calls) + } + + caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("write failed")}, {err: errors.New("verify failed")}}} + out, err = runRecordDeleteCLI(t, caller, []string{"r1"}) + if err == nil || out != "" { + t.Fatalf("delete dual failure = output:%q err:%v", out, err) + } + + out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, []string{"", " "}) + if err == nil || out != "" { + t.Fatalf("delete parsed empty IDs = output:%q err:%v", out, err) + } + + out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, recordIDFixtures(maxCompositeRecordRun+1)) + if err == nil || out != "" { + t.Fatalf("delete excessive IDs = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) { + record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}} + + t.Run("read-back query error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"updatedCount":1}`}, {err: errors.New("read failed")}}} + out, err := runRecordBatchCLI(t, caller, "+record-update", record) + if err == nil || out != "" { + t.Fatalf("query error = output:%q err:%v", out, err) + } + }) + + t.Run("read-back cells mismatch", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"updatedCount":1}`}, + {text: `{"records":[{"recordId":"r1","cells":{"f":"old"}}]}`}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-update", record) + if err == nil || out != "" { + t.Fatalf("cell mismatch = output:%q err:%v", out, err) + } + }) + + t.Run("upsert create read error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"createdRecords":[{"recordId":"new"}]}`}, + {err: errors.New("created read failed")}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}}) + if err == nil || out != "" { + t.Fatalf("create read error = output:%q err:%v", out, err) + } + }) + + t.Run("upsert create cells mismatch", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"createdRecords":[{"recordId":"new"}]}`}, + {text: `{"records":[{"recordId":"new","cells":{"f":"old"}}]}`}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}}) + if err == nil || out != "" { + t.Fatalf("create mismatch = output:%q err:%v", out, err) + } + }) + + t.Run("upsert update read error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"createdRecords":[],"updatedCount":1}`}, + {err: errors.New("updated read failed")}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-upsert", record) + if err == nil || out != "" { + t.Fatalf("update read error = output:%q err:%v", out, err) + } + }) + + t.Run("incomplete update read", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"updatedCount":1}`}, + {text: `{"records":[{"recordId":"r1","cells":{"f":"new"}}],"hasMore":true}`}, + }} + out, err := runRecordBatchCLI(t, caller, "+record-update", record) + if err == nil || out != "" { + t.Fatalf("incomplete read = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageRecordBatchPureHelpers(t *testing.T) { + if err := matchCreatedCells([]map[string]any{{"cells": map[string]any{"f": 1}}}, nil); err == nil { + t.Fatal("created result count mismatch must fail") + } + if err := matchCreatedCells( + []map[string]any{{"cells": map[string]any{"f": 1}}}, + []map[string]any{{"recordId": "r", "cells": map[string]any{"f": 2}}}, + ); err == nil { + t.Fatal("unmatched created cells must fail") + } + ids := createdRecordIDs(map[string]any{"createdIds": []any{" r1 ", "r1", map[string]any{"record_id": "r2"}}}) + if strings.Join(ids, ",") != "r1,r2" { + t.Fatalf("created IDs = %#v", ids) + } + if got := stringSlice("bad"); got != nil { + t.Fatalf("stringSlice = %#v", got) + } + if minInt(1, 2) != 1 || minInt(2, 1) != 1 { + t.Fatal("minInt branch mismatch") + } +} + +func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {name: "invalid patch", args: []string{"--all", "--patch", `{`}}, + {name: "empty patch", args: []string{"--all", "--patch", `{}`}}, + {name: "false all", args: []string{"--all=false", "--patch", `{"f":1}`}}, + {name: "invalid max", args: []string{"--all", "--patch", `{"f":1}`, "--max-matches", "0"}}, + {name: "invalid filters", args: []string{"--filters", `{`, "--patch", `{"f":1}`}}, + {name: "empty record IDs", args: []string{"--record-ids", ` `, "--patch", `{"f":1}`}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := []string{"--base-id", "base", "--table-id", "table"} + args = append(args, tc.args...) + args = append(args, "--yes") + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-bulk-patch", args...) + if err == nil || out != "" { + t.Fatalf("validation = output:%q err:%v", out, err) + } + }) + } + + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and"}`, "--query", "text", + "--record-ids", "r2,r1", "--view-id", "view", "--patch", `{"f":1}`, "--yes") + if err != nil || !strings.Contains(out, `"matchedCount": 0`) { + t.Fatalf("selectors = output:%q err:%v", out, err) + } + for _, key := range []string{"filters", "keyword", "recordIds", "viewId"} { + if _, ok := caller.calls[0].args[key]; !ok { + t.Fatalf("query args missing %s: %#v", key, caller.calls[0].args) + } + } + + caller = &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}} + out, err = runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"f":1}`, "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"status": "planned"`) { + t.Fatalf("empty dry run = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageRecordBulkPatchQueryFailuresE2E(t *testing.T) { + cases := []struct { + name string + steps []upsertByKeyStep + }{ + {name: "query error", steps: []upsertByKeyStep{{err: errors.New("query failed")}}}, + {name: "missing records", steps: []upsertByKeyStep{{text: `{}`}}}, + {name: "more without cursor", steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}}, + {name: "cursor cycle", steps: []upsertByKeyStep{ + {text: `{"records":[],"hasMore":true,"nextCursor":"same"}`}, + {text: `{"records":[],"hasMore":true,"nextCursor":"same"}`}, + }}, + {name: "matched record missing id", steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"f":1}}]}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes") + if err == nil || out != "" { + t.Fatalf("query failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageRecordQueryPageSafetyBoundE2E(t *testing.T) { + caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) { + if tool != "query_records" { + return "", fmt.Errorf("unexpected tool %s", tool) + } + return fmt.Sprintf(`{"records":[],"hasMore":true,"nextCursor":"cursor-%d"}`, index), nil + }} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes") + if err == nil || out != "" || len(caller.calls) != 10000 { + t.Fatalf("page safety = output:%q err:%v calls:%d", out, err, len(caller.calls)) + } +} + +func TestCrossPlatformCoverageRecordUpsertInputAndQueryFailuresE2E(t *testing.T) { + baseArgs := []string{"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--cells", `{"value":1}`, "--yes"} + cases := []struct { + name string + key []string + cells string + steps []upsertByKeyStep + }{ + {name: "empty string key", key: []string{"--key-value", " "}}, + {name: "invalid JSON key", key: []string{"--key-value-json", `{`}}, + {name: "null JSON key", key: []string{"--key-value-json", `null`}}, + {name: "object JSON key", key: []string{"--key-value-json", `{}`}}, + {name: "invalid cells", key: []string{"--key-value", "v"}, cells: `{`}, + {name: "conflicting key", key: []string{"--key-value", "v"}, cells: `{"key":"other"}`}, + {name: "query error", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{err: errors.New("query failed")}}}, + {name: "missing records", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{}`}}}, + {name: "incomplete unique query", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}}, + {name: "record missing id", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"key":"v"}}]}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{}, baseArgs...) + args = append(args, tc.key...) + if tc.cells != "" { + for index := range args { + if args[index] == "--cells" { + args[index+1] = tc.cells + } + } + } + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-upsert-by-key", args...) + if err == nil || out != "" { + t.Fatalf("upsert failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) { + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key", + "--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", `true`, + "--cells", `{"value":1}`, "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"keyValue": true`) { + t.Fatalf("JSON scalar = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) { + if records, ok := findRecords(nil); ok || records != nil { + t.Fatalf("nil records = %#v, %v", records, ok) + } + if _, ok := findRecords(map[string]any{"records": "bad"}); ok { + t.Fatal("non-array records must fail") + } + if _, ok := findRecords(map[string]any{"records": []any{"bad"}}); ok { + t.Fatal("non-object record must fail") + } + if records, ok := findRecords(map[string]any{"result": map[string]any{"records": []any{map[string]any{"id": "r"}}}}); !ok || recordID(records[0]) != "r" { + t.Fatalf("nested records = %#v, %v", records, ok) + } + if responseHasMore(nil) || !responseHasMore(map[string]any{"cursor": "next"}) || !responseHasMore(map[string]any{"pagination": map[string]any{"hasMore": true}}) { + t.Fatal("responseHasMore shape mismatch") + } + if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil { + t.Fatal("missing cells must fail") + } + if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" { + t.Fatal("responseCursor shape mismatch") + } +} diff --git a/internal/shortcut/aitable/record_upsert_by_key.go b/internal/shortcut/aitable/record_upsert_by_key.go index fe44d7eb..038afa45 100644 --- a/internal/shortcut/aitable/record_upsert_by_key.go +++ b/internal/shortcut/aitable/record_upsert_by_key.go @@ -182,11 +182,8 @@ func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error { func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) { if rt.Changed("key-value") { - value := rt.Str("key-value") - if value == "" { - return nil, apperrors.NewValidation("--key-value 不能为空") - } - return value, nil + // The command framework rejects an explicitly empty string before Execute. + return rt.Str("key-value"), nil } decoder := json.NewDecoder(strings.NewReader(rt.Str("key-value-json"))) decoder.UseNumber() diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 1738f2d8..64bf759a 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -28,6 +28,7 @@ type upsertByKeyCaller struct { steps []upsertByKeyStep calls []aitableURLCall dryRun bool + callFn func(index int, product, tool string, args map[string]any) (string, error) } func (c *upsertByKeyCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -45,6 +46,13 @@ func (c *upsertByKeyCaller) call(product, tool string, args map[string]any) (*ed } c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned}) index := len(c.calls) - 1 + if c.callFn != nil { + text, err := c.callFn(index, product, tool, args) + if err != nil { + return nil, err + } + return urlResult(text), nil + } if index >= len(c.steps) { return nil, errors.New("unexpected upsert-by-key call") } diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go new file mode 100644 index 00000000..2b834f68 --- /dev/null +++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go @@ -0,0 +1,367 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestCrossPlatformCoverageAITableProjectionShapeFailures(t *testing.T) { + if _, err := resolveNamedList("op", nil, []string{"data"}, []string{"items"}); err == nil { + t.Fatal("nil list response must fail") + } + if _, err := resolveNamedList("op", map[string]any{"data": "bad"}, []string{"data"}, []string{"items"}); err == nil { + t.Fatal("non-object envelope must fail") + } + if _, err := resolveNamedList("op", map[string]any{"data": map[string]any{"other": []any{}}}, []string{"data"}, []string{"items"}); err == nil { + t.Fatal("unrecognized envelope must fail") + } + for name, project := range map[string]func() error{ + "base": func() error { + _, err := baseListProject("list_bases", map[string]any{"bases": []any{"bad"}}) + return err + }, + "template": func() error { + _, err := templateSearchProject(map[string]any{"templates": []any{"bad"}}) + return err + }, + "view": func() error { + _, err := viewGetProject(map[string]any{"views": []any{"bad"}}) + return err + }, + "form": func() error { + _, err := formListProject(map[string]any{"views": []any{"bad"}}) + return err + }, + "workflow": func() error { + _, err := workflowListProject(map[string]any{"workflows": []any{"bad"}}) + return err + }, + } { + t.Run(name, func(t *testing.T) { + if err := project(); err == nil { + t.Fatal("non-object projection item must fail") + } + }) + } +} + +func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *testing.T) { + bases, err := baseListProject("list_bases", map[string]any{"bases": []any{map[string]any{"baseId": "b", "baseName": "Base"}}}) + if err != nil || len(bases) != 1 { + t.Fatalf("base projection = %#v, %v", bases, err) + } + if _, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"name": "missing"}}}); err == nil { + t.Fatal("template missing ID succeeded") + } + templates, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"templateId": "t"}}}) + if err != nil || len(templates) != 1 { + t.Fatalf("template projection = %#v, %v", templates, err) + } + views, err := viewGetProject(map[string]any{"views": []any{map[string]any{"viewId": "v"}}}) + if err != nil || len(views) != 1 { + t.Fatalf("view projection = %#v, %v", views, err) + } + forms, err := formListProject(map[string]any{"views": []any{map[string]any{"viewId": "f"}}}) + if err != nil || len(forms) != 1 { + t.Fatalf("form projection = %#v, %v", forms, err) + } + workflows, err := workflowListProject(map[string]any{"workflows": []any{map[string]any{"workflowId": "w"}}}) + if err != nil || len(workflows) != 1 { + t.Fatalf("workflow projection = %#v, %v", workflows, err) + } +} + +func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) { + err := compositeError(newCompositeResult("test"), errors.New("cause"), true) + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("composite default status = %v", err) + } + unreviewed := shortcut.Shortcut{Command: "+not-reviewed"} + if got := withReviewedAITableShortcutContracts(unreviewed); len(got) != 1 || !got[0].Contract.Empty() { + t.Fatalf("unreviewed shortcut changed: %#v", got) + } + value := shortcut.Shortcut{ + Command: "+base-list", + Description: "fallback description", + Tips: []string{"one", "two", "three"}, + } + contract := reviewedAITableShortcutContract(value) + if contract.Description != "fallback description" || len(contract.Selection.Examples) != 2 { + t.Fatalf("reviewed contract fallback = %#v", contract) + } +} + +func TestCrossPlatformCoverageURLResolveAllVerificationKindsE2E(t *testing.T) { + cases := []struct { + name string + url string + tool string + text string + }{ + {name: "record", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26recordId%3Drecord", tool: "query_records", text: `{"records":[{"record_id":"record"}]}`}, + {name: "view", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26viewId%3Dview", tool: "get_views", text: `{"views":[{"view_id":"view"}]}`}, + {name: "base by id", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"base_id":"base"}`}, + {name: "base structural fallback", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"data":{"tables":[]}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(tc.text)}} + out, err := runURLResolveCLI(t, caller, "--url", tc.url, "--verify") + if err != nil || !strings.Contains(out, `"verified": true`) || caller.calls[0].tool != tc.tool { + t.Fatalf("verification = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } + + t.Run("tool error", func(t *testing.T) { + caller := &urlResolveE2ECaller{errors: []error{errors.New("verification failed")}} + out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify") + if err == nil || out != "" { + t.Fatalf("tool error = output:%q err:%v", out, err) + } + }) + + t.Run("base unknown response", func(t *testing.T) { + caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(`{"success":true}`)}} + out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify") + if err == nil || out != "" { + t.Fatalf("unknown base = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageURLResolveRecursiveShapeHelpers(t *testing.T) { + value := map[string]any{"data": []any{map[string]any{"id": "wanted"}}} + if !responseContainsID(value, "wanted", "id") || responseContainsID(value, "other", "id") { + t.Fatal("responseContainsID recursion mismatch") + } + if !responseHasAnyKey(map[string]any{"data": []any{map[string]any{"tables": []any{}}}}, "tables") { + t.Fatal("responseHasAnyKey recursion mismatch") + } + if responseHasAnyKey([]any{"value"}, "tables") { + t.Fatal("unrelated key must not match") + } +} + +func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) { + cases := []struct { + name string + config string + steps []upsertByKeyStep + }{ + {name: "invalid config", config: `{`}, + {name: "empty config", config: `{}`}, + {name: "preflight error", config: `{"f":1}`, steps: []upsertByKeyStep{{err: errors.New("views failed")}}}, + {name: "preflight missing list", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{}`}}}, + {name: "matched missing id", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewName":"X","viewType":"Grid"}]}`}}}, + {name: "matched type conflict", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Kanban"}]}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", tc.config, "--yes") + if err == nil || out != "" { + t.Fatalf("validation = output:%q err:%v", out, err) + } + }) + } + + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"views":[]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"status": "planned"`) { + t.Fatalf("dry run = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) { + preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}` + cases := []struct { + name string + write upsertByKeyStep + readBack upsertByKeyStep + }{ + {name: "read error", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{err: errors.New("read failed")}}, + {name: "missing list", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{}`}}, + {name: "missing exact name", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[]}`}}, + {name: "identity mismatch", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[{"viewId":"other","viewName":"X","viewType":"Grid","config":{"f":1}}]}`}}, + {name: "dual failure", write: upsertByKeyStep{err: errors.New("write failed")}, readBack: upsertByKeyStep{err: errors.New("read failed")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: preflight}, tc.write, tc.readBack}} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes") + if err == nil || out != "" { + t.Fatalf("read-back failure = output:%q err:%v", out, err) + } + }) + } + + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: preflight}, {text: `{"updated":1}`}, {text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":1}}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply", + "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes") + if err != nil || !strings.Contains(out, `"action": "update"`) { + t.Fatalf("update success = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) { + if presetViewMatches(map[string]any{"viewType": "Kanban", "config": map[string]any{}}, "Grid", map[string]any{}) { + t.Fatal("type mismatch must fail") + } + if mapContains(map[string]any{}, map[string]any{"missing": 1}) { + t.Fatal("missing key must fail") + } + if mapContains(map[string]any{"nested": 1}, map[string]any{"nested": map[string]any{"x": 1}}) { + t.Fatal("nested type mismatch must fail") + } + if mapContains(map[string]any{"nested": map[string]any{}}, map[string]any{"nested": map[string]any{"x": 1}}) { + t.Fatal("nested value mismatch must fail") + } + if !mapContains(map[string]any{"nested": map[string]any{"x": 1, "extra": true}}, map[string]any{"nested": map[string]any{"x": 1}}) { + t.Fatal("nested subset must match") + } + if mapContains(map[string]any{"x": 1}, map[string]any{"x": 2}) { + t.Fatal("scalar mismatch must fail") + } +} + +func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) { + cases := []struct { + name string + dsl string + extra []string + steps []upsertByKeyStep + }{ + {name: "invalid DSL", dsl: `{`}, + {name: "wrong DSL version", dsl: `{"version":"v0"}`}, + {name: "update preflight error", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{err: errors.New("preflight failed")}}}, + {name: "update target not found", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{text: `{"flowId":"other"}`}}}, + {name: "publish missing ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true}`}}}, + {name: "publish ID wrong type", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":1}`}}}, + {name: "read-back error", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {err: errors.New("read failed")}}}, + {name: "read-back wrong ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"other"}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + args := []string{"--base-id", "base", "--dsl", tc.dsl} + args = append(args, tc.extra...) + args = append(args, "--yes") + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+workflow-deploy", args...) + if err == nil || out != "" { + t.Fatalf("workflow failure = output:%q err:%v", out, err) + } + }) + } + + caller := &upsertByKeyCaller{dryRun: true} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "enable and verify") { + t.Fatalf("workflow dry run = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageWorkflowEnableErrorCanStillBeVerifiedE2E(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`}, + {err: errors.New("enable reply failed")}, {text: `{"list":[{"flowId":"w","enabled":true}]}`}, + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err != nil || !strings.Contains(out, "enable response error") { + t.Fatalf("enable recovery = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageWorkflowListFailureShapesE2E(t *testing.T) { + for name, step := range map[string]upsertByKeyStep{ + "list error": {err: errors.New("list failed")}, + "missing list": {text: `{}`}, + "not present": {text: `{"list":[]}`}, + } { + t.Run(name, func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`}, {text: `{"enabled":true}`}, step, + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err == nil || out != "" { + t.Fatalf("list failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageWorkflowListPaginationAndBoundE2E(t *testing.T) { + page := make([]any, 100) + for index := range page { + page[index] = map[string]any{"flowId": fmt.Sprintf("other-%d", index)} + } + caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) { + switch index { + case 0: + return `{"valid":true,"flowId":"w"}`, nil + case 1: + return `{"flowId":"w"}`, nil + case 2: + return `{"enabled":true}`, nil + case 3: + return mustJSONText(t, map[string]any{"list": page}), nil + default: + return `{"list":[{"flowId":"w","status":"ACTIVE"}]}`, nil + } + }} + out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err != nil || !strings.Contains(out, `"running": true`) || len(caller.calls) != 5 { + t.Fatalf("paginated list = output:%q err:%v calls:%d", out, err, len(caller.calls)) + } + + fullPage := mustJSONText(t, map[string]any{"list": page}) + caller = &upsertByKeyCaller{callFn: func(index int, _, _ string, _ map[string]any) (string, error) { + if index == 0 { + return `{"valid":true,"flowId":"w"}`, nil + } + if index == 1 { + return `{"flowId":"w"}`, nil + } + if index == 2 { + return `{"enabled":true}`, nil + } + return fullPage, nil + }} + out, err = runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes") + if err == nil || out != "" || len(caller.calls) != 103 { + t.Fatalf("workflow list bound = output:%q err:%v calls:%d", out, err, len(caller.calls)) + } +} + +func TestCrossPlatformCoverageWorkflowPureResponseHelpers(t *testing.T) { + if _, _, _, err := workflowPublishResult(nil); err == nil { + t.Fatal("nil publish result must fail") + } + if value, found := findBoolByKeys(map[string]any{"data": []any{map[string]any{"enabled": true}}}, "enabled"); !found || !value { + t.Fatal("nested bool not found") + } + if _, found := findBoolByKeys([]any{"value"}, "enabled"); found { + t.Fatal("unrelated bool unexpectedly found") + } + for _, workflow := range []map[string]any{ + {"status": "enabled"}, {"state": "active"}, {"isEnabled": true}, + } { + if !workflowIsRunning(workflow) { + t.Fatalf("workflow should be running: %#v", workflow) + } + } + if workflowIsRunning(map[string]any{"status": "STOP", "enabled": false}) { + t.Fatal("stopped workflow must not be running") + } +} diff --git a/internal/shortcut/aitable/table_copy.go b/internal/shortcut/aitable/table_copy.go index 03cfd5fe..6da8c639 100644 --- a/internal/shortcut/aitable/table_copy.go +++ b/internal/shortcut/aitable/table_copy.go @@ -200,9 +200,6 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error { result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset} return compositeError(result, verifyErr, false) } - if writeErr != nil { - result.Warnings = append(result.Warnings, fmt.Sprintf("create_records offset %d returned an error, but returned IDs and read-back proved the copied state: %v", offset, writeErr)) - } createdCount = end result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_records", "offset": offset, "recordIds": createdIDs}) } diff --git a/internal/shortcut/aitable/table_copy_coverage_gap_test.go b/internal/shortcut/aitable/table_copy_coverage_gap_test.go new file mode 100644 index 00000000..c051befc --- /dev/null +++ b/internal/shortcut/aitable/table_copy_coverage_gap_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitable + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func copyFields(count int, prefix string) []any { + fields := make([]any, 0, count) + for index := 0; index < count; index++ { + fields = append(fields, map[string]any{ + "fieldId": fmt.Sprintf("%s%d", prefix, index), + "fieldName": fmt.Sprintf("F%d", index), + "type": "text", + }) + } + return fields +} + +func TestCrossPlatformCoverageTableCopyValidationAndDryRunE2E(t *testing.T) { + t.Run("invalid maximum", func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--max-records", "0", "--yes") + if err == nil || out != "" { + t.Fatalf("invalid maximum = output:%q err:%v", out, err) + } + }) + + t.Run("source read error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("source fields failed")}}} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err == nil || out != "" { + t.Fatalf("source read = output:%q err:%v", out, err) + } + }) + + t.Run("source fields collection missing", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{}`}}} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err == nil || out != "" { + t.Fatalf("source fields collection = output:%q err:%v", out, err) + } + }) + + t.Run("invalid source field", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[{"fieldName":"F","type":"text"}]}`}}} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err == nil || out != "" { + t.Fatalf("invalid source field = output:%q err:%v", out, err) + } + }) + + t.Run("dry run skips unsupported and preserves config", func(t *testing.T) { + payload := map[string]any{"fields": []any{ + map[string]any{"fieldId": "skip", "fieldName": "Formula", "type": "FORMULA"}, + map[string]any{"fieldId": "keep", "fieldName": "Text", "type": "text", "config": map[string]any{"x": 1}, "aiConfig": map[string]any{"y": 2}}, + }} + caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: mustJSONText(t, payload)}}} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--dry-run", "--yes") + if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "skipped field") { + t.Fatalf("dry run = output:%q err:%v", out, err) + } + }) + + t.Run("source records query error", func(t *testing.T) { + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: errors.New("records failed")}}} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes") + if err == nil || out != "" { + t.Fatalf("source records = output:%q err:%v", out, err) + } + }) +} + +func TestCrossPlatformCoverageTableCopySchemaFailureStagesE2E(t *testing.T) { + source := mustJSONText(t, map[string]any{"fields": copyFields(1, "s")}) + cases := []struct { + name string + steps []upsertByKeyStep + }{ + {name: "target fields error", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {err: errors.New("target fields failed")}}}, + {name: "target fields mismatch", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {text: `{"fields":[]}`}}}, + {name: "target mapping duplicate", steps: []upsertByKeyStep{ + {text: source}, {text: `{"tableId":"target"}`}, + {text: `{"fields":[{"fieldId":"t1","fieldName":"F0","type":"text"},{"fieldId":"t2","fieldName":"F0","type":"text"}]}`}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err == nil || out != "" { + t.Fatalf("schema failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageTableCopyFieldChunkErrorRecoveredE2E(t *testing.T) { + sourceFields := copyFields(16, "s") + targetFields := copyFields(16, "t") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, map[string]any{"fields": sourceFields})}, + {text: `{"tableId":"target"}`}, + {err: errors.New("create fields reply failed")}, + {text: mustJSONText(t, map[string]any{"fields": targetFields})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes") + if err != nil || !strings.Contains(out, "create_fields offset") || !strings.Contains(out, `"fieldCount": 16`) { + t.Fatalf("field chunk recovery = output:%q err:%v", out, err) + } +} + +func TestCrossPlatformCoverageTableCopyRecordFailureStagesE2E(t *testing.T) { + source := mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}}) + target := mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}}) + cases := []struct { + name string + record string + after []upsertByKeyStep + }{ + {name: "source record missing cells", record: `{"records":[{"recordId":"source"}]}`}, + {name: "created IDs read-back error", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{ + {text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {err: errors.New("target record read failed")}, + }}, + {name: "created records mismatch", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{ + {text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"wrong"}}]}`}, + }}, + {name: "fallback records list", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{ + {text: `{"records":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"v"}}]}`}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + steps := []upsertByKeyStep{{text: source}, {text: tc.record}, {text: `{"tableId":"target"}`}, {text: target}} + steps = append(steps, tc.after...) + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: steps}, "+table-copy", + "--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes") + if tc.name == "fallback records list" { + if err != nil || !strings.Contains(out, `"recordCount": 1`) { + t.Fatalf("fallback success = output:%q err:%v", out, err) + } + return + } + if err == nil || out != "" { + t.Fatalf("record failure = output:%q err:%v", out, err) + } + }) + } +} + +func TestCrossPlatformCoverageTableCopyPureHelpers(t *testing.T) { + if _, err := mapCopiedFieldIDs(nil, []map[string]any{{"fieldId": "a", "fieldName": "same"}, {"fieldId": "b", "fieldName": "same"}}); err == nil { + t.Fatal("duplicate target field must fail") + } + if _, err := mapCopiedFieldIDs([]map[string]any{{"fieldId": "s", "fieldName": "missing"}}, nil); err == nil { + t.Fatal("missing target field must fail") + } + if normalizedFieldType("FoRmUlA") != "formula" || normalizedFieldType("custom") != "custom" { + t.Fatal("field type normalization mismatch") + } +} diff --git a/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go b/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go new file mode 100644 index 00000000..9a66cddd --- /dev/null +++ b/internal/shortcut/aitabletarget/resolver_coverage_gap_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group +// SPDX-License-Identifier: Apache-2.0 + +package aitabletarget + +import ( + "errors" + "fmt" + "net/url" + "strings" + "testing" +) + +func TestCrossPlatformCoverageParseAITableURLRemainingEdges(t *testing.T) { + for _, raw := range []string{ + "https://alidocs.dingtalk.com/i/nodes/base%2Fchild", + "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=%25", + "https://alidocs.dingtalk.com/i/nodes/base?viewId=one&viewId=two&sheetId=table", + "https://alidocs.dingtalk.com/i/nodes/base?recordId=one&recordId=two&sheetId=table", + } { + if _, err := ParseURL(raw); err == nil { + t.Fatalf("ParseURL(%q) succeeded", raw) + } + } + if _, err := parseIframeQuery("%"); err == nil { + t.Fatal("invalid iframe escape succeeded") + } + if values, err := parseIframeQuery("preview"); err != nil || !values.Has("preview") { + t.Fatalf("plain iframe query = %#v, %v", values, err) + } + if _, err := parseIframeQuery("key=%zz"); err == nil { + t.Fatal("invalid iframe query value succeeded") + } + values := url.Values{"id": {" ", "bad/id"}} + if _, err := uniqueQueryID(values, "id"); err == nil { + t.Fatal("invalid query ID succeeded") + } + if validID("ok\x7fno") { + t.Fatal("control character ID succeeded") + } +} + +func TestCrossPlatformCoverageResolveBaseRemainingFailures(t *testing.T) { + if _, err := ResolveBaseName(&resolverReader{}, " ", false); err == nil { + t.Fatal("empty base name succeeded") + } + reader := &resolverReader{steps: []resolverStep{{err: errors.New("search failed")}}} + if _, err := ResolveBaseName(reader, "name", false); err == nil { + t.Fatal("search error was swallowed") + } + reader = &resolverReader{steps: []resolverStep{ + {data: map[string]any{"bases": []any{}, "nextCursor": "same"}}, + {data: map[string]any{"bases": []any{}, "nextCursor": "same"}}, + }} + if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" { + t.Fatalf("cursor cycle = %v", err) + } + + steps := make([]resolverStep, maxResolutionPages) + for index := range steps { + steps[index] = resolverStep{data: map[string]any{"bases": []any{}, "nextCursor": fmt.Sprintf("cursor-%d", index)}} + } + reader = &resolverReader{steps: steps} + if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" || len(reader.calls) != maxResolutionPages { + t.Fatalf("page bound = err:%v calls:%d", err, len(reader.calls)) + } +} + +func TestCrossPlatformCoverageResolveTableRemainingFailures(t *testing.T) { + for _, pair := range [][2]string{{"bad/id", "name"}, {"base", " "}} { + if _, err := ResolveTableName(&resolverReader{}, pair[0], pair[1], false); err == nil { + t.Fatalf("invalid table args %#v succeeded", pair) + } + } + reader := &resolverReader{steps: []resolverStep{{err: errors.New("tables failed")}}} + if _, err := ResolveTableName(reader, "base", "name", false); err == nil { + t.Fatal("table error was swallowed") + } + reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"tables": "bad"}}}} + if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" { + t.Fatalf("invalid table list = %v", err) + } + reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"success": true}}}} + if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" { + t.Fatalf("missing table list = %v", err) + } +} + +func TestCrossPlatformCoverageResolverPureShapeHelpers(t *testing.T) { + if list, found, err := findObjectList(nil, "items"); err != nil || found || list != nil { + t.Fatalf("nil list = %#v, %v, %v", list, found, err) + } + if _, _, err := findObjectList(map[string]any{"items": "bad"}, "items"); err == nil { + t.Fatal("invalid list field succeeded") + } + if list, found, err := findObjectList(map[string]any{"items": map[string]any{"items": []any{}}}, "items"); err != nil || !found || len(list) != 0 { + t.Fatalf("nested keyed list = %#v, %v, %v", list, found, err) + } + cursor, more, known := pagination(nil) + if cursor != "" || more || known { + t.Fatalf("nil pagination = %q, %v, %v", cursor, more, known) + } + cursor, more, known = pagination(map[string]any{"cursor": "outer", "data": map[string]any{"nextCursor": "inner", "hasMore": true}}) + if cursor != "outer" || !more || !known { + t.Fatalf("nested pagination = %q, %v, %v", cursor, more, known) + } + got := dedupe([]Candidate{{}, {ID: "one", Name: "first"}, {ID: "one", Name: "duplicate"}, {ID: "two", Name: "second"}}) + if len(got) != 2 || strings.Join([]string{got[0].ID, got[1].ID}, ",") != "one,two" { + t.Fatalf("dedupe = %#v", got) + } +} diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index daa72b3e..51f90346 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -4,8 +4,10 @@ package shortcut import ( + "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/spf13/cobra" ) @@ -16,3 +18,17 @@ func TestCrossPlatformCoverageRuntimeContextForTest(t *testing.T) { t.Fatalf("RuntimeContextForTest = %#v", rt) } } + +func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) { + caller := &runtimeReadCoverageCaller{text: `not-json`} + old := helpers.GetCaller() + t.Cleanup(func() { helpers.InitDeps(old) }) + helpers.InitDeps(caller) + rt := &RuntimeContext{} + if _, err := rt.callMCPWriteData("aitable", "update_records", nil); err == nil || !strings.Contains(err.Error(), "解析") { + t.Fatalf("invalid write JSON = %v", err) + } + if caller.args == nil { + t.Fatal("nil write parameters were not normalized") + } +} diff --git a/pkg/paging/paging_test.go b/pkg/paging/paging_test.go index 2a7e5080..c507a9b7 100644 --- a/pkg/paging/paging_test.go +++ b/pkg/paging/paging_test.go @@ -32,7 +32,7 @@ func (s *stubFetcher) Fetch(ctx context.Context, cursor string) (Page, error) { return s.pages[idx], nil } -func TestFetchAll_SinglePage(t *testing.T) { +func TestCrossPlatformCoverageFetchAllSinglePage(t *testing.T) { s := &stubFetcher{ pages: []Page{ {Records: []any{"a", "b", "c"}, NextCursor: ""}, @@ -44,7 +44,7 @@ func TestFetchAll_SinglePage(t *testing.T) { } } -func TestFetchAll_MultiPage(t *testing.T) { +func TestCrossPlatformCoverageFetchAllMultiPage(t *testing.T) { s := &stubFetcher{ pages: []Page{ {Records: []any{1, 2}, NextCursor: "c1"}, @@ -58,7 +58,7 @@ func TestFetchAll_MultiPage(t *testing.T) { } } -func TestFetchAll_PageLimit(t *testing.T) { +func TestCrossPlatformCoverageFetchAllPageLimit(t *testing.T) { s := &stubFetcher{ pages: []Page{ {Records: []any{1}, NextCursor: "c1"}, @@ -85,7 +85,7 @@ func TestFetchAll_PageLimit(t *testing.T) { } } -func TestFetchAll_MidErrorPartial(t *testing.T) { +func TestCrossPlatformCoverageFetchAllMidErrorPartial(t *testing.T) { upstreamErr := errors.New("502 bad gateway") s := &stubFetcher{ pages: []Page{ @@ -108,7 +108,7 @@ func TestFetchAll_MidErrorPartial(t *testing.T) { } } -func TestFetchAll_InitialCursorResume(t *testing.T) { +func TestCrossPlatformCoverageFetchAllInitialCursorResume(t *testing.T) { s := &stubFetcher{ pages: []Page{ {Records: []any{"resumed"}, NextCursor: ""}, @@ -123,7 +123,7 @@ func TestFetchAll_InitialCursorResume(t *testing.T) { } } -func TestFetchAll_ContextCancel(t *testing.T) { +func TestCrossPlatformCoverageFetchAllContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // 立即取消 s := &stubFetcher{pages: []Page{{Records: []any{1}, NextCursor: "c1"}}} @@ -136,7 +136,7 @@ func TestFetchAll_ContextCancel(t *testing.T) { } } -func TestFetchAll_UnlimitedPageLimit(t *testing.T) { +func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) { // PageLimit=0 是公开契约中的“无限制”。用超过默认安全阀的页数防止 // 实现再次偷偷把 0 改写成 DefaultPageLimit。 pages := make([]Page, DefaultPageLimit+1) @@ -157,7 +157,7 @@ func TestFetchAll_UnlimitedPageLimit(t *testing.T) { } } -func TestFetchAll_PreservesServerTotalCount(t *testing.T) { +func TestCrossPlatformCoverageFetchAllPreservesServerTotalCount(t *testing.T) { total := 123 s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}} got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond}) @@ -166,7 +166,7 @@ func TestFetchAll_PreservesServerTotalCount(t *testing.T) { } } -func TestFetchAll_StopsOnCursorCycle(t *testing.T) { +func TestCrossPlatformCoverageFetchAllStopsOnCursorCycle(t *testing.T) { s := &stubFetcher{pages: []Page{ {Records: []any{1}, NextCursor: "c1"}, {Records: []any{2}, NextCursor: "c1"}, @@ -179,3 +179,28 @@ func TestFetchAll_StopsOnCursorCycle(t *testing.T) { t.Fatalf("cursor cycle progress: got=%+v", got) } } + +func TestCrossPlatformCoverageFetchAllCancellationDuringDelay(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + got := FetchAll(ctx, func(context.Context, string) (Page, error) { + calls++ + time.AfterFunc(10*time.Millisecond, cancel) + return Page{Records: []any{"first"}, NextCursor: "next"}, nil + }, Options{InterPageDelay: time.Second}) + if calls != 1 || got.StopReason != StopCanceled || !errors.Is(got.Err, context.Canceled) || got.LastCursor != "next" { + t.Fatalf("delay cancellation = %+v calls:%d", got, calls) + } +} + +func TestCrossPlatformCoverageFetchAllStopsOnPriorCursorCycle(t *testing.T) { + s := &stubFetcher{pages: []Page{ + {Records: []any{1}, NextCursor: "c1"}, + {Records: []any{2}, NextCursor: "c2"}, + {Records: []any{3}, NextCursor: "c1"}, + }} + got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond}) + if got.StopReason != StopCursorCycle || got.Pages != 3 || got.LastCursor != "c1" || !errors.Is(got.Err, ErrCursorCycle) { + t.Fatalf("prior cursor cycle = %+v", got) + } +} From 307c9e797b4edf03d9f654d74ccbb37bf8425c51 Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 14:08:59 +0800 Subject: [PATCH 04/10] fix(aitable): reject empty bulk patch selectors --- .../shortcut/aitable/record_bulk_patch.go | 95 +++++++++++++++---- .../aitable/record_coverage_gap_test.go | 21 +++- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/internal/shortcut/aitable/record_bulk_patch.go b/internal/shortcut/aitable/record_bulk_patch.go index 742cc583..87c355e5 100644 --- a/internal/shortcut/aitable/record_bulk_patch.go +++ b/internal/shortcut/aitable/record_bulk_patch.go @@ -5,6 +5,7 @@ package aitable import ( "fmt" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -53,32 +54,23 @@ func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error { if len(patch) == 0 { return apperrors.NewValidation("--patch 必须是非空 JSON 对象") } - selectorCount := 0 - for _, name := range []string{"filters", "query", "record-ids", "all"} { - if rt.Changed(name) { - selectorCount++ - } - } - if selectorCount == 0 { - return apperrors.NewValidation("必须显式提供 --filters、--query、--record-ids 或 --all") - } - if rt.Changed("all") && !rt.Bool("all") { - return apperrors.NewValidation("--all=false 不构成全表授权") - } - maxMatches := rt.Int("max-matches") - if maxMatches < 1 || maxMatches > maxCompositeRecordRun { - return apperrors.NewValidation(fmt.Sprintf("--max-matches 必须在 1..%d", maxCompositeRecordRun)) - } params := map[string]any{"baseId": rt.Str("base-id"), "tableId": rt.Str("table-id")} + selectorCount := 0 if rt.Changed("filters") { - filters, err := parseJSONAny("filters", rt.Str("filters")) + filters, err := parseBulkPatchFilters(rt.Str("filters")) if err != nil { return err } params["filters"] = filters + selectorCount++ } if rt.Changed("query") { - params["keyword"] = rt.Str("query") + query := strings.TrimSpace(rt.Str("query")) + if query == "" { + return apperrors.NewValidation("--query 必须包含非空白关键词") + } + params["keyword"] = query + selectorCount++ } if rt.Changed("record-ids") { ids, err := parseRecordIDs(rt.StrSlice("record-ids")) @@ -86,6 +78,20 @@ func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error { return err } params["recordIds"] = ids + selectorCount++ + } + if rt.Changed("all") { + if !rt.Bool("all") { + return apperrors.NewValidation("--all=false 不构成全表授权") + } + selectorCount++ + } + if selectorCount == 0 { + return apperrors.NewValidation("必须显式提供 --filters、--query、--record-ids 或 --all") + } + maxMatches := rt.Int("max-matches") + if maxMatches < 1 || maxMatches > maxCompositeRecordRun { + return apperrors.NewValidation(fmt.Sprintf("--max-matches 必须在 1..%d", maxCompositeRecordRun)) } if rt.Changed("view-id") { params["viewId"] = rt.Str("view-id") @@ -115,3 +121,56 @@ func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error { } return executeRecordBatches(rt, "record_bulk_patch", "update_records", serverMain, updates, verifyUpdateBatch) } + +func parseBulkPatchFilters(raw string) (map[string]any, error) { + filters, err := parseJSONObject("filters", raw) + if err != nil { + return nil, err + } + if !bulkPatchFilterConstrictsScope(filters, true) { + return nil, apperrors.NewValidation("--filters 必须是包含非空筛选条件的 JSON 对象,根 operator 必须为 and/or") + } + return filters, nil +} + +func bulkPatchFilterConstrictsScope(value any, root bool) bool { + filter, ok := value.(map[string]any) + if !ok { + return false + } + operator, ok := filter["operator"].(string) + if !ok { + return false + } + operator = strings.ToLower(strings.TrimSpace(operator)) + if operator == "" { + return false + } + operands, ok := filter["operands"].([]any) + if !ok || len(operands) == 0 { + return false + } + if root && operator != "and" && operator != "or" { + return false + } + if operator == "and" || operator == "or" { + for _, operand := range operands { + if !bulkPatchFilterConstrictsScope(operand, false) { + return false + } + } + return true + } + switch operator { + case "eq", "ne", "gt", "lt", "gte", "lte", "contain", "exclusive", + "exist", "un_exist", "any_of", "all_of", "none_of", "date_eq", + "before", "after", "not_before", "not_after": + default: + return false + } + fieldID, ok := operands[0].(string) + if !ok || strings.TrimSpace(fieldID) == "" { + return false + } + return (operator == "exist" || operator == "un_exist") || len(operands) >= 2 +} diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index df470d64..948b29cd 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -189,6 +189,18 @@ func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testin {name: "false all", args: []string{"--all=false", "--patch", `{"f":1}`}}, {name: "invalid max", args: []string{"--all", "--patch", `{"f":1}`, "--max-matches", "0"}}, {name: "invalid filters", args: []string{"--filters", `{`, "--patch", `{"f":1}`}}, + {name: "null filters", args: []string{"--filters", `null`, "--patch", `{"f":1}`}}, + {name: "empty filters object", args: []string{"--filters", `{}`, "--patch", `{"f":1}`}}, + {name: "empty filters operands", args: []string{"--filters", `{"operator":"and","operands":[]}`, "--patch", `{"f":1}`}}, + {name: "missing filters operator", args: []string{"--filters", `{"operands":[{"operator":"eq","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}}, + {name: "blank child operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":" ","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}}, + {name: "null filters operands", args: []string{"--filters", `{"operator":"and","operands":null}`, "--patch", `{"f":1}`}}, + {name: "non condition operand", args: []string{"--filters", `{"operator":"and","operands":[null]}`, "--patch", `{"f":1}`}}, + {name: "root comparison", args: []string{"--filters", `{"operator":"eq","operands":["fld","x"]}`, "--patch", `{"f":1}`}}, + {name: "blank filter field", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":[" ","x"]}]}`, "--patch", `{"f":1}`}}, + {name: "unknown filter operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"unknown","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}}, + {name: "missing comparison value", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":["fld"]}]}`, "--patch", `{"f":1}`}}, + {name: "blank query", args: []string{"--query", ` `, "--patch", `{"f":1}`}}, {name: "empty record IDs", args: []string{"--record-ids", ` `, "--patch", `{"f":1}`}}, } for _, tc := range cases { @@ -196,16 +208,17 @@ func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testin args := []string{"--base-id", "base", "--table-id", "table"} args = append(args, tc.args...) args = append(args, "--yes") - out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-bulk-patch", args...) - if err == nil || out != "" { - t.Fatalf("validation = output:%q err:%v", out, err) + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", args...) + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("validation = output:%q err:%v calls:%#v", out, err, caller.calls) } }) } caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}} out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", - "--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and"}`, "--query", "text", + "--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"eq","operands":["fld","x"]}]}]}`, "--query", "text", "--record-ids", "r2,r1", "--view-id", "view", "--patch", `{"f":1}`, "--yes") if err != nil || !strings.Contains(out, `"matchedCount": 0`) { t.Fatalf("selectors = output:%q err:%v", out, err) From a92f54df3de3cd5e62eb2a6bc33664c671bff4f0 Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 14:42:27 +0800 Subject: [PATCH 05/10] fix(paging): restore zero-value safety limit --- internal/helpers/aitable.go | 6 +++- internal/helpers/aitable_coverage_gap_test.go | 25 +++++++++++++ pkg/paging/paging.go | 17 ++++++--- pkg/paging/paging_test.go | 35 +++++++++++++++++-- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 1ff70119..30f6e31a 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -139,6 +139,10 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error { } initialCursor, _ := requestArgs["cursor"].(string) + effectivePageLimit := pageLimit + if pageLimit == 0 { + effectivePageLimit = paging.UnlimitedPageLimit + } result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) { if cursor == "" { delete(requestArgs, "cursor") @@ -159,7 +163,7 @@ func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error { } return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content") }, paging.Options{ - PageLimit: pageLimit, + PageLimit: effectivePageLimit, InterPageDelay: paging.DefaultInterPageDelay, InitialCursor: initialCursor, }) diff --git a/internal/helpers/aitable_coverage_gap_test.go b/internal/helpers/aitable_coverage_gap_test.go index 009e60c3..8bb70634 100644 --- a/internal/helpers/aitable_coverage_gap_test.go +++ b/internal/helpers/aitable_coverage_gap_test.go @@ -5,6 +5,7 @@ package helpers import ( "encoding/json" + "fmt" "strings" "testing" @@ -66,3 +67,27 @@ func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) { t.Fatalf("incomplete result error = %v", err) } } + +func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) { + responses := make([]string, paging.DefaultPageLimit+1) + for index := range responses { + next := "" + if index < len(responses)-1 { + next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1) + } + responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next) + } + caller := &aitableTestCaller{responses: responses} + out := installAitableDeps(t, caller) + if err := recordQueryFetchAll(map[string]any{}, 0); err != nil { + t.Fatalf("explicit unlimited pagination failed: %v", err) + } + if len(caller.calls) != len(responses) { + t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses)) + } + for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} { + if !strings.Contains(out.String(), want) { + t.Fatalf("explicit unlimited output missing %s: %s", want, out.String()) + } + } +} diff --git a/pkg/paging/paging.go b/pkg/paging/paging.go index e0d05ce3..2b8d2a46 100644 --- a/pkg/paging/paging.go +++ b/pkg/paging/paging.go @@ -15,7 +15,7 @@ // 继续取"循环。设计目标: // // 1. 调用方只需提供 fetcher(拿单页)和 cursor 提取函数,循环逻辑由本包处理 -// 2. 支持 --page-limit 安全阀(调用方可用 DefaultPageLimit;0 = 无限制) +// 2. 支持分页安全阀(零值默认 50 页;显式 UnlimitedPageLimit 才不限页数) // 3. 支持页间退避,避免触发上游限流(默认 200ms) // 4. 任何一页失败时保留已取数据和可重试 cursor,并显式标记结果不完整 // @@ -50,12 +50,12 @@ type Page struct { // 返回给调用方,不直接中断流程。 type Fetcher func(ctx context.Context, cursor string) (Page, error) -// Options 控制 FetchAll 的行为。零值表示不限页数并使用默认页间退避。 +// Options 控制 FetchAll 的行为。零值使用默认页数上限和默认页间退避。 type Options struct { // PageLimit 最大翻页次数。 - // - 0 表示无限制(取到 NextCursor 为空为止) + // - 0 使用 DefaultPageLimit,保持 Options{} 的安全默认语义 + // - UnlimitedPageLimit 显式表示不限页数(取到 NextCursor 为空为止) // - >0 达到该次数后立即停止,返回 LastCursor 让调用方手动续拉 - // 面向终端用户的调用方可显式传 DefaultPageLimit 作为安全默认值。 PageLimit int // InterPageDelay 是页间退避时长。默认 200ms。 @@ -68,6 +68,10 @@ type Options struct { } const ( + // UnlimitedPageLimit 是调用方明确选择无限分页时使用的哨兵值。 + // 零值保留 DefaultPageLimit 的兼容和资源安全语义。 + UnlimitedPageLimit = -1 + // DefaultPageLimit 是 PageLimit 字段的默认值。 DefaultPageLimit = 50 @@ -123,6 +127,11 @@ type Result struct { // 不会主动抛 error;上游可基于 Result.Err / Result.Partial 决定如何向用户报告。 func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result { pageLimit := opts.PageLimit + if pageLimit == UnlimitedPageLimit { + pageLimit = 0 + } else if pageLimit <= 0 { + pageLimit = DefaultPageLimit + } delay := opts.InterPageDelay if delay == 0 { delay = DefaultInterPageDelay diff --git a/pkg/paging/paging_test.go b/pkg/paging/paging_test.go index c507a9b7..529971ed 100644 --- a/pkg/paging/paging_test.go +++ b/pkg/paging/paging_test.go @@ -6,6 +6,7 @@ package paging import ( "context" "errors" + "fmt" "testing" "time" ) @@ -137,8 +138,7 @@ func TestCrossPlatformCoverageFetchAllContextCancel(t *testing.T) { } func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) { - // PageLimit=0 是公开契约中的“无限制”。用超过默认安全阀的页数防止 - // 实现再次偷偷把 0 改写成 DefaultPageLimit。 + // 无限分页必须由哨兵值显式选择;用超过默认安全阀的页数证明它生效。 pages := make([]Page, DefaultPageLimit+1) for i := range pages { next := "" @@ -149,7 +149,7 @@ func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) { } s := &stubFetcher{pages: pages} got := FetchAll(context.Background(), s.Fetch, Options{ - PageLimit: 0, + PageLimit: UnlimitedPageLimit, InterPageDelay: 1 * time.Millisecond, }) if got.HasMore || !got.Complete || got.Pages != len(pages) || len(got.Records) != len(pages) { @@ -157,6 +157,35 @@ func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) { } } +func TestCrossPlatformCoverageFetchAllZeroValueUsesDefaultPageLimit(t *testing.T) { + pages := make([]Page, DefaultPageLimit+1) + for i := range pages { + pages[i] = Page{Records: []any{i}, NextCursor: fmt.Sprintf("cursor-%d", i+1)} + } + s := &stubFetcher{pages: pages} + got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Nanosecond}) + if got.Pages != DefaultPageLimit || got.Attempts != DefaultPageLimit || len(got.Records) != DefaultPageLimit { + t.Fatalf("zero-value page limit = %+v", got) + } + if !got.HasMore || got.Complete || got.Partial || got.StopReason != StopPageLimit || !errors.Is(got.Err, ErrPageLimitReached) { + t.Fatalf("zero-value safety result = %+v", got) + } + if s.calls != DefaultPageLimit { + t.Fatalf("zero-value fetch calls = %d, want %d", s.calls, DefaultPageLimit) + } +} + +func TestCrossPlatformCoverageFetchAllUnsupportedNegativeUsesDefaultPageLimit(t *testing.T) { + calls := 0 + got := FetchAll(context.Background(), func(context.Context, string) (Page, error) { + calls++ + return Page{NextCursor: fmt.Sprintf("cursor-%d", calls)}, nil + }, Options{PageLimit: -2, InterPageDelay: time.Nanosecond}) + if calls != DefaultPageLimit || got.StopReason != StopPageLimit { + t.Fatalf("unsupported negative page limit = %+v calls:%d", got, calls) + } +} + func TestCrossPlatformCoverageFetchAllPreservesServerTotalCount(t *testing.T) { total := 123 s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}} From b186e59a0195c855d5ce3459a5e03f825f0994f9 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 7 Aug 2026 14:32:58 +0800 Subject: [PATCH 06/10] feat: route reviewers by module ownership --- .github/reviewer-routing.js | 227 ++++++++++++++++++ .github/reviewer-routing.test.js | 73 ++++++ .github/workflows/ci.yml | 4 + .github/workflows/reviewer-router.yml | 111 ++++++--- test/scripts/reviewer_router_workflow_test.go | 39 ++- 5 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 .github/reviewer-routing.js create mode 100644 .github/reviewer-routing.test.js diff --git a/.github/reviewer-routing.js b/.github/reviewer-routing.js new file mode 100644 index 00000000..e1495ad5 --- /dev/null +++ b/.github/reviewer-routing.js @@ -0,0 +1,227 @@ +'use strict'; + +// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。 +const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong']; + +const PRODUCT_GROUPS = [ + { + primary: 'wxianfeng', + backup: 'typefield', + products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'], + }, + { + primary: 'typefield', + backup: 'wxianfeng', + products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'], + }, + { + primary: 'haofeng0705', + backup: 'typefield', + products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'], + }, +]; + +const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix)); + +const MODULES = [ + { + id: 'security', + label: '登录、认证、权限、安全', + primary: 'hlzjsong', + backup: 'typefield', + requiresSecondary: true, + matches: pathStartsWith([ + 'internal/auth/', + 'internal/keychain/', + 'internal/audit/', + 'internal/pat/', + 'internal/security/', + 'internal/safety/', + 'pkg/edition/', + ]), + }, + { + id: 'delivery', + label: 'CI、测试、发布、安装', + primary: 'haofeng0705', + backup: 'wxianfeng', + requiresSecondary: true, + matches: (path) => + path.startsWith('.github/') || + path.startsWith('scripts/release/') || + path.startsWith('scripts/policy/') || + path.startsWith('scripts/dev/') || + path.startsWith('scripts/install') || + path.startsWith('Formula/') || + path.startsWith('build/') || + path.startsWith('internal/upgrade/') || + path.startsWith('internal/app/upgrade') || + path.startsWith('test/') || + path.startsWith('verify/') || + path.startsWith('.workflow/') || + path === 'coverage.txt' || + path === 'coverage-base.txt' || + path === '.goreleaser.yaml' || + path === 'package.json' || + path === 'package-lock.json' || + path === 'docs/releasing.md', + }, + { + id: 'architecture', + label: 'DWS 架构、公共内核', + primary: 'wxianfeng', + backup: 'typefield', + requiresSecondary: true, + matches: pathStartsWith([ + 'cmd/', + 'internal/apiclient/', + 'internal/app/', + 'internal/cli/', + 'internal/cobracmd/', + 'internal/corecmd/', + 'internal/errors/', + 'internal/executor/', + 'internal/generator/', + 'internal/i18n/', + 'internal/interfacesnapshot/', + 'internal/jsonutil/', + 'internal/localio/', + 'internal/logging/', + 'internal/output/', + 'internal/pipeline/', + 'internal/plugin/', + 'internal/profilectx/', + 'internal/registry/', + 'internal/syncdata/', + 'internal/testseam/', + 'internal/transport/', + 'pkg/', + ]), + }, + { + id: 'compatibility', + label: '兼容性', + primary: 'wxianfeng', + backup: 'typefield', + requiresSecondary: true, + matches: (path) => + /(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) || + path.includes('schema_compat'), + }, +]; + +function productMatches(path, product) { + const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product]; + return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path)); +} + +const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) => + group.products.map((product) => ({ + id: `product:${product}`, + label: `产品:${product}`, + primary: group.primary, + backup: group.backup, + requiresSecondary: false, + matches: (path) => productMatches(path, product), + })), +); + +const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]]; + +function normalizedPaths(file) { + return [file?.filename, file?.previous_filename] + .filter((path) => typeof path === 'string' && path !== '') + .map((path) => path.toLowerCase()); +} + +function compareStats(left, right) { + return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id); +} + +function classifyFiles(files) { + const counts = new Map(); + for (const file of files || []) { + const matchingModules = new Set(); + for (const path of normalizedPaths(file)) { + const matches = ALL_MODULES.filter((module) => module.matches(path)); + const securityOrDelivery = matches.filter( + (module) => module.id === 'security' || module.id === 'delivery', + ); + const effectiveMatches = securityOrDelivery.length > 0 + ? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')] + : matches; + for (const match of effectiveMatches) { + matchingModules.add(match.id); + } + if ( + effectiveMatches.length === 0 && + (path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/')) + ) { + matchingModules.add('architecture'); + } + } + for (const moduleID of matchingModules) { + counts.set(moduleID, (counts.get(moduleID) || 0) + 1); + } + } + + return [...counts.entries()] + .map(([id, files]) => { + const index = ALL_MODULES.findIndex((module) => module.id === id); + return {module: {...ALL_MODULES[index], order: index}, files}; + }) + .sort(compareStats); +} + +function chooseModuleReviewer(module, unavailable) { + return [module.primary, module.backup].find( + (reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer), + ); +} + +function addReviewer(reviewers, reviewer) { + if (reviewer && !reviewers.includes(reviewer)) { + reviewers.push(reviewer); + } +} + +function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) { + const modules = classifyFiles(files); + const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase())); + const reviewers = []; + const primaryModule = modules[0]; + + if (!primaryModule) { + return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'}; + } + + addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable)); + + const requiresSecondary = + modules.length > 1 || modules.some(({module}) => module.requiresSecondary); + const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule; + if (requiresSecondary) { + addReviewer( + reviewers, + chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])), + ); + } + + for (const reviewer of fallbackReviewers) { + if (reviewers.length >= (requiresSecondary ? 2 : 1)) { + break; + } + if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) { + addReviewer(reviewers, reviewer); + } + } + + return { + modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})), + reviewers, + requiredReviewers: requiresSecondary ? 2 : 1, + reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module', + }; +} + +module.exports = {REVIEWER_POOL, classifyFiles, resolveReviewRouting}; diff --git a/.github/reviewer-routing.test.js b/.github/reviewer-routing.test.js new file mode 100644 index 00000000..49a6fa5d --- /dev/null +++ b/.github/reviewer-routing.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const {resolveReviewRouting} = require('./reviewer-routing'); + +function route(files, author = 'author', latestPusher = author) { + return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher}); +} + +{ + const result = route(['internal/helpers/chat_toolbar.go']); + assert.deepEqual(result.reviewers, ['wxianfeng']); + assert.equal(result.requiredReviewers, 1); + assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']); +} + +{ + const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']); + assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['.github/workflows/ci.yml']); + assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']); + assert.equal(result.requiredReviewers, 2); + assert.equal(result.reason, 'cross_or_sensitive'); +} + +{ + const result = route(['internal/auth/login.go'], 'hlzjsong'); + assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['internal/upgrade/downloader.go']); + assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']); + assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['pkg/edition/edition.go']); + assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']); + assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']); + assert.equal(result.requiredReviewers, 2); + assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']); +} + +{ + const result = route(['internal/helpers/leaf_dispatch.go']); + assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']); + assert.equal(result.requiredReviewers, 2); +} + +{ + const result = route(['docs/unknown-area.md']); + assert.deepEqual(result.reviewers, []); + assert.equal(result.reason, 'unknown_paths'); +} + +console.log('reviewer routing policy tests passed'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47d19963..97e71d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,6 +433,10 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + - name: Test reviewer routing policy + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + run: node .github/reviewer-routing.test.js + test-focused: name: Test (changed packages) needs: lint diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index af1b8692..b3f16459 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -21,6 +21,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Check out trusted routing policy + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} - name: Route review and enable auto-merge uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: @@ -29,11 +33,8 @@ jobs: const repo = context.repo.repo; const pullNumber = context.payload.pull_request.number; const eventHeadSha = context.payload.pull_request.head.sha; - const reviewerPool = [ - 'wxianfeng', - 'typefield', - 'haofeng0705', - ]; + const {REVIEWER_POOL, resolveReviewRouting} = require('./.github/reviewer-routing.js'); + const reviewerPool = REVIEWER_POOL; async function getReadyEventPull(phase) { const {data: currentPull} = await github.rest.pulls.get({ @@ -66,6 +67,21 @@ jobs: : author; async function routeReview() { + let changedFiles; + try { + changedFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + } catch (error) { + core.warning( + `Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`, + ); + changedFiles = []; + } + const eligible = reviewerPool.filter( reviewer => reviewer.toLowerCase() !== author && @@ -76,13 +92,9 @@ jobs: return; } - const alreadyRequested = - (pullRequest.requested_reviewers || []).length > 0 || - (pullRequest.requested_teams || []).length > 0; - if (alreadyRequested) { - core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`); - return; - } + const existingRequestedReviewers = new Set( + (pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()), + ); let reviews; try { @@ -115,22 +127,18 @@ jobs: latestDecisionByLogin.set(login, review); } } - const currentHeadDecision = [...latestDecisionByLogin.values()].find( - review => - review.commit_id === headSha && - eligible.some( - reviewer => - reviewer.toLowerCase() === - review.user.login.toLowerCase(), - ) && - ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state), + const currentHeadReviewers = new Set( + [...latestDecisionByLogin.values()] + .filter( + review => + review.commit_id === headSha && + eligible.some( + reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(), + ) && + ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state), + ) + .map(review => review.user.login.toLowerCase()), ); - if (currentHeadDecision) { - core.info( - `PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`, - ); - return; - } const loads = new Map(eligible.map(reviewer => [reviewer, 0])); try { @@ -177,31 +185,54 @@ jobs: tieOrder.get(left) - tieOrder.get(right), ); - for (const reviewer of ranked) { + const routing = resolveReviewRouting({ + files: changedFiles, + author, + latestPusher, + fallbackReviewers: ranked, + }); + const desiredReviewers = []; + for (const reviewer of [...routing.reviewers, ...ranked]) { + if ( + desiredReviewers.length < routing.requiredReviewers && + eligible.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase()) && + !desiredReviewers.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase()) + ) { + desiredReviewers.push(reviewer); + } + } + const reviewersToRequest = desiredReviewers.filter( + reviewer => + !currentHeadReviewers.has(reviewer.toLowerCase()) && + !existingRequestedReviewers.has(reviewer.toLowerCase()), + ); + if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) { + core.info( + `PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`, + ); + return; + } + core.info( + `PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`, + ); + + const requestedByRouter = new Set(); + for (const reviewer of reviewersToRequest) { try { const currentPull = await getReadyEventPull('review request'); if (!currentPull) { return; } - if ( - (currentPull.requested_reviewers || []).length > 0 || - (currentPull.requested_teams || []).length > 0 - ) { - core.info( - `PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`, - ); - return; - } await github.rest.pulls.requestReviewers({ owner, repo, pull_number: pullNumber, reviewers: [reviewer], }); + requestedByRouter.add(reviewer.toLowerCase()); core.info( `Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`, ); - return; } catch (error) { core.warning( `Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`, @@ -209,7 +240,9 @@ jobs: } } - core.warning(`No reviewer request could be created for PR #${pullNumber}.`); + if (requestedByRouter.size === 0) { + core.warning(`No reviewer request could be created for PR #${pullNumber}.`); + } } async function enableAutoMerge() { diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index e99070b6..cdc7572d 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -50,6 +50,23 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { if err := yaml.Unmarshal(data, &workflow); err != nil { t.Fatalf("yaml.Unmarshal(%s) error = %v", path, err) } + policyPath := filepath.Join(filepath.Dir(filepath.Dir(path)), "reviewer-routing.js") + policy, err := os.ReadFile(policyPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", policyPath, err) + } + for _, want := range []string{ + "'wxianfeng'", + "'typefield'", + "'haofeng0705'", + "'hlzjsong'", + "resolveReviewRouting", + "unknown_paths", + } { + if !strings.Contains(string(policy), want) { + t.Errorf("reviewer routing policy is missing contract marker %q", want) + } + } if len(workflow.On) != 1 { t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On) @@ -85,17 +102,16 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { t.Fatalf("route.if = %q, want non-draft guard", job.If) } const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b" - if len(job.Steps) != 1 || job.Steps[0].Uses != githubScriptSHA { - t.Fatalf("route steps = %#v, want one pinned base-owned github-script step", job.Steps) + if len(job.Steps) != 2 || job.Steps[0].Uses != "actions/checkout@v4" || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[1].Uses != githubScriptSHA { + t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps) } - script := job.Steps[0].With["script"] + script := job.Steps[1].With["script"] for _, want := range []string{ - "'sczheng189'", - "'shangguanxuan633-lab'", - "'audanye-sudo'", - "'wxianfeng'", + "REVIEWER_POOL", + "resolveReviewRouting", "github.rest.pulls.get", + "github.rest.pulls.listFiles", "currentPull.head.sha !== eventHeadSha", "currentPull.state !== 'open'", "currentPull.draft", @@ -107,9 +123,10 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "reviewer.toLowerCase() !== author", "reviewer.toLowerCase() !== latestPusher", "pullRequest.requested_reviewers", - "pullRequest.requested_teams", + "existingRequestedReviewers", "github.rest.pulls.listReviews", "['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes", + "currentHeadReviewers", "review.commit_id === headSha", "['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)", "review.state === 'CHANGES_REQUESTED'", @@ -117,7 +134,10 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "state: 'open'", "loads.set(candidate, loads.get(candidate) + 1)", "loads.get(left) - loads.get(right)", - "for (const reviewer of ranked)", + "routing.requiredReviewers", + "reviewersToRequest", + "for (const reviewer of reviewersToRequest)", + "requestedByRouter", "github.rest.pulls.requestReviewers", "trying the next candidate", "Reviewer routing hit an unexpected error", @@ -131,7 +151,6 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { } for _, forbidden := range []string{ - "actions/checkout", "['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']", "PeterGuy326", "core.setFailed", From 7490bb95c5df6183d0926fe5b92002c2cbfaf2cc Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 15:09:58 +0800 Subject: [PATCH 07/10] fix(aitable): scope strict write response checks --- internal/helpers/aitable.go | 4 ++-- .../helpers/aitable_record_query_e2e_test.go | 19 +++++++++++++++++++ internal/helpers/helpers.go | 4 ++-- .../shortcut/aitable/attachment_composite.go | 6 +++--- internal/shortcut/aitable/base_composite.go | 6 +++--- internal/shortcut/aitable/record_batch.go | 4 ++-- .../shortcut/aitable/record_upsert_by_key.go | 2 +- internal/shortcut/aitable/table_copy.go | 6 +++--- internal/shortcut/aitable/view_preset.go | 2 +- internal/shortcut/aitable/workflow_deploy.go | 4 ++-- internal/shortcut/runner.go | 13 ++++++++++++- internal/shortcut/runner_coverage_test.go | 16 ++++++++++++++++ internal/shortcut/shortcut_test.go | 17 +++++++++++------ 13 files changed, 77 insertions(+), 26 deletions(-) diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 30f6e31a..7dc93a09 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -7051,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)") _ = recordQueryCmd.Flags().MarkHidden("page-size") recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录") - recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限") - recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制") + recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出") + recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor") recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)") _ = recordQueryCmd.Flags().MarkHidden("view-id") recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)") diff --git a/internal/helpers/aitable_record_query_e2e_test.go b/internal/helpers/aitable_record_query_e2e_test.go index 9c9c6634..ac0af18b 100644 --- a/internal/helpers/aitable_record_query_e2e_test.go +++ b/internal/helpers/aitable_record_query_e2e_test.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "reflect" + "strings" "testing" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -171,3 +172,21 @@ func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) { t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want) } } + +func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) { + command := newAitableCommand() + command.SilenceErrors = true + command.SilenceUsage = true + out := &bytes.Buffer{} + command.SetOut(out) + command.SetErr(out) + command.SetArgs([]string{"record", "query", "--help"}) + if err := command.Execute(); err != nil { + t.Fatalf("record query help failed: %v", err) + } + for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("record query help missing %q:\n%s", want, out.String()) + } + } +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index a366c0b6..f96d2bc5 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -255,8 +255,8 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul // acknowledgement. This low-level parser cannot know the business contract, // so preserve that representation. Data-returning callers must validate the // operation-specific shape (for example records/tables/valid) before they - // report success; write orchestrators use CallMCPWriteData, which treats an - // empty response as unknown unless an independent read-back proves the state. + // report success. Orchestrators whose contract requires a business result use + // CallMCPWriteDataStrict and may prove an unknown effect by independent read-back. return "", nil } diff --git a/internal/shortcut/aitable/attachment_composite.go b/internal/shortcut/aitable/attachment_composite.go index 3060f7f8..60fc8b9a 100644 --- a/internal/shortcut/aitable/attachment_composite.go +++ b/internal/shortcut/aitable/attachment_composite.go @@ -121,7 +121,7 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error { result.Executed = false return rt.Output(result) } - prepareData, err := rt.CallMCPWriteData(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType}) + prepareData, err := rt.CallMCPWriteDataStrict(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType}) if err != nil { result.Status = "unknown" return compositeError(result, err, false) @@ -156,7 +156,7 @@ func executeAttachmentPut(rt *shortcut.RuntimeContext) error { } desired = append(desired, map[string]any{"fileToken": fileToken}) result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "HTTP PUT", "fileToken": fileToken, "fileName": info.Name(), "size": info.Size()}) - _, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{ + _, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{ "baseId": baseID, "tableId": tableID, "records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}}, }) @@ -226,7 +226,7 @@ func executeAttachmentRemove(rt *shortcut.RuntimeContext) error { result.Executed = false return rt.Output(result) } - _, writeErr := rt.CallMCPWriteData(serverMain, "update_records", map[string]any{ + _, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{ "baseId": baseID, "tableId": tableID, "records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}}, }) diff --git a/internal/shortcut/aitable/base_composite.go b/internal/shortcut/aitable/base_composite.go index 73971707..1addc982 100644 --- a/internal/shortcut/aitable/base_composite.go +++ b/internal/shortcut/aitable/base_composite.go @@ -191,7 +191,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error { if rt.Changed("template-id") { baseArgs["templateId"] = rt.Str("template-id") } - baseData, err := rt.CallMCPWriteData(serverMain, "create_base", baseArgs) + baseData, err := rt.CallMCPWriteDataStrict(serverMain, "create_base", baseArgs) if err != nil { result.Status = "unknown" result.FailedCount = len(tables) @@ -222,7 +222,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error { for index, spec := range tables { initialEnd := minInt(15, len(spec.Fields)) initialFields := spec.Fields[:initialEnd] - createData, createErr := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{ + createData, createErr := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{ "baseId": baseID, "tableName": spec.Name, "fields": initialFields, }) tableID := findStringByKeys(createData, "tableId", "sheetId") @@ -239,7 +239,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error { result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name}) for offset := initialEnd; offset < len(spec.Fields); offset += 15 { end := minInt(offset+15, len(spec.Fields)) - _, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{ + _, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{ "baseId": baseID, "tableId": tableID, "fields": spec.Fields[offset:end], }) if fieldErr != nil { diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 9ad7f9ad..8c26c3b1 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -87,7 +87,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error { for offset := 0; offset < len(ids); offset += recordBatchSize { end := minInt(offset+recordBatchSize, len(ids)) batch := ids[offset:end] - writeData, writeErr := rt.CallMCPWriteData(serverMain, "delete_records", map[string]any{ + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{ "baseId": baseID, "tableId": tableID, "recordIds": batch, }) step := compositeStep{ @@ -192,7 +192,7 @@ func executeRecordBatches( wireRecords = append(wireRecords, record) } params := map[string]any{"baseId": baseID, "tableId": tableID, "records": wireRecords} - writeData, writeErr := rt.CallMCPWriteData(product, tool, params) + writeData, writeErr := rt.CallMCPWriteDataStrict(product, tool, params) step := compositeStep{ Index: len(result.CompletedSteps) + 1, Name: "write record batch", Tool: tool, Status: "completed", Offset: offset, Count: len(batch), Result: writeData, diff --git a/internal/shortcut/aitable/record_upsert_by_key.go b/internal/shortcut/aitable/record_upsert_by_key.go index 038afa45..b6c3efbc 100644 --- a/internal/shortcut/aitable/record_upsert_by_key.go +++ b/internal/shortcut/aitable/record_upsert_by_key.go @@ -129,7 +129,7 @@ func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error { return rt.Output(result) } - writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params) writeStep := compositeStep{Index: 1, Name: action + " record", Tool: tool, Status: "completed", Count: 1, Result: writeData} if writeErr != nil { writeStep.Status = "unknown" diff --git a/internal/shortcut/aitable/table_copy.go b/internal/shortcut/aitable/table_copy.go index 6da8c639..05df6c78 100644 --- a/internal/shortcut/aitable/table_copy.go +++ b/internal/shortcut/aitable/table_copy.go @@ -113,7 +113,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error { } initialEnd := minInt(15, len(createFields)) - createData, err := rt.CallMCPWriteData(serverMain, "create_table", map[string]any{ + createData, err := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{ "baseId": targetBase, "tableName": rt.Str("new-name"), "fields": createFields[:initialEnd], }) targetTable := findStringByKeys(createData, "tableId", "sheetId") @@ -129,7 +129,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error { result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": targetBase, "tableId": targetTable}) for offset := initialEnd; offset < len(createFields); offset += 15 { end := minInt(offset+15, len(createFields)) - _, fieldErr := rt.CallMCPWriteData(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]}) + _, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]}) if fieldErr != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final schema verification decides success: %v", offset, fieldErr)) } @@ -172,7 +172,7 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error { batch = append(batch, record) wire = append(wire, record) } - writeData, writeErr := rt.CallMCPWriteData(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire}) + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire}) createdIDs := createdRecordIDs(writeData) if len(createdIDs) == 0 { if returned, found := findRecords(writeData); found { diff --git a/internal/shortcut/aitable/view_preset.go b/internal/shortcut/aitable/view_preset.go index ef53e0df..6a16aebc 100644 --- a/internal/shortcut/aitable/view_preset.go +++ b/internal/shortcut/aitable/view_preset.go @@ -96,7 +96,7 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error { result.Executed = false return rt.Output(result) } - writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params) if action == "create" { viewID = findStringByKeys(writeData, "viewId") } diff --git a/internal/shortcut/aitable/workflow_deploy.go b/internal/shortcut/aitable/workflow_deploy.go index d4f8f9c4..fc18390a 100644 --- a/internal/shortcut/aitable/workflow_deploy.go +++ b/internal/shortcut/aitable/workflow_deploy.go @@ -75,7 +75,7 @@ func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error { result.Executed = false return rt.Output(result) } - writeData, writeErr := rt.CallMCPWriteData(serverMain, tool, params) + writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params) if writeErr != nil { result.Status = "unknown" result.Checkpoint = map[string]any{"nextStep": "resolve workflow existence and definition before retrying", "workflowId": workflowID} @@ -105,7 +105,7 @@ func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error { return compositeError(result, verifyErr, action == "update") } if rt.Bool("enable") { - enableData, enableErr := rt.CallMCPWriteData(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID}) + enableData, enableErr := rt.CallMCPWriteDataStrict(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID}) if enableErr != nil { result.Warnings = append(result.Warnings, "enable response error: "+enableErr.Error()) } diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index df1ed6bd..09810782 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -155,8 +155,19 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an // CallMCPWriteData dispatches a write tool call and returns its parsed response. // Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot -// accidentally perform writes while rendering a preview. +// accidentally perform writes while rendering a preview. For compatibility with +// existing write shortcuts, an empty text acknowledgement remains an empty map. func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) { + if rt.DryRun() { + return nil, dryRunWriteError(product, tool) + } + return rt.callMCPData(product, tool, params) +} + +// CallMCPWriteDataStrict dispatches a write tool call whose contract requires a +// non-empty JSON business result. An empty acknowledgement is reported as an +// unknown remote effect so callers can verify it independently before success. +func (rt *RuntimeContext) CallMCPWriteDataStrict(product, tool string, params map[string]any) (map[string]any, error) { if rt.DryRun() { return nil, dryRunWriteError(product, tool) } diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index 51f90346..b5d7d8dd 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -4,9 +4,11 @@ package shortcut import ( + "errors" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/spf13/cobra" ) @@ -31,4 +33,18 @@ func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) { if caller.args == nil { t.Fatal("nil write parameters were not normalized") } + + caller.text = "" + legacy, err := rt.CallMCPWriteData("chat", "send_personal_message", nil) + if err != nil || legacy == nil || len(legacy) != 0 { + t.Fatalf("legacy empty acknowledgement = %#v, %v", legacy, err) + } + if _, err = rt.CallMCPWriteDataStrict("aitable", "update_records", nil); err == nil { + t.Fatal("strict empty acknowledgement was accepted") + } else { + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "empty_tool_response" || !typed.RetryableSet || typed.Retryable { + t.Fatalf("strict empty acknowledgement error = %#v", err) + } + } } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 7cd6cf81..b1a5139c 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -671,12 +671,17 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) { } rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "calendar"}} - _, err := rt.CallMCPWriteData("calendar", "create_calendar_event", map[string]any{"summary": "x"}) - if err == nil { - t.Fatal("expected dry-run write guard error") - } - if !strings.Contains(err.Error(), "calendar/create_calendar_event") { - t.Fatalf("error = %q, want tool name", err.Error()) + for name, call := range map[string]func(string, string, map[string]any) (map[string]any, error){ + "legacy": rt.CallMCPWriteData, + "strict": rt.CallMCPWriteDataStrict, + } { + _, err := call("calendar", "create_calendar_event", map[string]any{"summary": "x"}) + if err == nil { + t.Fatalf("%s: expected dry-run write guard error", name) + } + if !strings.Contains(err.Error(), "calendar/create_calendar_event") { + t.Fatalf("%s: error = %q, want tool name", name, err.Error()) + } } } From 5c8fd0a48c8fe5e1cac46005f612e118e9bf2047 Mon Sep 17 00:00:00 2001 From: chichuan Date: Fri, 7 Aug 2026 15:20:41 +0800 Subject: [PATCH 08/10] fix: preserve reviewer routing fallback --- .github/reviewer-routing.js | 60 +++++++++++++- .github/reviewer-routing.test.js | 79 ++++++++++++++++++- .github/workflows/reviewer-router.yml | 63 +++++++++------ test/scripts/reviewer_router_workflow_test.go | 9 ++- 4 files changed, 178 insertions(+), 33 deletions(-) diff --git a/.github/reviewer-routing.js b/.github/reviewer-routing.js index e1495ad5..0dd93ed8 100644 --- a/.github/reviewer-routing.js +++ b/.github/reviewer-routing.js @@ -185,6 +185,58 @@ function addReviewer(reviewers, reviewer) { } } +function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) { + const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase())); + const candidates = []; + for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) { + if ( + eligible.has(reviewer.toLowerCase()) && + !candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase()) + ) { + candidates.push(reviewer); + } + } + return candidates; +} + +async function requestReviewersWithFallback({ + candidates, + requiredReviewers, + satisfiedReviewers = [], + requestReviewer, + onFailure = () => {}, +}) { + const alreadySatisfied = new Set( + satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()), + ); + const satisfied = new Set(); + const requested = []; + + for (const reviewer of candidates) { + if (satisfied.size >= requiredReviewers) { + break; + } + const normalizedReviewer = reviewer.toLowerCase(); + if (alreadySatisfied.has(normalizedReviewer)) { + satisfied.add(normalizedReviewer); + continue; + } + + try { + const shouldContinue = await requestReviewer(reviewer); + if (shouldContinue === false) { + return {requested, satisfiedReviewers: [...satisfied], aborted: true}; + } + requested.push(reviewer); + satisfied.add(normalizedReviewer); + } catch (error) { + onFailure(reviewer, error); + } + } + + return {requested, satisfiedReviewers: [...satisfied], aborted: false}; +} + function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) { const modules = classifyFiles(files); const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase())); @@ -224,4 +276,10 @@ function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = }; } -module.exports = {REVIEWER_POOL, classifyFiles, resolveReviewRouting}; +module.exports = { + REVIEWER_POOL, + classifyFiles, + requestReviewersWithFallback, + resolveReviewRouting, + reviewerCandidates, +}; diff --git a/.github/reviewer-routing.test.js b/.github/reviewer-routing.test.js index 49a6fa5d..3ad317fd 100644 --- a/.github/reviewer-routing.test.js +++ b/.github/reviewer-routing.test.js @@ -1,7 +1,11 @@ 'use strict'; const assert = require('node:assert/strict'); -const {resolveReviewRouting} = require('./reviewer-routing'); +const { + requestReviewersWithFallback, + resolveReviewRouting, + reviewerCandidates, +} = require('./reviewer-routing'); function route(files, author = 'author', latestPusher = author) { return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher}); @@ -70,4 +74,75 @@ function route(files, author = 'author', latestPusher = author) { assert.equal(result.reason, 'unknown_paths'); } -console.log('reviewer routing policy tests passed'); +async function testSingleReviewerFallback() { + const candidates = reviewerCandidates({ + preferredReviewers: ['wxianfeng'], + fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'], + eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'], + }); + const attempts = []; + const result = await requestReviewersWithFallback({ + candidates, + requiredReviewers: 1, + requestReviewer: async (reviewer) => { + attempts.push(reviewer); + if (reviewer === 'wxianfeng') { + throw Object.assign(new Error('cannot request primary'), {status: 422}); + } + return true; + }, + }); + assert.deepEqual(attempts, ['wxianfeng', 'typefield']); + assert.deepEqual(result.requested, ['typefield']); + assert.equal(result.satisfiedReviewers.length, 1); +} + +async function testTwoReviewerFallback() { + const candidates = reviewerCandidates({ + preferredReviewers: ['haofeng0705', 'wxianfeng'], + fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'], + eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'], + }); + const attempts = []; + const result = await requestReviewersWithFallback({ + candidates, + requiredReviewers: 2, + requestReviewer: async (reviewer) => { + attempts.push(reviewer); + if (reviewer === 'wxianfeng') { + throw Object.assign(new Error('temporary failure'), {status: 503}); + } + return true; + }, + }); + assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']); + assert.deepEqual(result.requested, ['haofeng0705', 'typefield']); + assert.equal(result.satisfiedReviewers.length, 2); +} + +async function testLowerPriorityExistingRequestDoesNotReplaceOwner() { + const attempts = []; + const result = await requestReviewersWithFallback({ + candidates: ['wxianfeng', 'typefield'], + requiredReviewers: 1, + satisfiedReviewers: ['typefield'], + requestReviewer: async (reviewer) => { + attempts.push(reviewer); + return true; + }, + }); + assert.deepEqual(attempts, ['wxianfeng']); + assert.deepEqual(result.requested, ['wxianfeng']); + assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']); +} + +Promise.all([ + testSingleReviewerFallback(), + testTwoReviewerFallback(), + testLowerPriorityExistingRequestDoesNotReplaceOwner(), +]) + .then(() => console.log('reviewer routing policy tests passed')) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index b3f16459..105ac713 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -22,9 +22,10 @@ jobs: timeout-minutes: 5 steps: - name: Check out trusted routing policy - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - name: Route review and enable auto-merge uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: @@ -33,7 +34,12 @@ jobs: const repo = context.repo.repo; const pullNumber = context.payload.pull_request.number; const eventHeadSha = context.payload.pull_request.head.sha; - const {REVIEWER_POOL, resolveReviewRouting} = require('./.github/reviewer-routing.js'); + const { + REVIEWER_POOL, + requestReviewersWithFallback, + resolveReviewRouting, + reviewerCandidates, + } = require('./.github/reviewer-routing.js'); const reviewerPool = REVIEWER_POOL; async function getReadyEventPull(phase) { @@ -191,21 +197,12 @@ jobs: latestPusher, fallbackReviewers: ranked, }); - const desiredReviewers = []; - for (const reviewer of [...routing.reviewers, ...ranked]) { - if ( - desiredReviewers.length < routing.requiredReviewers && - eligible.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase()) && - !desiredReviewers.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase()) - ) { - desiredReviewers.push(reviewer); - } - } - const reviewersToRequest = desiredReviewers.filter( - reviewer => - !currentHeadReviewers.has(reviewer.toLowerCase()) && - !existingRequestedReviewers.has(reviewer.toLowerCase()), - ); + const candidates = reviewerCandidates({ + preferredReviewers: routing.reviewers, + fallbackReviewers: ranked, + eligibleReviewers: eligible, + }); + const desiredReviewers = candidates.slice(0, routing.requiredReviewers); if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) { core.info( `PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`, @@ -216,12 +213,20 @@ jobs: `PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`, ); - const requestedByRouter = new Set(); - for (const reviewer of reviewersToRequest) { - try { + const satisfiedReviewers = new Set([ + ...currentHeadReviewers, + ...[...existingRequestedReviewers].filter((reviewer) => + candidates.some((candidate) => candidate.toLowerCase() === reviewer), + ), + ]); + const requestResult = await requestReviewersWithFallback({ + candidates, + requiredReviewers: routing.requiredReviewers, + satisfiedReviewers: [...satisfiedReviewers], + requestReviewer: async (reviewer) => { const currentPull = await getReadyEventPull('review request'); if (!currentPull) { - return; + return false; } await github.rest.pulls.requestReviewers({ owner, @@ -229,19 +234,25 @@ jobs: pull_number: pullNumber, reviewers: [reviewer], }); - requestedByRouter.add(reviewer.toLowerCase()); core.info( `Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`, ); - } catch (error) { + return true; + }, + onFailure: (reviewer, error) => { core.warning( `Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`, ); - } + }, + }); + if (requestResult.aborted) { + return; } - if (requestedByRouter.size === 0) { - core.warning(`No reviewer request could be created for PR #${pullNumber}.`); + if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) { + core.warning( + `Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`, + ); } } diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index cdc7572d..61fe102a 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -101,8 +101,9 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { if job.If != "github.event.pull_request.draft == false" { t.Fatalf("route.if = %q, want non-draft guard", job.If) } + const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b" - if len(job.Steps) != 2 || job.Steps[0].Uses != "actions/checkout@v4" || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[1].Uses != githubScriptSHA { + if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA { t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps) } @@ -135,9 +136,9 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "loads.set(candidate, loads.get(candidate) + 1)", "loads.get(left) - loads.get(right)", "routing.requiredReviewers", - "reviewersToRequest", - "for (const reviewer of reviewersToRequest)", - "requestedByRouter", + "reviewerCandidates", + "requestReviewersWithFallback", + "satisfiedReviewers", "github.rest.pulls.requestReviewers", "trying the next candidate", "Reviewer routing hit an unexpected error", From 176b217139a06a8ba5da04012e7aa98b3d76eaaa Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 15:32:01 +0800 Subject: [PATCH 09/10] fix(aitable): require bootstrap confirmation --- internal/shortcut/aitable/base_composite.go | 2 +- .../aitable/base_composite_e2e_test.go | 38 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/internal/shortcut/aitable/base_composite.go b/internal/shortcut/aitable/base_composite.go index 1addc982..bcbae88e 100644 --- a/internal/shortcut/aitable/base_composite.go +++ b/internal/shortcut/aitable/base_composite.go @@ -44,7 +44,7 @@ var BaseBootstrap = shortcut.Shortcut{ Intent: "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ - Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent", + Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: aitableCompositeContract( "+base-bootstrap", diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index dbf7515a..1cef390e 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -108,7 +108,7 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin {text: `{"tables":[{"tableId":"t-new","name":"任务"}]}`}, {text: fieldReadBackJSON(t, fields)}, }} - out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields)) + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields), "--yes") if err != nil { t.Fatalf("bootstrap error = %v", err) } @@ -117,9 +117,21 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin t.Fatalf("bootstrap output missing %s: %s", want, out) } } - if len(caller.calls) != 6 || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" { + if len(caller.calls) != 6 || caller.calls[0].tool != "create_base" || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" { t.Fatalf("bootstrap calls = %#v", caller.calls) } + if caller.calls[0].args["baseName"] != "项目" { + t.Fatalf("create_base args = %#v", caller.calls[0].args) + } + createBaseCalls := 0 + for _, call := range caller.calls { + if call.tool == "create_base" { + createBaseCalls++ + } + } + if createBaseCalls != 1 { + t.Fatalf("create_base calls = %d, want exactly 1; calls = %#v", createBaseCalls, caller.calls) + } if got := len(caller.calls[2].args["fields"].([]any)); got != 15 { t.Fatalf("create_table fields = %d, want 15", got) } @@ -128,10 +140,25 @@ func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testin } } +func TestCrossPlatformCoverageBaseBootstrapRequiresConfirmationBeforeMCP(t *testing.T) { + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil)) + if out != "" { + t.Fatalf("unconfirmed bootstrap output = %q, want empty", out) + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("unconfirmed bootstrap error = %#v, want confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed bootstrap reached MCP: %#v", caller.calls) + } +} + func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) { t.Run("empty create response is unknown and not retry-safe", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}} - out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil)) + out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--yes") if err == nil || out != "" { t.Fatalf("unknown base create = output:%q err:%v", out, err) } @@ -207,7 +234,7 @@ func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) { } func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) { - out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`) + out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`, "--yes") if err == nil || out != "" { t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err) } @@ -239,6 +266,7 @@ func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) { } args := []string{"--name", "Project", "--tables", inputTables} args = append(args, tc.extra...) + args = append(args, "--yes") out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...) if err == nil || out != "" { t.Fatalf("bootstrap failure = output:%q err:%v", out, err) @@ -258,7 +286,7 @@ func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing. {text: fieldReadBackJSON(t, fields)}, }} out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", - "--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template") + "--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template", "--yes") if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") { t.Fatalf("field recovery = output:%q err:%v", out, err) } From 48e522ec00f667c235a751018092125528511f53 Mon Sep 17 00:00:00 2001 From: Dennis Date: Fri, 7 Aug 2026 15:50:35 +0800 Subject: [PATCH 10/10] fix(aitable): harden pagination and upload inputs --- .../shortcut/aitable/attachment_composite.go | 9 +++++- .../aitable/attachment_composite_e2e_test.go | 29 +++++++++++++++++++ .../aitable/record_coverage_gap_test.go | 21 +++++++++++++- internal/shortcut/aitable/record_query_all.go | 2 +- .../shortcut/aitable/record_upsert_by_key.go | 5 ++++ .../aitable/table_copy_bulk_patch_e2e_test.go | 19 ++++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/internal/shortcut/aitable/attachment_composite.go b/internal/shortcut/aitable/attachment_composite.go index 60fc8b9a..449bd4a6 100644 --- a/internal/shortcut/aitable/attachment_composite.go +++ b/internal/shortcut/aitable/attachment_composite.go @@ -21,7 +21,14 @@ import ( const maxAttachmentFileSize = int64(100 * 1024 * 1024) -var attachmentHTTPDo = http.DefaultClient.Do +func rejectAttachmentUploadRedirect(*http.Request, []*http.Request) error { + // Upload credentials are issued for one exact endpoint. Do not let a 3xx + // response redirect local file bytes or the credential to another host or + // to a URL outside validateAttachmentUploadURL's transport policy. + return http.ErrUseLastResponse +} + +var attachmentHTTPDo = (&http.Client{CheckRedirect: rejectAttachmentUploadRedirect}).Do var AttachmentPut = shortcut.Shortcut{ Service: "aitable", diff --git a/internal/shortcut/aitable/attachment_composite_e2e_test.go b/internal/shortcut/aitable/attachment_composite_e2e_test.go index 37b257d5..6c25621b 100644 --- a/internal/shortcut/aitable/attachment_composite_e2e_test.go +++ b/internal/shortcut/aitable/attachment_composite_e2e_test.go @@ -112,6 +112,35 @@ func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *test }) } +func TestCrossPlatformCoverageAttachmentPutRejectsHTTPSRedirectToPlaintextNonLoopbackE2E(t *testing.T) { + filePath := writeAttachmentFixture(t, "redirect-sensitive bytes") + sourceHits := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + sourceHits++ + w.Header().Set("Location", "http://example.com/upload") + w.WriteHeader(http.StatusFound) + })) + defer server.Close() + client := server.Client() + client.CheckRedirect = rejectAttachmentUploadRedirect + testseam.Swap(t, &attachmentHTTPDo, client.Do) + + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: attachmentRecordJSON(t, "field", []any{})}, + {text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft-redirect"})}, + }} + out, err := runAITableCompositeCLI(t, caller, "+attachment-put", + "--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes") + var typed *apperrors.Error + if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" || + typed.Cause == nil || !strings.Contains(typed.Cause.Error(), "status 302") { + t.Fatalf("redirect upload = output:%q err:%v", out, err) + } + if sourceHits != 1 || len(caller.calls) != 2 { + t.Fatalf("redirect upload reached an unexpected target: sourceHits=%d calls=%#v", sourceHits, caller.calls) + } +} + func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) { t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index 948b29cd..00d89975 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -324,6 +324,24 @@ func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) { } } +func TestCrossPlatformCoverageRecordUpsertRejectsTrailingJSONBeforeMCPE2E(t *testing.T) { + for name, value := range map[string]string{ + "trailing garbage": `1 garbage`, + "second JSON value": `1 2`, + "second string value": `true "extra"`, + } { + t.Run(name, func(t *testing.T) { + caller := &upsertByKeyCaller{} + out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key", + "--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", value, + "--cells", `{"value":1}`, "--yes") + if err == nil || out != "" || len(caller.calls) != 0 { + t.Fatalf("trailing JSON = output:%q err:%v calls:%#v", out, err, caller.calls) + } + }) + } +} + func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) { if records, ok := findRecords(nil); ok || records != nil { t.Fatalf("nil records = %#v, %v", records, ok) @@ -343,7 +361,8 @@ func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) { if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil { t.Fatal("missing cells must fail") } - if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" { + if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" || + responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" { t.Fatal("responseCursor shape mismatch") } } diff --git a/internal/shortcut/aitable/record_query_all.go b/internal/shortcut/aitable/record_query_all.go index 9e2970e5..0606abba 100644 --- a/internal/shortcut/aitable/record_query_all.go +++ b/internal/shortcut/aitable/record_query_all.go @@ -54,7 +54,7 @@ func responseCursor(data map[string]any) string { if data == nil { return "" } - for _, key := range []string{"nextCursor", "next_cursor"} { + for _, key := range []string{"nextCursor", "next_cursor", "cursor"} { if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } diff --git a/internal/shortcut/aitable/record_upsert_by_key.go b/internal/shortcut/aitable/record_upsert_by_key.go index b6c3efbc..ac4a1638 100644 --- a/internal/shortcut/aitable/record_upsert_by_key.go +++ b/internal/shortcut/aitable/record_upsert_by_key.go @@ -6,6 +6,7 @@ package aitable import ( "encoding/json" "fmt" + "io" "reflect" "sort" "strings" @@ -191,6 +192,10 @@ func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) { if err := decoder.Decode(&value); err != nil { return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error()) } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, apperrors.NewValidation("--key-value-json 必须只包含一个 JSON 标量") + } if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice { return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量") } diff --git a/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go b/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go index 5bb1ca9f..8c121947 100644 --- a/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go +++ b/internal/shortcut/aitable/table_copy_bulk_patch_e2e_test.go @@ -41,6 +41,25 @@ func TestCrossPlatformCoverageRecordBulkPatchPaginatesChunksAndVerifiesE2E(t *te } } +func TestCrossPlatformCoverageRecordBulkPatchPaginatesNestedCursorShapeE2E(t *testing.T) { + records := updateFixtureRecords(0, 2, "旧") + patched := updateFixtureRecords(0, 2, "新") + caller := &upsertByKeyCaller{steps: []upsertByKeyStep{ + {text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[:1], "hasMore": true, "cursor": "legacy-next"}})}, + {text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[1:], "hasMore": false}})}, + {text: `{"updatedCount":2}`}, + {text: recordListJSON(t, patched)}, + }} + out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", + "--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes") + if err != nil || !strings.Contains(out, `"verifiedCount": 2`) { + t.Fatalf("nested cursor bulk patch = output:%q err:%v", out, err) + } + if len(caller.calls) != 4 || caller.calls[1].args["cursor"] != "legacy-next" || caller.calls[2].tool != "update_records" { + t.Fatalf("nested cursor calls = %#v", caller.calls) + } +} + func TestCrossPlatformCoverageRecordBulkPatchSelectionBoundsAndEmptyE2E(t *testing.T) { t.Run("explicit no matches is success", func(t *testing.T) { caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}