fix(sheet): read back each sheet after --sheets table_put to catch silent data loss

The --values branch already reads back its first non-empty cell after writing,
to defend against the new-document initialization race where a write returns
success but the data does not land. The --sheets branch called table_put and
reported success with no read-back, so the same race would let the command exit
successfully while one or more sheets silently lost their initial data.

After table_put, the --sheets branch now:
- re-fetches get_all_sheets to build a name -> sheetId map (table_put reuses the
  renamed default sheet and auto-creates the rest by name), and
- for every spec that actually has content, reads back its first expected
  non-empty cell and fails if the read-back is empty or the sheet is missing.

firstNonEmptySheetSpecCell mirrors firstNonEmptyValuesCell: it treats columns as
the header row followed by data rows, honours start_cell, and returns
hasContent=false for a name-only spec so a legitimately empty sheet is not
misreported as data loss. Failures carry the nodeId and point at
sheet table-put for recovery, matching the --values branch's error shape.

Tests:
- TestSheetCreateWithSheetsVerifiesEachSheetLanded covers an empty read-back
  (errors, naming the sheet + nodeId + table-put), a sheet missing from the
  post-write listing, and a name-only sheet that must not trigger a read-back.
- TestFirstNonEmptySheetSpecCell covers header/data origins, an empty first
  header cell, a start_cell offset, and content-less specs.
- Existing --sheets tests updated for the added get_all_sheets + per-sheet
  read-back calls.
This commit is contained in:
huwenjiao.hwj committed 2026-08-07 20:25:21 +08:00
1 parent 7c9687094f
commit d73e199d97
2 files changed
+189 -5

No files matched your search

