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:
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
|
||||
|
||||
@@ -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 + `]]}]`,
|
||||
|
||||
Reference in new issue
Block a user