diff --git a/internal/errclass/codemeta_base.go b/internal/errclass/codemeta_base.go index 207cec18f8..b2651fabb8 100644 --- a/internal/errclass/codemeta_base.go +++ b/internal/errclass/codemeta_base.go @@ -6,6 +6,9 @@ package errclass import "github.com/larksuite/cli/errs" var baseCodeMeta = map[int]CodeMeta{ + // Base write-path errors. + 1254291: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, + // Copy Table domain errors (technical design chapter 18.2). 800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, 800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 9c853c8090..051f1ddc63 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -17,12 +17,16 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/recovery" + "github.com/larksuite/cli/internal/surface" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -1251,6 +1255,36 @@ func TestBaseViewExecutePropertyActions(t *testing.T) { } +func TestFieldCreateBatchDelayUsesLowerBoundOfWriteConflictGuidance(t *testing.T) { + want := 500 * time.Millisecond + if fieldCreateBatchDelay != want { + t.Fatalf("fieldCreateBatchDelay=%s, want %s", fieldCreateBatchDelay, want) + } +} + +func TestFieldCreateThrottleDelayCountsRequestTime(t *testing.T) { + startedAt := time.Unix(0, 0) + for _, tc := range []struct { + name string + now time.Time + want time.Duration + }{ + {name: "first request", want: 0}, + {name: "fast response waits only for remainder", now: startedAt.Add(200 * time.Millisecond), want: 300 * time.Millisecond}, + {name: "slow response needs no extra wait", now: startedAt.Add(600 * time.Millisecond), want: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + previousStartedAt := startedAt + if tc.name == "first request" { + previousStartedAt = time.Time{} + } + if got := fieldCreateThrottleDelay(previousStartedAt, tc.now); got != tc.want { + t.Fatalf("fieldCreateThrottleDelay()=%s, want %s", got, tc.want) + } + }) + } +} + func TestBaseFieldExecuteCRUD(t *testing.T) { t.Run("list", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) @@ -1346,7 +1380,14 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { }, Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + "data": map[string]interface{}{ + "id": "fld_a", + "name": "A", + "type": "text", + "default_value": nil, + "description": "verbose server field metadata", + "style": map[string]interface{}{"type": "plain"}, + }, }, } secondStub := &httpmock.Stub{ @@ -1375,14 +1416,267 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { if len(fields) != 2 { t.Fatalf("fields len=%d output=%#v", len(fields), data) } + firstField, _ := fields[0].(map[string]interface{}) + if firstField["id"] != "fld_a" || firstField["name"] != "A" || firstField["type"] != "text" { + t.Fatalf("batch output should preserve field identity, got %#v", firstField) + } + if firstField["description"] != "verbose server field metadata" || firstField["default_value"] != nil { + t.Fatalf("batch output should preserve server field metadata, got %#v", firstField) + } + style, _ := firstField["style"].(map[string]interface{}) + if style["type"] != "plain" { + t.Fatalf("batch output should preserve server field style, got %#v", firstField) + } if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil { t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data) } + hint := common.GetString(data, "verification_hint") + for _, want := range []string{"do not list or get fields", "filter +field-list with --jq"} { + if !strings.Contains(hint, want) { + t.Fatalf("verification_hint=%q, want %q", hint, want) + } + } if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) { t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody) } }) + t.Run("create array reports progress when a later field fails", func(t *testing.T) { + oldDelay := fieldCreateBatchDelay + fieldCreateBatchDelay = 0 + t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) + + runPartial := func(input, createdType, failedName string, failedResponse map[string]interface{}) map[string]interface{} { + t.Helper() + factory, stdout, reg := newExecuteFactory(t) + register := func(name string, response map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"`+name+`"`) }, + Body: response, + }) + } + register("A", map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "fld_a", + "name": "A", + "type": createdType, + "default_value": nil, + "description": "verbose server field metadata", + "style": map[string]interface{}{"type": "plain"}, + }, + }) + register(failedName, failedResponse) + err := runShortcut(t, BaseFieldCreate, []string{ + "+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input, + }, factory, stdout) + var partialErr *output.PartialFailureError + if !errors.As(err, &partialErr) { + t.Fatalf("expected partial failure error, got %T: %v", err, err) + } + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String()) + } + if envelope.OK || envelope.Data == nil { + t.Fatalf("unexpected partial failure envelope: %#v", envelope) + } + return envelope.Data + } + + conflictResponse := map[string]interface{}{ + "code": 1254090, + "msg": "field already exists", + "error": map[string]interface{}{ + "log_id": "202607300001", + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", + "details": []interface{}{map[string]interface{}{"value": "choose a different field name"}}, + }, + } + data := runPartial(`[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "auto_number", "B", conflictResponse) + summary, _ := data["summary"].(map[string]interface{}) + if summary["requested"] != float64(3) || summary["attempted"] != float64(2) || + summary["created"] != float64(1) || summary["failed"] != float64(1) || summary["not_attempted"] != float64(1) { + t.Fatalf("unexpected summary: %#v", summary) + } + + items, _ := data["items"].([]interface{}) + if len(items) != 3 { + t.Fatalf("items=%#v, want three outcomes", items) + } + created, _ := items[0].(map[string]interface{}) + createdField, _ := created["field"].(map[string]interface{}) + failed, _ := items[1].(map[string]interface{}) + failedField, _ := failed["field"].(map[string]interface{}) + notAttempted, _ := items[2].(map[string]interface{}) + notAttemptedField, _ := notAttempted["field"].(map[string]interface{}) + if created["status"] != "created" || createdField["id"] != "fld_a" || + failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" || + notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" { + t.Fatalf("unexpected item outcomes: %#v", items) + } + if len(createdField) != 3 { + t.Fatalf("partial failure should keep only compact created-field identity, got %#v", createdField) + } + if !strings.Contains(common.GetString(failed, "error"), "field already exists") { + t.Fatalf("failed item must include the API error: %#v", failed) + } + for key, want := range map[string]interface{}{ + "type": "api", + "subtype": "unknown", + "code": float64(1254090), + "hint": "choose a different field name", + "retryable": false, + "log_id": "202607300001", + "troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists", + } { + if failed[key] != want { + t.Fatalf("failed[%q]=%#v, want %#v; failed=%#v", key, failed[key], want, failed) + } + } + if _, ok := failed["error_type"]; ok { + t.Fatalf("failed item must use canonical type/subtype fields: %#v", failed) + } + writeConflictData := runPartial(`[{"name":"A","type":"text"},{"name":"W","type":"text"}]`, "text", "W", map[string]interface{}{ + "code": 1254291, + "msg": "write conflict", + }) + items, _ = writeConflictData["items"].([]interface{}) + writeConflict, _ := items[1].(map[string]interface{}) + if writeConflict["type"] != "api" || writeConflict["subtype"] != "conflict" || writeConflict["retryable"] != true || + !strings.Contains(common.GetString(writeConflict, "hint"), "retry later") { + t.Fatalf("1254291 must remain a retryable conflict with wait guidance: %#v", writeConflict) + } + if !strings.Contains(data["hint"].(string), "Automatically retry a failed item unchanged only when retryable is true") { + t.Fatalf("hint=%#v", data["hint"]) + } + if data["field_get_recommended"] != true || data["next_step"] != "inspect_items" || data["verification_hint"] == nil { + t.Fatalf("partial success with auto_number must recommend readback: %#v", data) + } + + simpleData := runPartial(`[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "text", "B", conflictResponse) + if simpleData["field_get_recommended"] != false || simpleData["next_step"] != "inspect_items" { + t.Fatalf("simple-field partial failure must inspect items, not report done: %#v", simpleData) + } + + permissionData := runPartial(`[{"name":"A","type":"text"},{"name":"P","type":"text"}]`, "text", "P", map[string]interface{}{ + "code": 99991672, + "msg": "app scope not applied", + "error": map[string]interface{}{ + "permission_violations": []interface{}{map[string]interface{}{"subject": "base:field:create"}}, + }, + }) + items, _ = permissionData["items"].([]interface{}) + permissionFailure, _ := items[1].(map[string]interface{}) + missingScopes, _ := permissionFailure["missing_scopes"].([]interface{}) + if len(missingScopes) != 1 || missingScopes[0] != "base:field:create" || + permissionFailure["identity"] != "bot" || common.GetString(permissionFailure, "console_url") == "" { + t.Fatalf("permission failure must retain typed extensions: %#v", permissionFailure) + } + }) + + t.Run("create array presents partial failure recovery", func(t *testing.T) { + oldDelay := fieldCreateBatchDelay + fieldCreateBatchDelay = 0 + t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) + + tests := []struct { + name string + plan *surface.Plan + }{ + {name: "visible"}, + { + name: "concealed", + plan: surface.NewPlan(map[surface.CommandID]surface.CommandState{ + surface.CommandAuthLogin: surface.CommandConcealed, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + factory.Recovery = recovery.NewProjector(func() *surface.Plan { return tt.plan }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"A"`) }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"B"`) }, + Body: map[string]interface{}{"code": 230027, "msg": "operation unauthorized"}, + }) + + err := runShortcutWithAuthTypes(t, BaseFieldCreate, []string{"bot", "user"}, []string{ + "+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--as", "user", + "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`, + }, factory, stdout) + var partialErr *output.PartialFailureError + if !errors.As(err, &partialErr) { + t.Fatalf("expected partial failure error, got %T: %v", err, err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Items []map[string]interface{} `json:"items"` + Hint string `json:"hint"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String()) + } + if envelope.OK || len(envelope.Data.Items) != 2 { + t.Fatalf("unexpected partial failure envelope: %#v", envelope) + } + + failed := envelope.Data.Items[1] + wantHint := errclass.PermissionRecovery( + []string{"base:field:create"}, "user", errs.SubtypeUserUnauthorized, "", + ).Render(tt.plan) + gotHint := common.GetString(failed, "hint") + if gotHint != wantHint { + t.Errorf("failed hint = %q, want %q", gotHint, wantHint) + } + if failed["type"] != "authorization" || failed["subtype"] != "user_unauthorized" || failed["identity"] != "user" || failed["retryable"] != false { + t.Errorf("failed typed metadata = %#v", failed) + } + for _, want := range []string{ + "Automatically retry a failed item unchanged only when retryable is true", + "otherwise follow its hint to authorize or correct the input before resubmitting it", + } { + if !strings.Contains(envelope.Data.Hint, want) { + t.Errorf("partial failure hint = %q, want %q", envelope.Data.Hint, want) + } + } + if strings.Contains(envelope.Data.Hint, "Do not retry failed items") { + t.Errorf("partial failure hint must allow recovery before resubmission: %q", envelope.Data.Hint) + } + if _, ok := failed["missing_scopes"]; ok { + t.Errorf("presentation must not fabricate missing_scopes: %#v", failed) + } + if tt.plan == nil { + if !strings.Contains(gotHint, `auth login --scope "base:field:create"`) { + t.Errorf("visible recovery lost precise auth path: %q", gotHint) + } + } else if strings.Contains(gotHint, "auth login") || !strings.Contains(gotHint, "base:field:create") { + t.Errorf("concealed recovery leaked command or lost scope: %q", gotHint) + } + }) + } + }) + t.Run("create array with generated field recommends readback", func(t *testing.T) { oldDelay := fieldCreateBatchDelay fieldCreateBatchDelay = 0 diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 1c47651bcb..e24c90d659 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -246,10 +246,60 @@ func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) { } } -func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) { +func TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) { + tips := strings.Join(BaseFieldCreate.Tips, "\n") + for _, want := range []string{ + "+field-create defines storage schema only", + "a documented field type", + "value being stored", + "never from the field name or business purpose", + "use style only to format that type", + "explicitly requested derived, automatic, synchronized, or backfilled behavior", + "use documented formula, lookup, link, workflow, or automation only", + "formula, lookup, link, workflow, or automation", + "If unsupported, do not probe code/web/OpenAPI, create a storage placeholder, or claim completion", + "report the boundary and alternatives", + "arrays remain sequential per-field requests", + "split only for timeout bounds, not a fixed chunk size", + "prefer --json @file or an argv-safe subprocess call", + "do not double-escape JSON inside shell command substitution", + "For large arrays, bound successful stdout with --jq", + "if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end", + "preserves the full partial-failure envelope", + "next_step:done means stop", + "filter +field-list with --jq", + } { + if !strings.Contains(tips, want) { + t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips) + } + } + lowerTips := strings.ToLower(tips) + for _, caseArtifact := range []string{ + "base_table_", + "larkoffice.com/base/", + "grading_pass_rate", + "benchmark", + } { + if strings.Contains(lowerTips, caseArtifact) { + t.Fatalf("field-create tips should remain generic, found %q:\n%s", caseArtifact, tips) + } + } +} + +func TestBaseFieldCreateHelpDocumentsBatchAndHidesReadGuideFlag(t *testing.T) { parent := &cobra.Command{Use: "base"} BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) cmd := parent.Commands()[0] + if !strings.Contains(cmd.Short, "one or more fields") { + t.Fatalf("help should describe creating one or more fields, got %q", cmd.Short) + } + jsonFlag := cmd.Flags().Lookup("json") + if jsonFlag == nil { + t.Fatal("flag json must exist") + } + if !strings.Contains(jsonFlag.Usage, "JSON object or non-empty array") || !strings.Contains(jsonFlag.Usage, "supports @file") { + t.Fatalf("json flag help should document object and array input, got %q", jsonFlag.Usage) + } if cmd.Flags().Lookup("i-have-read-guide") == nil { t.Fatalf("flag i-have-read-guide must exist for runtime validation") } diff --git a/shortcuts/base/base_skill_contract_test.go b/shortcuts/base/base_skill_contract_test.go new file mode 100644 index 0000000000..14c4b94754 --- /dev/null +++ b/shortcuts/base/base_skill_contract_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) { + const skillPath = "../../skills/lark-base/SKILL.md" + content, err := vfs.ReadFile(skillPath) + if err != nil { + t.Fatalf("read lark-base skill: %v", err) + } + + skill := string(content) + normalizedSkill := strings.Join(strings.Fields(skill), " ") + for _, want := range []string{ + "用户要求“新增/创建”时", + "本轮 create 返回的对象、ID 或数量", + "同名目标已存在时报告冲突", + "不能把已有资源算作本轮新增", + "不能静默复用或更新", + "对每类资源只做一次必要盘点", + "只有命令明确返回逐项结果时才优先使用批量创建", + "继续配置本轮返回的 ID", + "明确要求确保存在、复用或更新", + } { + if !strings.Contains(normalizedSkill, want) { + t.Fatalf("lark-base skill missing %q", want) + } + } + + for _, forbidden := range []string{ + "base_table_", + "larkoffice.com/base/", + "grading_pass_rate", + } { + if strings.Contains(skill, forbidden) { + t.Fatalf("lark-base skill must remain generic, found %q", forbidden) + } + } +} + +func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *testing.T) { + readNormalized := func(path string) string { + t.Helper() + content, err := vfs.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return strings.Join(strings.Fields(string(content)), " ") + } + + skill := readNormalized("../../skills/lark-base/SKILL.md") + fieldJSON := readNormalized("../../skills/lark-base/references/lark-base-field-json.md") + fieldCreate := readNormalized("../../skills/lark-base/references/lark-base-field-create.md") + + for _, want := range []string{ + "同一表创建多个字段时,一次向 `+field-create --json` 传字段对象数组", + "仅创建一个或多个只含 `name` + `type:text` 的简单字段时按 `+field-create --help` 即可", + "其他类型或属性必读", + "只有命令明确返回逐项结果时才优先使用批量创建", + } { + if !strings.Contains(skill, want) { + t.Fatalf("lark-base skill missing %q", want) + } + } + for _, want := range []string{ + "单个字段定义始终是 JSON 对象", + "`+field-create --json` 接受一个字段对象或非空字段对象数组", + "`+field-update --json` 只接受一个字段对象", + } { + if !strings.Contains(fieldJSON, want) { + t.Fatalf("field JSON SSOT missing %q", want) + } + } + if strings.Contains(fieldJSON, "`--json` 必须是 JSON 对象") { + t.Fatal("field JSON SSOT must not apply the update-only top-level object rule to field-create") + } + for _, want := range []string{ + "遇到首个失败即停止且不自动回滚已创建字段", + "部分失败返回 `ok:false`", + "`summary`", + "`items`", + "`next_step:\"inspect_items\"`", + "`missing_scopes`、`identity`、`console_url`", + "`retryable:true` 只表示该 `failed` 项可原样自动重试", + "否则先按该项 `hint` 完成授权或修正输入,再重新提交该项", + "`not_attempted` 项应单独继续", + } { + if !strings.Contains(fieldCreate, want) { + t.Fatalf("field-create reference missing %q", want) + } + } +} diff --git a/shortcuts/base/data_query_guide_contract_test.go b/shortcuts/base/data_query_guide_contract_test.go new file mode 100644 index 0000000000..55ae362947 --- /dev/null +++ b/shortcuts/base/data_query_guide_contract_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/vfs" +) + +func TestDataQueryQuickGuideCoversConditionValueShapesWithoutCaseArtifacts(t *testing.T) { + const guidePath = "../../skills/lark-base/references/lark-base-data-query-guide.md" + content, err := vfs.ReadFile(guidePath) + if err != nil { + t.Fatalf("read data-query quick guide: %v", err) + } + guide := string(content) + normalizedGuide := strings.Join(strings.Fields(guide), " ") + + for _, want := range []string{ + "Common `Condition.value` shapes", + "`is` / `isNot`", + "exactly one option name", + "`isGreater`", + "`isLess`", + "`isEmpty`", + "`isNotEmpty`", + "uses `[]`", + `["Today"]`, + `["ExactDate",""]`, + "Use relative date keywords only for relative requests", + "[lark-base-data-query.md](lark-base-data-query.md)", + } { + if !strings.Contains(normalizedGuide, want) { + t.Fatalf("quick guide missing %q", want) + } + } + + if len(content) > 6*1024 { + t.Fatalf("quick guide grew to %d bytes; keep full DSL details in lark-base-data-query.md", len(content)) + } + + for _, forbidden := range []string{ + "base_table_", + "bytedance.larkoffice.com/base/", + } { + if strings.Contains(normalizedGuide, forbidden) { + t.Fatalf("quick guide must remain generic, found %q", forbidden) + } + } +} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index 121117627e..50ac895a90 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -12,20 +12,25 @@ import ( var BaseFieldCreate = common.Shortcut{ Service: "base", Command: "+field-create", - Description: "Create a field", + Description: "Create one or more fields", Risk: "write", Scopes: []string{"base:field:create"}, AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "json", Desc: "field property JSON object or non-empty array of field objects; supports @file", Required: true}, {Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true}, }, Tips: []string{ `Example text: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"text"}'`, `Example select: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, - "Agent hint: use the lark-base skill's field-create guide for usage and limits.", + `+field-create defines storage schema only: choose a documented field type from the value being stored, never from the field name or business purpose, and use style only to format that type.`, + `For explicitly requested derived, automatic, synchronized, or backfilled behavior, use documented formula, lookup, link, workflow, or automation only. If unsupported, do not probe code/web/OpenAPI, create a storage placeholder, or claim completion; report the boundary and alternatives.`, + "Agent hint: arrays remain sequential per-field requests; use one array per table when its estimated runtime fits the caller timeout, and split only for timeout bounds, not a fixed chunk size.", + "For generated arrays, prefer --json @file or an argv-safe subprocess call; do not double-escape JSON inside shell command substitution.", + `For large arrays, bound successful stdout with --jq 'if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end'; this preserves the full partial-failure envelope. Omit the projection when individual field IDs are needed.`, + "On successful simple fields, next_step:done means stop: do not list/get fields unless readback is explicitly requested; if needed, filter +field-list with --jq instead of printing every field.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateFieldCreate(runtime) diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 3198183781..31c6563c8c 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -5,14 +5,31 @@ package base import ( "context" + "errors" "fmt" "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) -var fieldCreateBatchDelay = time.Second +// Keep field writes sequential and use the lower bound of the documented +// 0.5-1s write-conflict guidance as the minimum interval between request starts. +// Request latency counts toward the interval, so successful calls do not incur +// an unconditional sleep. +var fieldCreateBatchDelay = 500 * time.Millisecond + +func fieldCreateThrottleDelay(previousStartedAt, now time.Time) time.Duration { + if previousStartedAt.IsZero() || fieldCreateBatchDelay <= 0 { + return 0 + } + wait := previousStartedAt.Add(fieldCreateBatchDelay).Sub(now) + if wait > 0 { + return wait + } + return 0 +} func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") @@ -162,12 +179,17 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { return err } fields := make([]interface{}, 0, len(bodies)) + var previousStartedAt time.Time for idx, body := range bodies { - if idx > 0 && fieldCreateBatchDelay > 0 { - time.Sleep(fieldCreateBatchDelay) + if wait := fieldCreateThrottleDelay(previousStartedAt, time.Now()); wait > 0 { + time.Sleep(wait) } + previousStartedAt = time.Now() data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) if err != nil { + if len(fields) > 0 { + return fieldCreatePartialFailure(runtime, bodies, fields, idx, err) + } return err } fields = append(fields, data) @@ -180,6 +202,98 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { return nil } +func fieldCreatePartialFailure(runtime *common.RuntimeContext, bodies []map[string]interface{}, createdFields []interface{}, failedIndex int, err error) error { + items := make([]map[string]interface{}, 0, len(bodies)) + for idx, field := range createdFields { + items = append(items, map[string]interface{}{ + "index": idx, + "status": "created", + "field": fieldCreateOutputIdentity(field, bodies[idx]), + }) + } + + presented := runtime.PresentError(err) + failed := map[string]interface{}{ + "index": failedIndex, + "status": "failed", + "field": fieldCreateInputIdentity(bodies[failedIndex]), + "error": presented.Error(), + } + if problem, ok := errs.ProblemOf(presented); ok { + failed["type"] = string(problem.Category) + failed["subtype"] = string(problem.Subtype) + failed["retryable"] = problem.Retryable + if problem.Code != 0 { + failed["code"] = problem.Code + } + if problem.Hint != "" { + failed["hint"] = problem.Hint + } + if problem.LogID != "" { + failed["log_id"] = problem.LogID + } + if problem.Troubleshooter != "" { + failed["troubleshooter"] = problem.Troubleshooter + } + } + var permissionError *errs.PermissionError + if errors.As(presented, &permissionError) { + if len(permissionError.MissingScopes) > 0 { + failed["missing_scopes"] = permissionError.MissingScopes + } + if permissionError.Identity != "" { + failed["identity"] = permissionError.Identity + } + if permissionError.ConsoleURL != "" { + failed["console_url"] = permissionError.ConsoleURL + } + } + items = append(items, failed) + + for idx := failedIndex + 1; idx < len(bodies); idx++ { + items = append(items, map[string]interface{}{ + "index": idx, + "status": "not_attempted", + "field": fieldCreateInputIdentity(bodies[idx]), + }) + } + + result := fieldCreateBatchResult(map[string]interface{}{ + "summary": map[string]interface{}{ + "requested": len(bodies), + "attempted": failedIndex + 1, + "created": len(createdFields), + "failed": 1, + "not_attempted": len(bodies) - failedIndex - 1, + }, + "items": items, + "hint": "Some fields were already created and were not rolled back. Automatically retry a failed item unchanged only when retryable is true; otherwise follow its hint to authorize or correct the input before resubmitting it. Submit not_attempted items separately.", + }, bodies[:len(createdFields)]) + result["next_step"] = "inspect_items" + return runtime.OutPartialFailure(result, nil) +} + +func fieldCreateInputIdentity(body map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "name": body["name"], + "type": body["type"], + } +} + +func fieldCreateOutputIdentity(field interface{}, submitted map[string]interface{}) map[string]interface{} { + identity := fieldCreateInputIdentity(submitted) + returned, ok := field.(map[string]interface{}) + if !ok { + return identity + } + for _, key := range []string{"id", "name", "type"} { + if value, exists := returned[key]; exists { + identity[key] = value + } + } + return identity +} + func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) { bodies, err := parseObjectList(pc, raw, "json") if err != nil { @@ -219,7 +333,7 @@ func fieldCreateResult(result map[string]interface{}, submitted map[string]inter // server state without breaking the existing fields/total structure. func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} { recommend := false - reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed" + reason := "simple fields created successfully; next_step:done means stop: do not list or get fields unless the user explicitly requests readback or extra properties; if verification is required, filter +field-list with --jq" for _, body := range submitted { if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec { recommend = true @@ -274,7 +388,7 @@ func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) case "formula", "lookup", "auto_number", "link": return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation) case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location": - return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation) + return false, fmt.Sprintf("simple field %s succeeded; next_step:done means stop: do not list or get fields unless the user explicitly requests readback or extra properties; if verification is required, filter +field-list with --jq", operation) default: return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state" } diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 54c53b7c56..cfdda51f74 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -57,7 +57,7 @@ metadata: | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | | 复制 Base 内单张数据表 | `+table-copy` / `+table-copy-status` | 默认只复制结构;只有用户明确要求复制全表、数据、行或记录时才传 `--range all`;异步任务按返回的 `task_id` 查询或续等 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | -| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | +| 创建/更新字段 | `+field-create` / `+field-update` | 同一表创建多个字段时,一次向 `+field-create --json` 传字段对象数组,不要逐字段调用;仅创建一个或多个只含 `name` + `type:text` 的简单字段时按 `+field-create --help` 即可,其他类型或属性必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md),lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);仍需逐项恢复或命令细节时读 [lark-base-field-create.md](references/lark-base-field-create.md),更新细节读 [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | | 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) | | 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 | @@ -113,6 +113,7 @@ metadata: ## 写入前置规则 - 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。 +- 严格区分动作语义:用户要求“新增/创建”时,必须用本轮 create 返回的对象、ID 或数量确认完成;同名目标已存在时报告冲突,不能把已有资源算作本轮新增,也不能静默复用或更新。复合创建任务对每类资源只做一次必要盘点;只有命令明确返回逐项结果时才优先使用批量创建,并继续配置本轮返回的 ID;只有用户明确要求确保存在、复用或更新时,才操作同名已有资源。 - 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。 - 附件上传、下载、删除走专用 `+record-*-attachment` 命令。 - 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);请求字段类型不在 reference 已支持类型目录中时,说明当前 CLI 不支持并停止,不要猜测未注册的字段 JSON、service 或 schema,也不要用其他字段类型冒充;涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。 diff --git a/skills/lark-base/references/lark-base-data-query-guide.md b/skills/lark-base/references/lark-base-data-query-guide.md index d20bf4dbcd..0ea15ff6fc 100644 --- a/skills/lark-base/references/lark-base-data-query-guide.md +++ b/skills/lark-base/references/lark-base-data-query-guide.md @@ -42,6 +42,14 @@ lark-cli base +data-query \ --dsl '{"datasource":{"type":"table","table":{"tableId":""}},"dimensions":[{"field_name":"Owner","alias":"owner"}],"measures":[{"field_name":"Amount","aggregation":"sum","alias":"total_amount"}],"filters":{"type":1,"conjunction":"and","conditions":[{"field_name":"Status","operator":"is","value":["Done"]}]},"shaper":{"format":"flat"}}' ``` +## Common filter values + +Common `Condition.value` shapes: select `is` / `isNot` uses exactly one option +name; datetime `is` / `isGreater` / `isLess` uses `["Today"]` or +`["ExactDate",""]`; `isEmpty` / `isNotEmpty` uses `[]`. +Use relative date keywords only for relative requests; see +[lark-base-data-query.md](lark-base-data-query.md) for other field types and operators. + Use `tableName` when the table ID is unavailable but the table name is known: ```bash diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 4771458984..c2644b4688 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -创建一个字段。 +创建一个或多个字段;同一表的多个字段用一次 JSON 数组输入,不要逐字段调用。 ## Agent 最小工作流 @@ -29,6 +29,12 @@ lark-cli base +field-create \ --base-token \ --table-id \ --json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}' + +# 多个字段复用相同字段 JSON 形状,一次传非空数组 +lark-cli base +field-create \ + --base-token \ + --table-id \ + --json '[{"name":"备注","type":"text"},{"name":"优先级","type":"select","multiple":false,"options":[{"name":"高"},{"name":"低"}]}]' ``` ## 参数 @@ -37,7 +43,8 @@ lark-cli base +field-create \ |------|------|------| | `--base-token ` | 是 | Base Token | | `--table-id ` | 是 | 表 ID 或表名 | -| `--json ` | 是 | 字段属性 JSON 对象 | +| `--json ` | 是 | 单个字段 JSON 对象,或多个字段对象组成的非空数组 | + ## API 入参详情 **HTTP 方法和路径:** @@ -48,8 +55,9 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## JSON 值规范 -- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。 -- 顶层最少包含:`name`、`type`。 +- `--json` 接受单个字段 **JSON 对象**,也接受多个字段对象组成的非空数组;不要再套 `fields` 等外层对象。 +- 数组按顺序创建字段,遇到首个失败即停止且不自动回滚已创建字段;需要原子写入时不要假设数组具备事务语义。 +- 每个字段对象最少包含:`name`、`type`。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。 - 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue;`datetime` / `user` 的动态填充用 `$slot`。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。 - `type` 不同,必填子字段不同: @@ -86,13 +94,15 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields ## 返回重点 -- 返回 `field` 和 `created: true`。 -- 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 -- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 +- 单字段返回 `field` 和 `created: true`;多字段完整返回服务端 `fields`、`total` 和 `created: true`。 +- 大数组成功时若不需要逐字段 ID,可追加 `--jq 'if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end'` 控制 stdout 大小;失败分支仍保留完整部分失败明细。需要逐字段 ID 时不要使用该投影。 +- 数组部分失败返回 `ok:false`、`summary` 和有序 `items`,保留已创建字段及 ID、失败项和未执行项。`failed` 项保留 `type`、`subtype`、`code`、`hint`、`retryable`、`log_id`、`troubleshooter`;权限错误还保留原错误已有的 `missing_scopes`、`identity`、`console_url`。 +- 部分失败统一返回 `next_step:"inspect_items"`;`field_get_recommended` 仅表示已创建字段是否建议读回。`retryable:true` 只表示该 `failed` 项可原样自动重试;否则先按该项 `hint` 完成授权或修正输入,再重新提交该项。`not_attempted` 项应单独继续。 +- 完整成功且返回 `field_get_recommended:false`、`next_step:"done"` 时直接结束;除非用户明确要求读回或额外属性,否则不要再执行 `+field-list/get`。确需核验时用 `--jq` 过滤 `+field-list`,不要把全部字段打印进上下文。 +- `field_get_recommended:true` 表示完成当前 `next_step` 后按 `verification_hint` 读回;完整成功时 `next_step:"field_get"` 表示可直接读回。`formula`、`lookup`、`link`、`auto_number` 等字段更适合读回确认服务端最终结构。 ## 工作流 - 1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。 2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index 3050d68330..5667ca0bf4 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -6,8 +6,9 @@ ## 1. 顶层规则(必须遵守) -- `--json` 必须是 JSON 对象。 -- 顶层统一使用:`type` + `name` + 类型特有字段。 +- 单个字段定义始终是 JSON 对象,每个字段对象统一使用:`type` + `name` + 类型特有字段。 +- `+field-create --json` 接受一个字段对象或非空字段对象数组。 +- `+field-update --json` 只接受一个字段对象。 - 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。 - 字段默认值使用 `default_value`,直接传对应 CellValue;支持范围只有 `text`、`number`、静态 `select`、`datetime`、`user`。清空默认值传 `null`;省略表示创建时不设置、更新时不修改。 - 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。