479 lines
19 KiB
Go
479 lines
19 KiB
Go
// 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 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-bootstrap;复制现有 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, baseBootstrapValidation(fmt.Sprintf("--tables 不是合法 JSON 数组:%v", err))
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok || len(items) == 0 {
|
|
return nil, baseBootstrapValidation("--tables 必须是非空 JSON 数组")
|
|
}
|
|
if len(items) > 100 {
|
|
return nil, baseBootstrapValidation("--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, baseBootstrapValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index))
|
|
}
|
|
name := strings.TrimSpace(stringValue(object, "name"))
|
|
if name == "" || seen[name] {
|
|
return nil, baseBootstrapValidation(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, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index))
|
|
}
|
|
}
|
|
fieldNames := map[string]bool{}
|
|
for fieldIndex, field := range fields {
|
|
object, ok := field.(map[string]any)
|
|
fieldName := strings.TrimSpace(stringValue(object, "fieldName", "name"))
|
|
if !ok || fieldName == "" || strings.TrimSpace(stringValue(object, "type")) == "" {
|
|
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex))
|
|
}
|
|
if fieldNames[fieldName] {
|
|
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d].fieldName %q 不能重复", index, fieldIndex, fieldName))
|
|
}
|
|
fieldNames[fieldName] = true
|
|
if config, exists := object["config"]; exists {
|
|
if _, ok := config.(map[string]any); !ok {
|
|
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d].config 必须是 JSON 对象", index, fieldIndex))
|
|
}
|
|
}
|
|
}
|
|
out = append(out, bootstrapTable{Name: name, Fields: fields})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func baseBootstrapValidation(message string) error {
|
|
return apperrors.NewValidation(message,
|
|
apperrors.WithHint("tables 使用 [{\"name\":\"任务\",\"fields\":[{\"fieldName\":\"标题\",\"type\":\"text\"}]}];已知参数时不要先调用 --help"),
|
|
apperrors.WithActions(`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[{"fieldName":"标题","type":"text"}]}]'`),
|
|
apperrors.WithAvailableFlags("name", "folder-id", "template-id", "tables"),
|
|
)
|
|
}
|
|
|
|
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", "name": rt.Str("name")}
|
|
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-search", "--query", rt.Str("name"), "--format", "json")
|
|
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", "name": rt.Str("name")}
|
|
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-search", "--query", rt.Str("name"), "--format", "json")
|
|
return compositeError(result, fmt.Errorf("create_base response is missing baseId"), false)
|
|
}
|
|
result.Resolved = map[string]any{"baseId": baseID}
|
|
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-get", "--base-id", baseID, "--format", "json")
|
|
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})
|
|
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+table-get", "--base-id", baseID, "--table-id", tableID, "--format", "json")
|
|
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 {
|
|
verifyErr = fmt.Errorf("field read-back for table %s is missing the fields collection", tableID)
|
|
}
|
|
if verifyErr == nil {
|
|
verifyErr = verifyDeclaredFieldStructures(fields, spec.Fields)
|
|
}
|
|
if verifyErr != nil {
|
|
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}
|
|
result.NextCommand = ""
|
|
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 verifyDeclaredFieldStructures(actual []map[string]any, expected []any) error {
|
|
actualByName := map[string][]map[string]any{}
|
|
for _, field := range actual {
|
|
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
|
|
if name != "" {
|
|
actualByName[name] = append(actualByName[name], field)
|
|
}
|
|
}
|
|
for index, raw := range expected {
|
|
field, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("declared field at index %d is not an object", index)
|
|
}
|
|
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
|
|
matches := actualByName[name]
|
|
if len(matches) == 0 {
|
|
return fmt.Errorf("field read-back is missing declared field %q", name)
|
|
}
|
|
if len(matches) != 1 {
|
|
return fmt.Errorf("field read-back contains %d fields named %q", len(matches), name)
|
|
}
|
|
actualField := matches[0]
|
|
expectedType := strings.TrimSpace(stringValue(field, "type"))
|
|
actualType := strings.TrimSpace(stringValue(actualField, "type", "fieldType"))
|
|
if actualType != expectedType {
|
|
return fmt.Errorf("field %q type mismatch: got %q, want %q", name, actualType, expectedType)
|
|
}
|
|
for _, key := range []string{"config", "aiConfig"} {
|
|
expectedConfig, declared := field[key]
|
|
if !declared {
|
|
continue
|
|
}
|
|
actualConfig, found := actualField[key]
|
|
if !found || !declaredValueMatches(actualConfig, expectedConfig) {
|
|
return fmt.Errorf("field %q %s mismatch", name, key)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func declaredValueMatches(actual, expected any) bool {
|
|
switch expectedValue := expected.(type) {
|
|
case map[string]any:
|
|
actualValue, ok := actual.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for key, expectedChild := range expectedValue {
|
|
actualChild, found := actualValue[key]
|
|
if !found || !declaredValueMatches(actualChild, expectedChild) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case []any:
|
|
actualValue, ok := actual.([]any)
|
|
if !ok || len(actualValue) != len(expectedValue) {
|
|
return false
|
|
}
|
|
for index := range expectedValue {
|
|
if !declaredValueMatches(actualValue[index], expectedValue[index]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
default:
|
|
return reflect.DeepEqual(actual, expected)
|
|
}
|
|
}
|