Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
7490bb95c5 | ||
|
|
a92f54df3d | ||
|
|
307c9e797b | ||
|
|
07ce090eeb | ||
|
|
201949aec1 |
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+179
-94
@@ -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,195 @@ 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)
|
||||
|
||||
effectivePageLimit := pageLimit
|
||||
if pageLimit == 0 {
|
||||
effectivePageLimit = paging.UnlimitedPageLimit
|
||||
}
|
||||
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: effectivePageLimit,
|
||||
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 格式校验 ──────────────────────────────────────────────────────
|
||||
@@ -6966,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 获取 (必填)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. Orchestrators whose contract requires a business result use
|
||||
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// 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)
|
||||
|
||||
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",
|
||||
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.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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
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.CallMCPWriteDataStrict(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.CallMCPWriteDataStrict(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
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// 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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
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 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{
|
||||
{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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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: "user_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.CallMCPWriteDataStrict(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.CallMCPWriteDataStrict(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.CallMCPWriteDataStrict(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
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// 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), "--yes")
|
||||
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[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)
|
||||
}
|
||||
if got := len(caller.calls[3].args["fields"].([]any)); got != 1 {
|
||||
t.Fatalf("create_fields fields = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
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), "--yes")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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", `{`, "--yes")
|
||||
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...)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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", "--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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// 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.CallMCPWriteDataStrict(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.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,
|
||||
}
|
||||
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 {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, 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
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 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 对象")
|
||||
}
|
||||
params := map[string]any{"baseId": rt.Str("base-id"), "tableId": rt.Str("table-id")}
|
||||
selectorCount := 0
|
||||
if rt.Changed("filters") {
|
||||
filters, err := parseBulkPatchFilters(rt.Str("filters"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filters"] = filters
|
||||
selectorCount++
|
||||
}
|
||||
if rt.Changed("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"))
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// 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: "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 {
|
||||
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")
|
||||
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","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)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
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" ||
|
||||
responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" {
|
||||
t.Fatal("responseCursor shape mismatch")
|
||||
}
|
||||
}
|
||||
@@ -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", "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
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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.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"
|
||||
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") {
|
||||
// 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()
|
||||
var value any
|
||||
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 标量")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// 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
|
||||
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) {
|
||||
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 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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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.CallMCPWriteDataStrict(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.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))
|
||||
}
|
||||
}
|
||||
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.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 {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// 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 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":[]}`}}}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.CallMCPWriteDataStrict(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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.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}
|
||||
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.CallMCPWriteDataStrict(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
|
||||
}
|
||||
@@ -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}),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {},
|
||||
|
||||
@@ -155,7 +155,8 @@ 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)
|
||||
@@ -163,6 +164,16 @@ func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[stri
|
||||
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)
|
||||
}
|
||||
return rt.callMCPWriteData(product, tool, params)
|
||||
}
|
||||
|
||||
func dryRunWriteError(product, tool string) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"--dry-run 下禁止执行未明确分类为只读的工具 %s/%s;请在 shortcut 中输出 preview 后返回",
|
||||
@@ -209,6 +220,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
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -16,3 +20,31 @@ 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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+99
-13
@@ -15,9 +15,9 @@
|
||||
// 继续取"循环。设计目标:
|
||||
//
|
||||
// 1. 调用方只需提供 fetcher(拿单页)和 cursor 提取函数,循环逻辑由本包处理
|
||||
// 2. 支持 --page-limit 安全阀(默认 50 页 ≈ 5000 条,0 = 无限制)
|
||||
// 2. 支持分页安全阀(零值默认 50 页;显式 UnlimitedPageLimit 才不限页数)
|
||||
// 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 使用 DefaultPageLimit,保持 Options{} 的安全默认语义
|
||||
// - UnlimitedPageLimit 显式表示不限页数(取到 NextCursor 为空为止)
|
||||
// - >0 达到该次数后立即停止,返回 LastCursor 让调用方手动续拉
|
||||
// 默认(DefaultPageLimit)= 50 页。
|
||||
PageLimit int
|
||||
|
||||
// InterPageDelay 是页间退避时长。默认 200ms。
|
||||
@@ -65,6 +68,10 @@ type Options struct {
|
||||
}
|
||||
|
||||
const (
|
||||
// UnlimitedPageLimit 是调用方明确选择无限分页时使用的哨兵值。
|
||||
// 零值保留 DefaultPageLimit 的兼容和资源安全语义。
|
||||
UnlimitedPageLimit = -1
|
||||
|
||||
// DefaultPageLimit 是 PageLimit 字段的默认值。
|
||||
DefaultPageLimit = 50
|
||||
|
||||
@@ -72,12 +79,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 +104,11 @@ type Result struct {
|
||||
HasMore bool
|
||||
LastCursor string
|
||||
Pages int
|
||||
Attempts int
|
||||
Complete bool
|
||||
Partial bool
|
||||
StopReason StopReason
|
||||
TotalCount *int
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -98,12 +121,15 @@ 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 {
|
||||
if pageLimit == UnlimitedPageLimit {
|
||||
pageLimit = 0
|
||||
} else if pageLimit <= 0 {
|
||||
pageLimit = DefaultPageLimit
|
||||
}
|
||||
delay := opts.InterPageDelay
|
||||
@@ -115,7 +141,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 +157,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 +174,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 +194,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 +219,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 +275,7 @@ func FetchAll(ctx context.Context, fetcher Fetcher, opts Options) Result {
|
||||
// 上游可通过 errors.Is(result.Err, ErrPageLimitReached) 判定,决定提示用户
|
||||
// "数据被截断,请用 --cursor <LastCursor> 续拉"。
|
||||
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")
|
||||
|
||||
+97
-15
@@ -6,6 +6,7 @@ package paging
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -32,19 +33,19 @@ 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: ""},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_MultiPage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllMultiPage(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{1, 2}, NextCursor: "c1"},
|
||||
@@ -53,12 +54,12 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_PageLimit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFetchAllPageLimit(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{1}, NextCursor: "c1"},
|
||||
@@ -80,9 +81,12 @@ 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) {
|
||||
func TestCrossPlatformCoverageFetchAllMidErrorPartial(t *testing.T) {
|
||||
upstreamErr := errors.New("502 bad gateway")
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
@@ -100,9 +104,12 @@ 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) {
|
||||
func TestCrossPlatformCoverageFetchAllInitialCursorResume(t *testing.T) {
|
||||
s := &stubFetcher{
|
||||
pages: []Page{
|
||||
{Records: []any{"resumed"}, NextCursor: ""},
|
||||
@@ -117,7 +124,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"}}}
|
||||
@@ -130,11 +137,9 @@ func TestFetchAll_ContextCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAll_UnlimitedPageLimit(t *testing.T) {
|
||||
// PageLimit 显式置 -1 时(约定为"不限"),翻完为止
|
||||
// (当前实现 PageLimit==0 用默认值 50;要真正无限制需另设特殊值,
|
||||
// 这里测试默认 50 上限内能完成全量)
|
||||
pages := make([]Page, 10)
|
||||
func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
|
||||
// 无限分页必须由哨兵值显式选择;用超过默认安全阀的页数证明它生效。
|
||||
pages := make([]Page, DefaultPageLimit+1)
|
||||
for i := range pages {
|
||||
next := ""
|
||||
if i < len(pages)-1 {
|
||||
@@ -144,10 +149,87 @@ func TestFetchAll_UnlimitedPageLimit(t *testing.T) {
|
||||
}
|
||||
s := &stubFetcher{pages: pages}
|
||||
got := FetchAll(context.Background(), s.Fetch, Options{
|
||||
PageLimit: 100, // 高于实际页数
|
||||
PageLimit: UnlimitedPageLimit,
|
||||
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 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}}}
|
||||
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 TestCrossPlatformCoverageFetchAllStopsOnCursorCycle(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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 中的自动化工作流(分页) |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
Reference in New Issue
Block a user