@@ -142,6 +142,27 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value
}); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但写入初始数据失败: %w", nodeID, err)
}
// 逐表回读校验:table_put 可能返回成功但在新建文档初始化竞态下数据未落盘,
// 与 --values 分支同源的问题。table_put 会按 name 复用/新建工作表,因此
// 重取一次 name→sheetId 映射,再按每个 spec 的首个预期非空单元格回读。
sheetIDByName, err := resolveSheetIDsByName(ctx, nodeID)
if err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),数据已提交但无法回读校验(获取工作表列表失败): %w;请自行确认各工作表数据是否落盘,必要时用 sheet table-put 补写", nodeID, err)
}
for _, spec := range sheetSpecs {
name, _ := spec["name"].(string)
probeCell, hasContent := firstNonEmptySheetSpecCell(spec)
if !hasContent {
continue // 只有 name、无 columns/data 的工作表本就应为空,不回读
}
sid, ok := sheetIDByName[name]
if !ok {
return fmt.Errorf("表格已创建 (nodeId=%s),但写入后未找到工作表 %q,其初始数据可能未落盘;请用 sheet table-put 对该文档补写", nodeID, name)
}
if err := verifyRangeNotEmpty(ctx, nodeID, sid, probeCell); err != nil {
return fmt.Errorf("表格已创建 (nodeId=%s),但工作表 %q 的初始数据写入未生效: %w;请用 sheet table-put 对该文档补写", nodeID, name, err)
}
}
}
progress("初始数据写入完成。")
@@ -749,6 +770,71 @@ func resolveFirstSheetID(ctx context.Context, nodeID string) (string, error) {
return "", fmt.Errorf("工作表列表未返回 sheetId,响应: %s", text)
}
// resolveSheetIDsByName 返回文档内 name→sheetId 映射,用于 table_put 之后按
// 工作表名逐一回读校验(table_put 会按 name 复用/新建工作表)。
func resolveSheetIDsByName(ctx context.Context, nodeID string) (map[string]string, error) {
text, err := callMCPToolReturnText(ctx, "get_all_sheets", map[string]any{"nodeId": nodeID})
if err != nil {
return nil, err
}
data := unwrapSheetResult(text)
if data == nil {
return nil, fmt.Errorf("解析工作表列表失败,响应: %s", text)
}
sheets, _ := data["sheets"].([]any)
m := make(map[string]string, len(sheets))
for _, s := range sheets {
sm, _ := s.(map[string]any)
name, _ := sm["name"].(string)
id, _ := sm["sheetId"].(string)
if name != "" && id != "" {
m[name] = id
}
}
return m, nil
}
// sheetSpecGrid 把 table_put 的一个 sheet spec 还原成写入的二维逻辑网格:
// columns(若非空)作为表头行,其后接 data 行。用于定位首个预期非空单元格。
func sheetSpecGrid(spec map[string]any) [][]any {
var grid [][]any
if cols, ok := spec["columns"].([]any); ok && len(cols) > 0 {
grid = append(grid, cols)
}
if data, ok := spec["data"].([]any); ok {
for _, r := range data {
if row, ok := r.([]any); ok {
grid = append(grid, row)
} else {
grid = append(grid, []any{r})
}
}
}
return grid
}
// firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。
// 尊重 start_cell 偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入
// 的内容(例如只给了 name),这类工作表本就应为空,调用方跳过回读,避免把合法的
// 空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell 一致:不能死盯
// A1,首行/首列为空的合法数据不应被误报。
func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) {
col0, row0 := 1, 1
if sc, ok := spec["start_cell"].(string); ok && sc != "" {
if c, r, err := parseA1Cell(strings.ToUpper(sc)); err == nil {
col0, row0 = c, r
}
}
for i, row := range sheetSpecGrid(spec) {
for j, cell := range row {
if cellToString(cell) != "" {
return fmt.Sprintf("%s%d", sheetColumnLetterFromZeroBased(col0-1+j), row0+i), true
}
}
}
return "", false
}
// unwrapSheetResult 解析 MCP 响应 JSON,自动剥离外层 result 包装。
func unwrapSheetResult(text string) map[string]any {
var data map[string]any
+103 -5
View File
@@ -295,6 +295,10 @@ func TestSheetCreateWithSheetsRenamesDefaultThenTablePut(t *testing.T) {
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"success":true}`}, // update_sheet 重命名默认表
{text: `{"success":true}`}, // table_put
// 回读校验阶段:先取 name→sheetId,再逐表回读
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"},{"sheetId":"SHEET_2","name":"二月"}]}`},
{text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空
{text: `{"csv":"[row=1] 项目\n"}`}, // 二月 回读非空
}}
if err := runCreate(t, caller, map[string]string{
"name": "报表",
@@ -302,12 +306,102 @@ func TestSheetCreateWithSheetsRenamesDefaultThenTablePut(t *testing.T) {
}); err != nil {
t.Fatalf("create with sheets: %v", err)
}
if caller.tool != "table_put" {
t.Fatalf("last tool = %q, want table_put", caller.tool)
// create → 探活 → 定位默认表 → 重命名 → table_put → 取 name 映射 → 两表各回读一次
if caller.calls != 8 {
t.Fatalf("calls = %d, want 8", caller.calls)
}
// --sheets 路径不做 A1 回读校验,因此比 --values 少一次调用
if caller.calls != 5 {
t.Fatalf("calls = %d, want 5", caller.calls)
}
// table_put 返回成功但某工作表回读为空(新建文档初始化竞态),必须报数据未落盘,
// 而不是静默成功。错误须带 nodeId 与补写指引。
func TestSheetCreateWithSheetsVerifiesEachSheetLanded(t *testing.T) {
// 二月回读为空 → 报错
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodeId":"NODE_1"}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"success":true}`},
{text: `{"success":true}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"},{"sheetId":"SHEET_2","name":"二月"}]}`},
{text: `{"csv":"[row=1] 项目\n"}`}, // 一月 非空
{text: `{"csv":""}`}, // 二月 回读为空
}}
err := runCreate(t, caller, map[string]string{
"name": "报表",
"sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]},{"name":"二月","columns":["项目"],"data":[["房租"]]}]`,
})
if err == nil {
t.Fatal("二月回读为空应报错,而非静默成功")
}
for _, want := range []string{"NODE_1", "二月", "写入未生效", "table-put"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误信息缺少 %q: %v", want, err)
}
}
// table_put 后工作表列表里找不到某个 spec 名 → 同样报错
missing := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodeId":"NODE_1"}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"success":true}`},
{text: `{"success":true}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"}]}`}, // 缺二月
{text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空,随后二月查找失败
}}
err = runCreate(t, missing, map[string]string{
"name": "报表",
"sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]},{"name":"二月","columns":["项目"],"data":[["房租"]]}]`,
})
if err == nil || !strings.Contains(err.Error(), "二月") || !strings.Contains(err.Error(), "NODE_1") {
t.Fatalf("缺失工作表应带 nodeId 报错: %v", err)
}
// 只有 name、无数据的工作表本就应为空,不回读、不报错
empty := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodeId":"NODE_1"}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"success":true}`},
{text: `{"success":true}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"有数据"},{"sheetId":"SHEET_2","name":"空表"}]}`},
{text: `{"csv":"[row=1] a\n"}`}, // 有数据 回读非空;空表不回读
}}
if err := runCreate(t, empty, map[string]string{
"name": "报表",
"sheets": `[{"name":"有数据","columns":["a"],"data":[["v"]]},{"name":"空表"}]`,
}); err != nil {
t.Fatalf("无数据工作表不应触发回读失败: %v", err)
}
if empty.calls != 7 {
t.Fatalf("calls = %d, want 7(空表不回读,只 6 步编排 + 有数据表 1 次回读)", empty.calls)
}
}
func TestFirstNonEmptySheetSpecCell(t *testing.T) {
mustDecode := func(s string) map[string]any {
var m map[string]any
if err := json.Unmarshal([]byte(s), &m); err != nil {
t.Fatal(err)
}
return m
}
for _, tc := range []struct {
spec string
wantCell string
wantContent bool
}{
{`{"name":"S","columns":["项目"],"data":[["房租"]]}`, "A1", true},
{`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空
{`{"name":"S","data":[[1]]}`, "A1", true}, // 无表头,纯 data
{`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell 偏移
{`{"name":"S"}`, "", false}, // 只有 name
{`{"name":"S","columns":[],"data":[]}`, "", false}, // 空
} {
gotCell, gotContent := firstNonEmptySheetSpecCell(mustDecode(tc.spec))
if gotCell != tc.wantCell || gotContent != tc.wantContent {
t.Errorf("firstNonEmptySheetSpecCell(%s) = (%q,%v), want (%q,%v)", tc.spec, gotCell, gotContent, tc.wantCell, tc.wantContent)
}
}
}
@@ -921,6 +1015,8 @@ func TestSheetCreateSheetsAppliesStylesBySheetName(t *testing.T) {
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // resolveFirstSheetID
{text: `{"success":true}`}, // update_sheet 重命名
{text: `{"success":true}`}, // table_put
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"}]}`}, // resolveSheetIDsByName
{text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空
{text: `{"success":true}`}, // set_cell_range(样式)
}}
if err := runCreate(t, caller, map[string]string{
@@ -1166,6 +1262,8 @@ func TestSheetCreatePreservesLargeIntegerLiterals(t *testing.T) {
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`},
{text: `{"success":true}`},
{text: `{"success":true}`},
{text: `{"sheets":[{"sheetId":"SHEET_1","name":"a"}]}`}, // resolveSheetIDsByName
{text: `{"csv":"[row=1] id\n"}`}, // a 回读非空
}}, map[string]string{
"name": "X",
"sheets": `[{"name":"a","columns":["id"],"data":[[` + snowflake + `],[` + order + `]]}]`,