mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
7c4f6c023f
* fix(base): improve field creation and query guidance
1. Document batch field creation in shortcut help and the delivered Base skill.
2. Choose field types from stored values instead of business-purpose names.
3. Add generic common filter values to the data-query quick guide with contract tests.
说明:
- Combines the accepted fixes for base_table_096, base_table_028, and base_table_087;
MR 1275 contributes round 4 only.
```ai-signature
改动范围: Base field-create 帮助与 Skill 指南、data-query 快速指南,以及对应的 shortcuts/base 契约测试
思考过程: 保留三个实验的最终通用规则,合并 public main 上新增的字段读回提示,并排除没有 benchmark 支撑的 MR 1275 round6
改动原因: 让代理发现批量字段接口、按存储值选择字段类型,并用常见通用形状构造 data-query 过滤条件
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: a53fdb6b6b82d74e4deff5e6d32591ec897cd2dc6ad662885c961a27e67d4666
* test(base): strengthen guidance contracts
1. Assert every common filter fragment introduced by the data-query quick guide.
2. Keep the field-create argument table compliant with markdown table spacing.
```ai-signature
改动范围: data-query 指南契约测试与 field-create Markdown 表格后的空行
思考过程: 逐条核对 CodeRabbit 建议,只补会防止新增指南片段回退的断言和确定性的 MD058 格式问题,不改生产提示语义
改动原因: 关闭 PR 2114 的两条有效自动审查意见并保持变更可回归
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 81096f474001f2c9c59af48ee64150f205101710ebcf3b909ab9d300a019f46f
* fix(base): generalize data-query filter guidance
1. Replace the date-and-status scenario template with reusable Condition.value shape rules.
2. Update the contract test to require relative-date guidance and reject evaluation-shaped placehold
ers.
```ai-signature
改动范围: lark-base data-query quick guide 与对应 shortcuts/base 契约测试
思考过程: 保留 select、datetime、empty 的通用 value shape,删除日期字段和状态字段组合模板,避免将 base_table_087 的解题路径固化到公共指南
改动原因: benchmark 显示当前文案能引导目标题,但组合示例与测试过度贴合单题,需要收敛为跨场景可复用的不变量
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 7b6916f05dccf6489dce65610fe757f7b346bcd0f8f5500cfbfb1f30c60471f3
* fix(base): generalize creation guidance
* fix(base): report partial field-create results
* fix(base): preserve partial field-create recovery metadata
* fix(base): separate partial field recovery
* perf(base): 缩短字段批量创建的空等
1. 将固定 1 秒批次等待改为 500ms 最小请求起点间隔,并让请求耗时抵扣等待
2. 新增节流计算契约测试,覆盖首次请求、快速响应和慢响应
3. 同输入 150 字段 A/B 从 269.64s 降至 118.80s,且两侧均创建 150/150
说明:
- 保持同表写入串行和 partial failure 输出不变
```ai-signature
改动范围: shortcuts/base/field_ops.go 与 shortcuts/base/base_execute_test.go,仅调整 field-create 数组批次的串行节流计算和回归测试
思考过程: 保留同表串行写入,以 500ms 作为请求起点最小间隔,并把请求耗时计入间隔,避免固定空等同时降低写冲突风险
改动原因: PR 引导 Agent 使用数组批量创建后触发既有每项固定 1 秒等待,导致 150 字段用例产生约 149 秒可归因耗时回退
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 69b888d362d2f484c4ce6ad050bdbfe2de7368948eb79ba516bcaa6806ec9107
* fix(base): 收紧不支持字段行为的终止边界
1. 派生、自动、同步或回填行为只使用已记录能力,无法实现时禁止探测、占位或虚假完成
2. 删除 data-query 契约测试对旧题模板占位符的反向黑名单,只保留原子规则和真实 case 污染检查
3. field-create 指引替换前后均为 36 个英文词,不扩大该帮助项的词数
```ai-signature
改动范围: shortcuts/base/field_create.go、base_shortcuts_test.go 与 data_query_guide_contract_test.go,仅收口通用终止规则和测试泛化
思考过程: 采纳 review 中可独立闭环的两点,不增加翻译专用规则,不修改运行时能力;用等词数替换避免帮助上下文继续增长
改动原因: 当前规则能阻止按业务名猜字段类型,却仍允许退化成普通文本占位;同时测试记住旧题模板会阻碍未来合理示例
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 759b7f4044c6381b963c8ea299b70967e634ab9d43b6ebfbe94ef38bb69ba46a
* perf(base): 压缩字段批量创建的评测开销
1. 批量成功与部分失败仅返回字段 id/name/type,保留恢复所需身份并减少大响应上下文
2. 引导数组在调用方超时范围内一次提交,并为生成的大数组推荐 @file 或 argv-safe 调用
3. 字段列表默认页大小提升到 API 上限 200,避免百字段以上场景的帮助查询与重试
说明:
- 同口径 case032:raw token 432292→428304,weighted token 126408→117761,耗时 272675ms→235022ms,两侧均读回 154 字段
```ai-signature
改动范围: Base field-create 批量输出、帮助提示、field-list 默认分页及对应契约测试与 Skill 返回说明
思考过程: 从同口径 trace 定位固定分块、大字段对象回传、100 条分页和 shell 双重转义四个确定性开销,保持单字段与部分失败恢复语义不变并逐项用测试锁定
改动原因: PR 引导数组批量创建后虽降低耗时,但多回合大输出会推高 raw token;需要在不牺牲正确率和恢复信息的前提下同时压缩 token 与耗时
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 8a2bd2d79c9abdb1e36ae45822dd04b2a3384498a1dc268cffcd47cff00d94ed
* perf(base): 收敛字段批量创建的上下文
1. 大数组成功路径推荐保留摘要的 --jq 投影,失败路径仍原样保留部分失败明细
2. 为一个或多个简单 text 字段提供 help fast path,并让 next_step:done 终止默认回读
3. 补充 Skill、帮助与执行结果契约测试,锁定有界输出和可恢复失败语义
说明:
- 最新 main 同题 A/B:两侧均回读 154 字段,raw token 下降 5.9%,耗时下降 19.3%,峰值上下文下降 13.3%,工具调用下降 20%
```ai-signature
改动范围: Base field-create 帮助、简单字段成功提示、lark-base Skill 路由与对应契约测试
思考过程: 从最终 A/B trace 分别定位批量成功展开 150 项、简单 text 读取冗余指南和成功后整表回读三类可控上下文开销,用成功摘要与失败全量明细分流来保留恢复能力
改动原因: 继续优化 PR 2114 的 token 和耗时,同时要求任何回退不能归因到 PR;需要让大批量成功路径有界且不削弱正确性或部分失败恢复
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 626b288a7f5444dfecb872e6cd29d4fe0788404f5001dcb47aac64e2d55d9b5a
* fix(base): 恢复批量字段输出与分页默认契约
1. 批量创建完整成功时保留服务端字段元数据,部分失败仍返回精简 identity
2. 将 +field-list 默认页大小恢复为 100,继续支持显式 --limit 200
3. 补充回归测试与字段创建文档,保留 --jq 有界输出指导
说明:
- 定向、Base 全量、race、仓库单测、构建、vet 与 lint 均通过
```ai-signature
改动范围: Base 批量字段创建成功输出、字段列表默认分页、对应测试与文档
思考过程: 先用契约测试复现完整字段元数据丢失和默认分页翻倍,再只恢复主干既有成功输出与默认值,同时锁定部分失败精简输出不变
改动原因: 移除可归因到 PR 的兼容性和 token 回退,并保留显式 jq 投影、节流、快速路径及部分失败恢复带来的通用收益
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 6351ed9d228611e3b6f5bc30faf278835b463e07f4eb020f225abd41e264399c
* fix(base): present batch field errors before partial output
1. Project the typed field-create error through Runtime.PresentError before copying result fields
2. Read Error, ProblemOf, and permission extensions from the presented clone
3. Cover visible scoped authorization and concealed recovery without fabricating missing_scopes
说明:
- Targeted, Base, full race, build, vet, format, lint, and module checks pass
```ai-signature
改动范围: Base 批量字段创建部分失败的错误呈现,以及 visible 和 concealed 恢复契约测试
思考过程: 先在最新 main 合并树上复现无 scope 授权提示和隐藏命令泄露,再复用兄弟批量命令的 PresentError 边界,仅替换 payload 复制时的错误来源
改动原因: OutPartialFailure 不会再次呈现根错误,必须在复制 typed error 字段前应用命令 scope 与发行隐藏策略
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: deee600dc8d4bb889629895178c1f217ba7762fbd5b3163da3b9d621c8585ac0
* fix(base): allow recovery before retrying failed field writes
1. Clarify that retryable gates unchanged automatic retries, not corrected resubmissions
2. Classify Base error 1254291 as a retryable conflict with canonical wait guidance
3. Cover authorization recovery, write conflicts, and reference contract consistency
```ai-signature
改动范围: internal/errclass/codemeta_base.go、shortcuts/base/field_ops.go、对应 Base 回归测试与 field-create 参考文档
思考过程: 将 retryable 限定为同一请求原样自动重试资格,保留授权或输入修正后重新提交,并复用现有 conflict 恢复提示
改动原因: 部分失败顶层提示会与权限恢复 hint 冲突,且 1254291 未分类导致等待重试规则无法由结构化错误驱动
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 65cd95f24968c5e0b0c43cf858ed787eabb6d9d63b3d5199a294bd284816c35e
* fix(base): preserve partial field recovery contracts
1. Preserve presented typed-error extensions without allowing them to overwrite batch ledger fields
2. Align field creation guidance with command-specific name semantics and caller-timeout recovery
3. Cover security challenges, extension collisions, and storage-type selection with regression tests
```ai-signature
改动范围: shortcuts/base 的 field-create 部分失败输出、命令提示、Base Skill 写入规则、field-create reference 与对应回归测试
思考过程: 复用 Runtime.PresentError 后 concrete typed error 的 JSON wire shape 作为扩展字段单源;对批次账本自有键统一生成无冲突 error_ 别名,并只收敛已证实的同名、fast path 与 timeout 契约矛盾
改动原因: 部分失败会丢失 challenge_url 等恢复字段,自定义 typed error 还可覆盖 status/index/error 导致错误账本;过度绝对的字段类型、同名和超时文案也会形成可归因正确率回退
Break Change: 否
```
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 02377560f14202ea652f530389ac102647189923e45548337814ec3871f5a4de
---------
Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
489 lines
17 KiB
Go
489 lines
17 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// 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")
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
limit := runtime.Int("limit")
|
|
return common.NewDryRunAPI().
|
|
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
|
|
Params(map[string]interface{}{"offset": offset, "limit": limit}).
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime))
|
|
}
|
|
|
|
func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
return common.NewDryRunAPI().
|
|
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
Set("field_id", runtime.Str("field-id"))
|
|
}
|
|
|
|
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
pc := newParseCtx(runtime)
|
|
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
|
}
|
|
dr := common.NewDryRunAPI().
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime))
|
|
for _, body := range bodies {
|
|
dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body)
|
|
}
|
|
return dr
|
|
}
|
|
|
|
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
pc := newParseCtx(runtime)
|
|
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
|
}
|
|
return common.NewDryRunAPI().
|
|
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
|
Body(body).
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
Set("field_id", runtime.Str("field-id"))
|
|
}
|
|
|
|
func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
return common.NewDryRunAPI().
|
|
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
Set("field_id", runtime.Str("field-id"))
|
|
}
|
|
|
|
func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
limit := runtime.Int("limit")
|
|
params := map[string]interface{}{
|
|
"offset": runtime.Int("offset"),
|
|
"limit": limit,
|
|
}
|
|
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
|
params["query"] = keyword
|
|
}
|
|
return common.NewDryRunAPI().
|
|
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options").
|
|
Params(params).
|
|
Set("base_token", runtime.Str("base-token")).
|
|
Set("table_id", baseTableID(runtime)).
|
|
Set("field_id", runtime.Str("field-id"))
|
|
}
|
|
|
|
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
|
pc := newParseCtx(runtime)
|
|
return parseJSONObject(pc, runtime.Str("json"), "json")
|
|
}
|
|
|
|
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {
|
|
fieldType := strings.ToLower(strings.TrimSpace(common.GetString(body, "type")))
|
|
if (fieldType == "formula" || fieldType == "lookup") && !runtime.Bool("i-have-read-guide") {
|
|
guidePath := "skills/lark-base/references/formula-field-guide.md"
|
|
if fieldType == "lookup" {
|
|
guidePath = "skills/lark-base/references/lookup-field-guide.md"
|
|
}
|
|
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFieldCreate(runtime *common.RuntimeContext) error {
|
|
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, body := range bodies {
|
|
if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFieldUpdate(runtime *common.RuntimeContext) error {
|
|
body, err := validateFieldJSON(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validateFormulaLookupGuideAck(runtime, "+field-update", body)
|
|
}
|
|
|
|
func executeFieldList(runtime *common.RuntimeContext) error {
|
|
offset := runtime.Int("offset")
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
limit := runtime.Int("limit")
|
|
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if total == 0 {
|
|
total = len(fields)
|
|
}
|
|
runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil)
|
|
return nil
|
|
}
|
|
|
|
func executeFieldGet(runtime *common.RuntimeContext) error {
|
|
baseToken := runtime.Str("base-token")
|
|
tableIDValue := baseTableID(runtime)
|
|
fieldRef := runtime.Str("field-id")
|
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(map[string]interface{}{"field": data}, nil)
|
|
return nil
|
|
}
|
|
|
|
func executeFieldCreate(runtime *common.RuntimeContext) error {
|
|
bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fields := make([]interface{}, 0, len(bodies))
|
|
var previousStartedAt time.Time
|
|
for idx, body := range bodies {
|
|
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)
|
|
}
|
|
if len(fields) == 1 {
|
|
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
|
return nil
|
|
}
|
|
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
|
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
|
|
}
|
|
}
|
|
for key, value := range fieldCreateTypedErrorExtensions(presented) {
|
|
failed[key] = value
|
|
}
|
|
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 fieldCreateTypedErrorExtensions(err error) map[string]interface{} {
|
|
typed, ok := errs.UnwrapTypedError(err)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// Built-in typed errors expose JSON-safe extension fields. Keep this
|
|
// projection best-effort so an encoding failure cannot replace the more
|
|
// useful partial-success envelope.
|
|
raw, marshalErr := json.Marshal(typed)
|
|
if marshalErr != nil {
|
|
return nil
|
|
}
|
|
var fields map[string]interface{}
|
|
if unmarshalErr := json.Unmarshal(raw, &fields); unmarshalErr != nil {
|
|
return nil
|
|
}
|
|
for _, key := range []string{"type", "subtype", "code", "message", "hint", "log_id", "troubleshooter", "retryable"} {
|
|
delete(fields, key)
|
|
}
|
|
// These keys belong to the partial-failure ledger. Preserve colliding
|
|
// extension values under a non-conflicting error_ alias instead of letting
|
|
// an extension rewrite the submitted item identity or status.
|
|
for _, key := range []string{"index", "status", "field", "error"} {
|
|
value, exists := fields[key]
|
|
if !exists {
|
|
continue
|
|
}
|
|
alias := "error_" + key
|
|
for {
|
|
if _, conflict := fields[alias]; !conflict {
|
|
break
|
|
}
|
|
alias = "error_" + alias
|
|
}
|
|
fields[alias] = value
|
|
delete(fields, key)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
if len(bodies) == 0 {
|
|
return nil, baseFlagErrorf("--json must contain at least one field JSON object")
|
|
}
|
|
return bodies, nil
|
|
}
|
|
|
|
func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
|
pc := newParseCtx(runtime)
|
|
baseToken := runtime.Str("base-token")
|
|
tableIDValue := baseTableID(runtime)
|
|
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fieldRef := runtime.Str("field-id")
|
|
data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
|
return nil
|
|
}
|
|
|
|
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
|
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
|
|
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
|
}
|
|
|
|
// fieldCreateBatchResult attaches the same top-level readback contract to a
|
|
// multi-field create. It recommends +field-get when any submitted field is a
|
|
// computed/linked/generated (or unknown) type, so agents know when to verify
|
|
// 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; 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
|
|
reason = r
|
|
break
|
|
}
|
|
}
|
|
return attachFieldReadbackRecommendation(result, recommend, reason)
|
|
}
|
|
|
|
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
|
returnedType := normalizeFieldType(fieldResultType(result["field"]))
|
|
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
|
|
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
|
|
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
|
}
|
|
|
|
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
|
|
if returnedType != "" && submittedType != "" && returnedType != submittedType {
|
|
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
|
|
}
|
|
|
|
fieldType := returnedType
|
|
if fieldType == "" {
|
|
fieldType = submittedType
|
|
}
|
|
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
|
|
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
|
|
}
|
|
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
|
|
}
|
|
|
|
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
|
|
result["field_get_recommended"] = readbackRecommended
|
|
result["verification_hint"] = reason
|
|
if readbackRecommended {
|
|
result["next_step"] = "field_get"
|
|
} else {
|
|
result["next_step"] = "done"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
|
|
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
|
|
return fieldTypeReadbackRecommendation(fieldType, operation)
|
|
}
|
|
|
|
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
|
|
fieldType = normalizeFieldType(fieldType)
|
|
switch fieldType {
|
|
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 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"
|
|
}
|
|
}
|
|
|
|
func normalizeFieldType(fieldType string) string {
|
|
return strings.ToLower(strings.TrimSpace(fieldType))
|
|
}
|
|
|
|
func fieldResultType(value interface{}) string {
|
|
field, ok := value.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
|
|
return fieldType
|
|
}
|
|
nested, ok := field["field"].(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
|
|
}
|
|
|
|
func executeFieldDelete(runtime *common.RuntimeContext) error {
|
|
baseToken := runtime.Str("base-token")
|
|
tableIDValue := baseTableID(runtime)
|
|
fieldRef := runtime.Str("field-id")
|
|
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(map[string]interface{}{"deleted": true, "field_id": fieldRef, "field_name": fieldRef}, nil)
|
|
return nil
|
|
}
|
|
|
|
func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
|
|
baseToken := runtime.Str("base-token")
|
|
tableIDValue := baseTableID(runtime)
|
|
fieldRef := runtime.Str("field-id")
|
|
limit := runtime.Int("limit")
|
|
params := map[string]interface{}{
|
|
"offset": runtime.Int("offset"),
|
|
"limit": limit,
|
|
}
|
|
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
|
params["query"] = keyword
|
|
}
|
|
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options, _ := data["options"].([]interface{})
|
|
total := toInt(data["total"])
|
|
if total == 0 {
|
|
total = len(options)
|
|
}
|
|
runtime.Out(map[string]interface{}{
|
|
"field_id": fieldRef,
|
|
"field_name": fieldRef,
|
|
"keyword": strings.TrimSpace(runtime.Str("keyword")),
|
|
"options": options,
|
|
"total": total,
|
|
}, nil)
|
|
return nil
|
|
}
|