@tita-agent/dsh-data-table 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -2
  3. package/cordis.patch.yml +7 -0
  4. package/lib/client.js +16766 -0
  5. package/lib/contract-envelope.d.ts +91 -0
  6. package/lib/contract-envelope.js +106 -0
  7. package/lib/contract-layout.d.ts +130 -0
  8. package/lib/contract-layout.js +138 -0
  9. package/lib/contract-refs.d.ts +39 -0
  10. package/lib/contract-refs.js +9 -0
  11. package/lib/contract-version.d.ts +311 -0
  12. package/lib/contract-version.js +270 -0
  13. package/lib/contract-wire.d.ts +147 -0
  14. package/lib/contract-wire.js +99 -0
  15. package/lib/contracts.d.ts +12 -0
  16. package/lib/contracts.js +12 -0
  17. package/lib/types/audit.d.ts +42 -0
  18. package/lib/types/audit.js +39 -0
  19. package/lib/types/client/ProjectImportMenu.d.ts +30 -0
  20. package/lib/types/client/ProjectImportMenu.js +70 -0
  21. package/lib/types/client/SheetPanel.d.ts +33 -0
  22. package/lib/types/client/SheetPanel.js +1766 -0
  23. package/lib/types/client/SheetToggle.d.ts +18 -0
  24. package/lib/types/client/SheetToggle.js +26 -0
  25. package/lib/types/client/index.d.ts +15 -0
  26. package/lib/types/client/index.js +110 -0
  27. package/lib/types/client/persistence/changes.d.ts +15 -0
  28. package/lib/types/client/persistence/changes.js +51 -0
  29. package/lib/types/client/persistence/ids.d.ts +34 -0
  30. package/lib/types/client/persistence/ids.js +48 -0
  31. package/lib/types/client/persistence/merge.d.ts +35 -0
  32. package/lib/types/client/persistence/merge.js +362 -0
  33. package/lib/types/client/store.d.ts +61 -0
  34. package/lib/types/client/store.js +152 -0
  35. package/lib/types/client/surface-context.d.ts +27 -0
  36. package/lib/types/client/surface-context.js +104 -0
  37. package/lib/types/client/surface-control.d.ts +2 -0
  38. package/lib/types/client/surface-control.js +12 -0
  39. package/lib/types/client/transport/workbookApi.d.ts +159 -0
  40. package/lib/types/client/transport/workbookApi.js +246 -0
  41. package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
  42. package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
  43. package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
  44. package/lib/types/client/univer/WorkbookCanvas.js +87 -0
  45. package/lib/types/client/univer/WorkbookView.d.ts +3 -0
  46. package/lib/types/client/univer/WorkbookView.js +2 -0
  47. package/lib/types/client/univer/loader.d.ts +18 -0
  48. package/lib/types/client/univer/loader.js +81 -0
  49. package/lib/types/client/univer/standalone-entry.d.ts +1 -0
  50. package/lib/types/client/univer/standalone-entry.js +19 -0
  51. package/lib/types/client/versions/VersionChrome.d.ts +33 -0
  52. package/lib/types/client/versions/VersionChrome.js +21 -0
  53. package/lib/types/client/versions/inspect.d.ts +70 -0
  54. package/lib/types/client/versions/inspect.js +88 -0
  55. package/lib/types/client/versions/preview-decoration.d.ts +11 -0
  56. package/lib/types/client/versions/preview-decoration.js +54 -0
  57. package/lib/types/client/versions/records.d.ts +19 -0
  58. package/lib/types/client/versions/records.js +70 -0
  59. package/lib/types/client/workspace-host.d.ts +61 -0
  60. package/lib/types/client/workspace-host.js +124 -0
  61. package/lib/types/collaboration-hub.d.ts +31 -0
  62. package/lib/types/collaboration-hub.js +69 -0
  63. package/lib/types/contract/ids.d.ts +24 -0
  64. package/lib/types/contract/ids.js +46 -0
  65. package/lib/types/contract/mutations.d.ts +25 -0
  66. package/lib/types/contract/mutations.js +782 -0
  67. package/lib/types/contract/operations.d.ts +54 -0
  68. package/lib/types/contract/operations.js +203 -0
  69. package/lib/types/contract/snapshots.d.ts +176 -0
  70. package/lib/types/contract/snapshots.js +351 -0
  71. package/lib/types/contract/univer-api-catalog.d.ts +29 -0
  72. package/lib/types/contract/univer-api-catalog.js +264 -0
  73. package/lib/types/errors.d.ts +8 -0
  74. package/lib/types/errors.js +12 -0
  75. package/lib/types/http/assets-routes.d.ts +3 -0
  76. package/lib/types/http/assets-routes.js +169 -0
  77. package/lib/types/http/collaboration.d.ts +10 -0
  78. package/lib/types/http/collaboration.js +154 -0
  79. package/lib/types/http/history.d.ts +15 -0
  80. package/lib/types/http/history.js +134 -0
  81. package/lib/types/http/project-files.d.ts +79 -0
  82. package/lib/types/http/project-files.js +206 -0
  83. package/lib/types/http/project-scope.d.ts +50 -0
  84. package/lib/types/http/project-scope.js +52 -0
  85. package/lib/types/http/routes.d.ts +98 -0
  86. package/lib/types/http/routes.js +323 -0
  87. package/lib/types/http/standalone.d.ts +9 -0
  88. package/lib/types/http/standalone.js +31 -0
  89. package/lib/types/http/trunk.d.ts +10 -0
  90. package/lib/types/http/trunk.js +322 -0
  91. package/lib/types/http/worktree.d.ts +9 -0
  92. package/lib/types/http/worktree.js +145 -0
  93. package/lib/types/index.d.ts +18 -0
  94. package/lib/types/index.js +156 -0
  95. package/lib/types/invariant.d.ts +15 -0
  96. package/lib/types/invariant.js +15 -0
  97. package/lib/types/request-params.d.ts +21 -0
  98. package/lib/types/request-params.js +76 -0
  99. package/lib/types/store/paths.d.ts +68 -0
  100. package/lib/types/store/paths.js +148 -0
  101. package/lib/types/store/project.d.ts +88 -0
  102. package/lib/types/store/project.js +120 -0
  103. package/lib/types/store/read.d.ts +110 -0
  104. package/lib/types/store/read.js +404 -0
  105. package/lib/types/store/retention.d.ts +27 -0
  106. package/lib/types/store/retention.js +111 -0
  107. package/lib/types/store/write.d.ts +22 -0
  108. package/lib/types/store/write.js +210 -0
  109. package/lib/types/tools/arguments.d.ts +40 -0
  110. package/lib/types/tools/arguments.js +130 -0
  111. package/lib/types/tools/cells.d.ts +50 -0
  112. package/lib/types/tools/cells.js +298 -0
  113. package/lib/types/tools/index.d.ts +2 -0
  114. package/lib/types/tools/index.js +250 -0
  115. package/lib/types/tools/render.d.ts +47 -0
  116. package/lib/types/tools/render.js +290 -0
  117. package/lib/types/tools/univer-api.d.ts +18 -0
  118. package/lib/types/tools/univer-api.js +198 -0
  119. package/lib/types/tools/workbook-catalog.d.ts +3 -0
  120. package/lib/types/tools/workbook-catalog.js +83 -0
  121. package/lib/types/tools/workbook-edit.d.ts +192 -0
  122. package/lib/types/tools/workbook-edit.js +912 -0
  123. package/lib/types/tools/workbook-find.d.ts +11 -0
  124. package/lib/types/tools/workbook-find.js +89 -0
  125. package/lib/types/tools/workbook-history.d.ts +2 -0
  126. package/lib/types/tools/workbook-history.js +59 -0
  127. package/lib/types/tools/workbook-read.d.ts +21 -0
  128. package/lib/types/tools/workbook-read.js +204 -0
  129. package/lib/types/types.d.ts +377 -0
  130. package/lib/types/types.js +34 -0
  131. package/lib/types/univer/apply-operation.d.ts +14 -0
  132. package/lib/types/univer/apply-operation.js +398 -0
  133. package/lib/types/univer/chunk.d.ts +68 -0
  134. package/lib/types/univer/chunk.js +16 -0
  135. package/lib/types/univer/decoration.d.ts +34 -0
  136. package/lib/types/univer/decoration.js +88 -0
  137. package/lib/types/univer/diagnostics.d.ts +31 -0
  138. package/lib/types/univer/diagnostics.js +141 -0
  139. package/lib/types/univer/engine.d.ts +33 -0
  140. package/lib/types/univer/engine.js +338 -0
  141. package/lib/types/univer/entry.d.ts +8 -0
  142. package/lib/types/univer/entry.js +11 -0
  143. package/lib/types/univer/facade.d.ts +107 -0
  144. package/lib/types/univer/facade.js +1 -0
  145. package/lib/types/univer/fonts.d.ts +202 -0
  146. package/lib/types/univer/fonts.js +140 -0
  147. package/lib/types/univer/plugins/collaboration.d.ts +23 -0
  148. package/lib/types/univer/plugins/collaboration.js +47 -0
  149. package/lib/types/univer/plugins/export.d.ts +39 -0
  150. package/lib/types/univer/plugins/export.js +246 -0
  151. package/lib/types/univer/plugins/history.d.ts +24 -0
  152. package/lib/types/univer/plugins/history.js +50 -0
  153. package/lib/types/univer/plugins/version.d.ts +13 -0
  154. package/lib/types/univer/plugins/version.js +13 -0
  155. package/lib/types/version/diff.d.ts +36 -0
  156. package/lib/types/version/diff.js +156 -0
  157. package/lib/types/version/grouping.d.ts +7 -0
  158. package/lib/types/version/grouping.js +94 -0
  159. package/lib/types/version/records.d.ts +17 -0
  160. package/lib/types/version/records.js +102 -0
  161. package/lib/types/version/session.d.ts +36 -0
  162. package/lib/types/version/session.js +89 -0
  163. package/lib/types/version/summary.d.ts +24 -0
  164. package/lib/types/version/summary.js +111 -0
  165. package/lib/types/workbook-manager.d.ts +191 -0
  166. package/lib/types/workbook-manager.js +935 -0
  167. package/lib/types/xlsx-export-worker.d.ts +5 -0
  168. package/lib/types/xlsx-export-worker.js +24 -0
  169. package/lib/types/xlsx-export.d.ts +21 -0
  170. package/lib/types/xlsx-export.js +371 -0
  171. package/lib/types/xlsx-import-worker.d.ts +1 -0
  172. package/lib/types/xlsx-import-worker.js +19 -0
  173. package/lib/univer-client.js +690 -0
  174. package/lib/workbook-page.js +23826 -0
  175. package/lib/xlsx-export-worker.js +104 -0
  176. package/lib/xlsx-import-worker.js +46 -0
  177. package/package.json +106 -3
  178. package/src/audit.ts +58 -0
  179. package/src/client/ProjectImportMenu.tsx +100 -0
  180. package/src/client/SheetPanel.module.css +1578 -0
  181. package/src/client/SheetPanel.tsx +1934 -0
  182. package/src/client/SheetToggle.module.css +73 -0
  183. package/src/client/SheetToggle.tsx +65 -0
  184. package/src/client/contract.d.ts +19 -0
  185. package/src/client/index.ts +131 -0
  186. package/src/client/persistence/changes.ts +58 -0
  187. package/src/client/persistence/ids.ts +60 -0
  188. package/src/client/persistence/merge.ts +392 -0
  189. package/src/client/store.ts +188 -0
  190. package/src/client/surface-context.ts +123 -0
  191. package/src/client/surface-control.ts +13 -0
  192. package/src/client/transport/workbookApi.ts +355 -0
  193. package/src/client/univer/SheetView.module.css +110 -0
  194. package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
  195. package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
  196. package/src/client/univer/WorkbookCanvas.tsx +128 -0
  197. package/src/client/univer/WorkbookView.module.css +30 -0
  198. package/src/client/univer/WorkbookView.tsx +3 -0
  199. package/src/client/univer/loader.ts +90 -0
  200. package/src/client/univer/react-dom-client.d.ts +10 -0
  201. package/src/client/univer/standalone-entry.tsx +19 -0
  202. package/src/client/univer/standalone.css +12 -0
  203. package/src/client/univer/univer-locales.d.ts +89 -0
  204. package/src/client/univer/univer-modules.d.ts +26 -0
  205. package/src/client/versions/VersionChrome.tsx +114 -0
  206. package/src/client/versions/inspect.ts +138 -0
  207. package/src/client/versions/preview-decoration.ts +54 -0
  208. package/src/client/versions/records.ts +78 -0
  209. package/src/client/workspace-host.ts +147 -0
  210. package/src/collaboration-hub.ts +83 -0
  211. package/src/contract/ids.ts +50 -0
  212. package/src/contract/mutations.ts +791 -0
  213. package/src/contract/operations.ts +221 -0
  214. package/src/contract/snapshots.ts +383 -0
  215. package/src/contract/univer-api-catalog.ts +289 -0
  216. package/src/css-modules.d.ts +6 -0
  217. package/src/errors.ts +25 -0
  218. package/src/http/assets-routes.ts +177 -0
  219. package/src/http/collaboration.ts +144 -0
  220. package/src/http/history.ts +151 -0
  221. package/src/http/project-files.ts +227 -0
  222. package/src/http/project-scope.ts +77 -0
  223. package/src/http/routes.ts +364 -0
  224. package/src/http/standalone.ts +32 -0
  225. package/src/http/trunk.ts +322 -0
  226. package/src/http/worktree.ts +119 -0
  227. package/src/index.ts +165 -0
  228. package/src/invariant.ts +17 -0
  229. package/src/request-params.ts +92 -0
  230. package/src/store/paths.ts +165 -0
  231. package/src/store/project.ts +137 -0
  232. package/src/store/read.ts +454 -0
  233. package/src/store/retention.ts +106 -0
  234. package/src/store/write.ts +230 -0
  235. package/src/tools/arguments.ts +128 -0
  236. package/src/tools/cells.ts +298 -0
  237. package/src/tools/index.ts +274 -0
  238. package/src/tools/render.ts +301 -0
  239. package/src/tools/univer-api.ts +243 -0
  240. package/src/tools/workbook-catalog.ts +94 -0
  241. package/src/tools/workbook-edit.ts +987 -0
  242. package/src/tools/workbook-find.ts +80 -0
  243. package/src/tools/workbook-history.ts +63 -0
  244. package/src/tools/workbook-read.ts +210 -0
  245. package/src/types.ts +432 -0
  246. package/src/univer/apply-operation.ts +369 -0
  247. package/src/univer/chunk.ts +77 -0
  248. package/src/univer/decoration.ts +111 -0
  249. package/src/univer/diagnostics.ts +136 -0
  250. package/src/univer/engine.ts +370 -0
  251. package/src/univer/entry.ts +14 -0
  252. package/src/univer/facade.ts +91 -0
  253. package/src/univer/fonts.ts +148 -0
  254. package/src/univer/overrides.css +33 -0
  255. package/src/univer/plugins/collaboration.ts +47 -0
  256. package/src/univer/plugins/export.ts +236 -0
  257. package/src/univer/plugins/history.ts +49 -0
  258. package/src/univer/plugins/version.ts +13 -0
  259. package/src/version/diff.ts +169 -0
  260. package/src/version/grouping.ts +96 -0
  261. package/src/version/records.ts +113 -0
  262. package/src/version/session.ts +90 -0
  263. package/src/version/summary.ts +117 -0
  264. package/src/workbook-manager.ts +1111 -0
  265. package/src/xlsx-export-worker.ts +34 -0
  266. package/src/xlsx-export.ts +373 -0
  267. package/src/xlsx-import-worker.ts +30 -0
@@ -0,0 +1,1111 @@
1
+ /**
2
+ * Node-side workbook repository for dsh-data-table.
3
+ *
4
+ * One named workbook = one Univer snapshot JSON file under the DSH home
5
+ * storage root (`<dshHome>/storages/data-table/<id>.json`; the pre-rename
6
+ * `storages/data-sheet` root is migrated once on first access). The agent tools
7
+ * address workbooks by id across sessions; the browser mounts a residency
8
+ * instance from the current snapshot and saves back in Version-sized batches.
9
+ * @module dsh-data-table/workbook-manager
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto'
13
+
14
+
15
+ import { join } from 'node:path'
16
+ import {
17
+ type WorkbookMeta,
18
+ type WorkbookOperation,
19
+ type WorkbookOperationPayload,
20
+ type WorkbookOperationType,
21
+ type WorkbookVersionStatus,
22
+ type WorkbookChange,
23
+ type WorkbookProposal,
24
+ type WorkbookWorktree,
25
+ type WorkbookWorktreeStatus,
26
+ } from './types.ts'
27
+ import { conflictPayload, type ConflictEntry, type ConflictPayload, type VersionKind } from '#contracts'
28
+ import type { LineageContext } from '#contracts'
29
+
30
+ import { DataSheetError } from './errors.ts'
31
+ import { publishWorkbookCollaboration } from './collaboration-hub.ts'
32
+ import { newWorktreeId, nextContinuationVersionId, normalizeWorkbookId, normalizeWorktreeId } from './contract/ids.ts'
33
+ import { operationSummary, workbookOperationSteps } from './contract/operations.ts'
34
+ import {
35
+ dshHome,
36
+ workbookCheckpointsPath,
37
+ } from './store/paths.ts'
38
+ import {
39
+ readWorkbook,
40
+ readWorkbookAtRevision,
41
+ readWorkbookWorktree,
42
+ readJsonFile,
43
+ isStoredCheckpoint,
44
+ } from './store/read.ts'
45
+ import {
46
+ withWorkbookQueue,
47
+ withWorktreeQueue,
48
+ writeWorkbook,
49
+ writeWorktree,
50
+ } from './store/write.ts'
51
+ import {
52
+ pruneAutoVersionOperations,
53
+ isIdlePast,
54
+ CHECKPOINT_INTERVAL,
55
+ } from './store/retention.ts'
56
+ // Re-exported façade (docs/issue/0022 §4): the store is now the home of this
57
+ // vocabulary; callers move to `store/` as their own phase lands.
58
+ export { workbookRoot, legacyWorkbookRoot, WorkbookStorageMigration, migrateLegacyWorkbookStorage, workbookPath, workbookIndexPath } from './store/paths.ts'
59
+ export { WorkbookIndex, WorkbookIndexOperation, WorkbookIndexProposal, readWorkbookIndex, readWorkbook, readWorkbookAtRevision, workbookExists, listWorkbooks, listWorkbookSummaries, listWorkbookWorktrees, readWorkbookWorktree, summarize } from './store/read.ts'
60
+ export { writeWorkbook, deleteWorkbook, renameWorkbook } from './store/write.ts'
61
+ import { applyWorkbookOperation, resolveOperationPayload, validateSnapshotBounds } from './contract/mutations.ts'
62
+ import { PROPOSAL_LOG_LIMIT } from './contract/operations.ts'
63
+ import { DIFF_CHANGE_LIMIT, collectDiffScopes, diffWorkbookSnapshots } from './version/diff.ts'
64
+ import { describeSnapshotReplace } from './version/summary.ts'
65
+ import {
66
+ finalizeOpenUserVersions,
67
+ finalizeVersion,
68
+ isOpenVersionStatus,
69
+ normalizeVersionKind,
70
+ normalizeVersionStatus,
71
+ optionalVersionText,
72
+ settleIdleUserVersions,
73
+ VERSION_STEP_LIMIT,
74
+ } from './version/session.ts'
75
+ import {
76
+ DEFAULT_SHEET_FONT_FAMILY,
77
+ asWorkbookSnapshot,
78
+ cloneSnapshot,
79
+ findDuplicateRows,
80
+ readWorkbookRange,
81
+ DEFAULT_SHEET_ROWS,
82
+ DEFAULT_SHEET_COLUMNS,
83
+ stableJson,
84
+ } from './contract/snapshots.ts'
85
+
86
+ // Re-exported façade (docs/issue/0022 §4): callers move to `contract/` as their own
87
+ // phase lands, so this module can be split without touching them.
88
+ export { newWorktreeId, normalizeWorkbookId, normalizeWorktreeId } from './contract/ids.ts'
89
+ export { workbookOperationSteps } from './contract/operations.ts'
90
+ export { findDuplicateRows, readWorkbookRange } from './contract/snapshots.ts'
91
+ export { applyWorkbookOperation } from './contract/mutations.ts'
92
+
93
+ // Re-exported façade (docs/issue/0022 §4): the version vocabulary now lives in
94
+ // `version/`; callers move there as their own phase lands.
95
+ export { describeSnapshotReplace } from './version/summary.ts'
96
+ export { settleIdleUserVersions } from './version/session.ts'
97
+ export { diffWorkbookSnapshots, type WorkbookDiffScope } from './version/diff.ts'
98
+
99
+
100
+ /** Expand a persisted operation into the ordered steps represented by one
101
+ * version. A normal operation is one step; a batch keeps each child visible
102
+ * for history and replay without introducing a second persistence model. */
103
+ /** Create a worktree from the current trunk snapshot without mutating trunk. */
104
+ export async function createWorkbookWorktree(args: {
105
+ workbookId: string
106
+ ownerSessionId: string
107
+ name?: string
108
+ worktreeId?: string
109
+ }): Promise<WorkbookWorktree> {
110
+ const workbookId = normalizeWorkbookId(args.workbookId)
111
+ const ownerSessionId = normalizeSessionId(args.ownerSessionId)
112
+ if (ownerSessionId === null) throw new DataSheetError('VALIDATION_ERROR', 'ownerSessionId 必须是非空字符串')
113
+ return withWorktreeQueue(workbookId, args.worktreeId ?? 'new', async () => {
114
+ const meta = await readWorkbook(workbookId)
115
+ if (meta === null) throw new DataSheetError('WORKBOOK_NOT_FOUND', `工作簿不存在:${workbookId}`, 404)
116
+ const id = normalizeWorktreeId(args.worktreeId ?? newWorktreeId())
117
+ const existing = await readWorkbookWorktree(workbookId, id)
118
+ if (existing !== null) throw new DataSheetError('VALIDATION_ERROR', `worktree 已存在:${id}`, 409)
119
+ const timestamp = new Date().toISOString()
120
+ const worktree: WorkbookWorktree = {
121
+ id,
122
+ workbookId,
123
+ ownerSessionId,
124
+ name: args.name?.trim() || `${meta.name} 草稿`,
125
+ baseRevision: meta.revision ?? 0,
126
+ revision: 0,
127
+ status: 'draft',
128
+ createdAt: timestamp,
129
+ updatedAt: timestamp,
130
+ snapshot: cloneSnapshot(meta.snapshot),
131
+ operations: [],
132
+ }
133
+ await writeWorktree(worktree)
134
+ return worktree
135
+ })
136
+ }
137
+
138
+ /** Apply an operation inside a worktree; trunk remains unchanged. */
139
+ export async function mutateWorkbookWorktree(args: {
140
+ workbookId: string
141
+ worktreeId: string
142
+ ownerSessionId: string
143
+ actor: 'agent' | 'user'
144
+ type: WorkbookOperationType
145
+ payload: WorkbookOperationPayload
146
+ expectedRevision?: number
147
+ opId?: string
148
+ lineage?: LineageContext
149
+ }): Promise<{ worktree: WorkbookWorktree; operation: WorkbookOperation }> {
150
+ const workbookId = normalizeWorkbookId(args.workbookId)
151
+ return withWorktreeQueue(workbookId, args.worktreeId, async () => {
152
+ const worktree = await readWorkbookWorktree(workbookId, args.worktreeId)
153
+ assertWorktreeOwner(worktree, args.ownerSessionId)
154
+ if (worktree.status !== 'draft') throw new DataSheetError('WORKTREE_STATE_INVALID', `worktree 当前状态为 ${worktree.status},不能修改`)
155
+ const revision = worktree.revision
156
+ if (args.expectedRevision !== undefined && args.expectedRevision !== revision) {
157
+ throw new WorkbookRevisionConflictError(args.expectedRevision, revision, undefined, [], workbookId)
158
+ }
159
+ const operation: WorkbookOperation = {
160
+ opId: args.opId ?? `worktree:${worktree.id}:${randomUUID()}`,
161
+ workbookId,
162
+ revision: revision + 1,
163
+ parentRevision: revision,
164
+ baseRevision: args.expectedRevision ?? revision,
165
+ actor: args.actor,
166
+ type: args.type,
167
+ timestamp: new Date().toISOString(),
168
+ lineage: args.lineage,
169
+ payload: resolveOperationPayload(worktree.snapshot, args.type, args.payload),
170
+ }
171
+ worktree.snapshot = applyWorkbookOperation(worktree.snapshot, operation)
172
+ worktree.revision = operation.revision
173
+ worktree.updatedAt = operation.timestamp
174
+ worktree.operations = [...worktree.operations, operation].slice(-200)
175
+ await writeWorktree(worktree)
176
+ return { worktree, operation }
177
+ })
178
+ }
179
+
180
+ /** Move a worktree between review states without changing trunk data. */
181
+ export async function setWorkbookWorktreeStatus(args: {
182
+ workbookId: string
183
+ worktreeId: string
184
+ ownerSessionId: string
185
+ status: 'ready' | 'reopen' | 'discarded'
186
+ }): Promise<WorkbookWorktree> {
187
+ const workbookId = normalizeWorkbookId(args.workbookId)
188
+ return withWorktreeQueue(workbookId, args.worktreeId, async () => {
189
+ const worktree = await readWorkbookWorktree(workbookId, args.worktreeId)
190
+ assertWorktreeOwner(worktree, args.ownerSessionId)
191
+ const nextStatus: WorkbookWorktreeStatus = args.status === 'reopen' ? 'draft' : args.status
192
+ if (worktree.status === 'merged' || worktree.status === 'discarded') throw new DataSheetError('WORKTREE_STATE_INVALID', `worktree 当前状态为 ${worktree.status},不能再次操作`)
193
+ if (nextStatus === 'ready' && worktree.status !== 'draft') throw new DataSheetError('WORKTREE_STATE_INVALID', `worktree 当前状态为 ${worktree.status},不能进入 ready`)
194
+ if (nextStatus === 'ready' && worktree.revision === 0) throw new DataSheetError('WORKTREE_STATE_INVALID', '空 worktree 不能进入 ready 状态')
195
+ if (nextStatus === 'draft' && worktree.status !== 'ready') throw new DataSheetError('WORKTREE_STATE_INVALID', `worktree 当前状态为 ${worktree.status},不能重新打开`)
196
+ worktree.status = nextStatus
197
+ worktree.updatedAt = new Date().toISOString()
198
+ await writeWorktree(worktree)
199
+ return worktree
200
+ })
201
+ }
202
+
203
+ /** Merge a ready worktree into trunk as one atomic snapshot replacement. */
204
+ export async function mergeWorkbookWorktree(args: {
205
+ workbookId: string
206
+ worktreeId: string
207
+ ownerSessionId: string
208
+ actor?: 'agent' | 'user'
209
+ expectedRevision?: number
210
+ }): Promise<{ worktree: WorkbookWorktree; mutation: WorkbookMutationResult }> {
211
+ const workbookId = normalizeWorkbookId(args.workbookId)
212
+ return withWorktreeQueue(workbookId, args.worktreeId, async () => {
213
+ const worktree = await readWorkbookWorktree(workbookId, args.worktreeId)
214
+ assertWorktreeOwner(worktree, args.ownerSessionId)
215
+ if (worktree.status !== 'ready') throw new DataSheetError('WORKTREE_STATE_INVALID', `worktree 当前状态为 ${worktree.status},只有 ready 可以合并`)
216
+ const mutation = await mutateWorkbook({
217
+ workbookId,
218
+ actor: args.actor ?? 'user',
219
+ type: 'snapshot_replace',
220
+ payload: { snapshot: worktree.snapshot },
221
+ expectedRevision: args.expectedRevision ?? worktree.baseRevision,
222
+ opId: `worktree-merge:${worktree.id}:${worktree.revision}`,
223
+ })
224
+ const latest = await readWorkbookWorktree(workbookId, worktree.id)
225
+ assertWorktreeOwner(latest, args.ownerSessionId)
226
+ latest.status = 'merged'
227
+ latest.updatedAt = new Date().toISOString()
228
+ await writeWorktree(latest)
229
+ return { worktree: latest, mutation }
230
+ })
231
+ }
232
+
233
+ function normalizeSessionId(value: string): string | null {
234
+ const id = value.trim()
235
+ return id.length > 0 && id.length <= 256 ? id : null
236
+ }
237
+
238
+ function assertWorktreeOwner(worktree: WorkbookWorktree | null, ownerSessionId: string): asserts worktree is WorkbookWorktree {
239
+ if (worktree === null) throw new DataSheetError('WORKTREE_NOT_FOUND', 'worktree 不存在', 404)
240
+ if (normalizeSessionId(ownerSessionId) !== worktree.ownerSessionId) {
241
+ throw new DataSheetError('SESSION_SCOPE_DENIED', '当前 session 无权访问该 worktree', 403)
242
+ }
243
+ }
244
+
245
+ /** Create an empty workbook snapshot (one blank default sheet). */
246
+ export function blankSnapshot(name: string): unknown {
247
+ return {
248
+ id: 'default',
249
+ name,
250
+ sheetOrder: ['sheet1'],
251
+ sheets: {
252
+ sheet1: {
253
+ id: 'sheet1',
254
+ name: 'sheet1',
255
+ rowCount: DEFAULT_SHEET_ROWS,
256
+ columnCount: DEFAULT_SHEET_COLUMNS,
257
+ defaultStyle: { ff: DEFAULT_SHEET_FONT_FAMILY },
258
+ cellData: {},
259
+ },
260
+ },
261
+ }
262
+ }
263
+
264
+ /** A failed optimistic-concurrency check, surfaced as a model-facing error. */
265
+ export class WorkbookRevisionConflictError extends Error {
266
+ /** Unified cross-plugin 409 body (PRD §5.1). */
267
+ readonly payload: ConflictPayload
268
+
269
+ constructor(
270
+ readonly expected: number,
271
+ readonly actual: number,
272
+ message?: string,
273
+ readonly conflictSet: ConflictEntry[] = [],
274
+ readonly workbookId = '',
275
+ ) {
276
+ const text = message ?? `工作簿版本已变化:请求基于 revision ${expected},当前是 ${actual};请先重新读取工作簿`
277
+ super(text)
278
+ this.name = 'WorkbookRevisionConflictError'
279
+ this.payload = conflictPayload({
280
+ artifactKind: 'workbook',
281
+ artifactId: workbookId,
282
+ baseRevision: expected,
283
+ currentRevision: actual,
284
+ conflictSet,
285
+ message: text,
286
+ nextAction: '重新读取工作簿后重试;需要保留本地修改时先查看差异或提交合并提案',
287
+ })
288
+ }
289
+ }
290
+
291
+ /** Describe the operations a caller missed as conflict-set entries. */
292
+ function conflictingOperations(operations: WorkbookOperation[]): ConflictEntry[] {
293
+ return operations.slice(-20).map(operation => ({
294
+ kind: 'content' as const,
295
+ target: `r${operation.revision}`,
296
+ description: operationSummary(operation),
297
+ remote: operation.type,
298
+ }))
299
+ }
300
+
301
+ export interface WorkbookMutationResult {
302
+ meta: WorkbookMeta
303
+ operation: WorkbookOperation
304
+ /** Operations observed after the caller's base revision when it was rebased. */
305
+ rebasedOperations?: WorkbookOperation[]
306
+ /** True when the server accepted a safe concurrent cell edit. */
307
+ transformed?: boolean
308
+ }
309
+
310
+ export interface WorkbookMutationPlanStep {
311
+ type: WorkbookOperationType
312
+ payload: WorkbookOperationPayload
313
+ opId?: string
314
+ versionId?: string
315
+ versionSummary?: string
316
+ versionKind?: VersionKind
317
+ versionIntent?: string
318
+ agentRunId?: string
319
+ lineage?: LineageContext
320
+ }
321
+
322
+ export interface WorkbookMutationPlanResult {
323
+ meta: WorkbookMeta
324
+ operations: WorkbookOperation[]
325
+ /** True when every step was already committed and the request was replayed. */
326
+ replayed?: boolean
327
+ /** True when the plan was validated in memory only and nothing was persisted. */
328
+ dryRun?: boolean
329
+ /** Operations observed after the caller's base revision when the plan was rebased. */
330
+ rebasedOperations?: WorkbookOperation[]
331
+ /** True when the plan was accepted on top of a newer revision instead of 409ing. */
332
+ transformed?: boolean
333
+ }
334
+
335
+ export interface WorkbookProposalResult {
336
+ meta: WorkbookMeta
337
+ proposal: WorkbookProposal
338
+ }
339
+
340
+ /** Apply and persist one operation, incrementing the workbook revision. */
341
+ /**
342
+ * Fingerprint of one mutation request. The single-operation path and the plan
343
+ * path both compare it, so reusing an `opId` with different content is rejected
344
+ * instead of silently replaying the earlier write.
345
+ */
346
+ function requestFingerprint(fields: {
347
+ actor: 'agent' | 'user'
348
+ type: WorkbookOperationType
349
+ payload: WorkbookOperationPayload
350
+ versionId?: string
351
+ // Keep the caller's wire value in the fingerprint. That lets an old browser
352
+ // safely retry a previously accepted `temporary` request while the durable
353
+ // record itself is normalized to `open`.
354
+ versionStatus?: WorkbookVersionStatus
355
+ versionSummary?: string
356
+ versionKind?: VersionKind
357
+ versionIntent?: string
358
+ agentRunId?: string
359
+ lineage?: LineageContext
360
+ }): string {
361
+ return stableJson({
362
+ actor: fields.actor,
363
+ type: fields.type,
364
+ payload: fields.payload,
365
+ // Keep these two base fields present even when undefined so request hashes
366
+ // stay compatible with operation logs written before semantic Version
367
+ // metadata existed.
368
+ versionId: fields.versionId,
369
+ versionStatus: fields.versionStatus,
370
+ ...(fields.versionSummary === undefined ? {} : { versionSummary: fields.versionSummary }),
371
+ // Added in v3; omitted when absent so pre-v3 retries keep their hash.
372
+ ...(fields.versionKind === undefined ? {} : { versionKind: fields.versionKind }),
373
+ ...(fields.versionIntent === undefined ? {} : { versionIntent: fields.versionIntent }),
374
+ ...(fields.agentRunId === undefined ? {} : { agentRunId: fields.agentRunId }),
375
+ ...(fields.lineage === undefined ? {} : { lineage: fields.lineage }),
376
+ })
377
+ }
378
+
379
+ /** Fingerprint of an already committed operation, including logs written before
380
+ * `requestHash` was persisted. */
381
+ function storedRequestFingerprint(operation: WorkbookOperation): string {
382
+ return operation.requestHash ?? requestFingerprint({
383
+ actor: operation.actor,
384
+ type: operation.type,
385
+ payload: operation.payload,
386
+ versionId: operation.versionId,
387
+ versionStatus: operation.versionStatus,
388
+ versionSummary: operation.versionSummary,
389
+ versionKind: operation.versionKind,
390
+ versionIntent: operation.versionIntent,
391
+ agentRunId: operation.agentRunId,
392
+ lineage: operation.lineage,
393
+ })
394
+ }
395
+
396
+ export async function mutateWorkbook(args: {
397
+ workbookId: string
398
+ actor: 'agent' | 'user'
399
+ type: WorkbookOperationType
400
+ payload: WorkbookOperationPayload
401
+ expectedRevision?: number
402
+ opId?: string
403
+ createIfMissing?: boolean
404
+ /** Join an existing open logical Version (browser user saves). */
405
+ versionId?: string
406
+ versionStatus?: WorkbookVersionStatus
407
+ /** Host-owned audit metadata for a semantic Agent Version. */
408
+ versionSummary?: string
409
+ /** v3 tier; omitted by browser user edits, which are auto checkpoints. */
410
+ versionKind?: VersionKind
411
+ versionIntent?: string
412
+ agentRunId?: string
413
+ lineage?: LineageContext
414
+ }): Promise<WorkbookMutationResult> {
415
+ const workbookId = normalizeWorkbookId(args.workbookId)
416
+ return withWorkbookQueue(workbookId, async () => {
417
+ let meta = await readWorkbook(args.workbookId)
418
+ if (meta === null) {
419
+ if (!args.createIfMissing) throw new Error(`工作簿不存在:${args.workbookId}`)
420
+ meta = {
421
+ id: workbookId,
422
+ name: workbookId,
423
+ sheetOrder: ['sheet1'],
424
+ updatedAt: new Date().toISOString(),
425
+ revision: 0,
426
+ operations: [],
427
+ snapshot: blankSnapshot(workbookId),
428
+ }
429
+ }
430
+ // Never mutate the cache object before all files are durably committed.
431
+ meta = cloneSnapshot(meta) as WorkbookMeta
432
+ const revision = meta.revision ?? 0
433
+ validateOpId(args.opId)
434
+ const versionId = args.versionId === undefined ? undefined : args.versionId.trim()
435
+ if (versionId !== undefined && versionId.length === 0) throw new Error('versionId 必须是非空字符串')
436
+ if (versionId !== undefined && versionId.length > 240) throw new Error('versionId 最多 240 个字符')
437
+ const versionSummary = optionalVersionText(args.versionSummary, 'versionSummary', 280)
438
+ const versionKind = normalizeVersionKind(args.versionKind)
439
+ const versionIntent = optionalVersionText(args.versionIntent, 'versionIntent', 1000)
440
+ const agentRunId = optionalVersionText(args.agentRunId, 'agentRunId', 160)
441
+ const lineage = args.lineage
442
+ const requestedVersionStatus = normalizeVersionStatus(args.versionStatus)
443
+ const payload = resolveOperationPayload(meta.snapshot, args.type, args.payload)
444
+ const requestHash = requestFingerprint({
445
+ actor: args.actor,
446
+ type: args.type,
447
+ payload,
448
+ versionId,
449
+ versionStatus: args.versionStatus,
450
+ versionSummary,
451
+ versionKind,
452
+ versionIntent,
453
+ agentRunId,
454
+ lineage,
455
+ })
456
+ // Idempotency is checked before the revision gate. A retried request may
457
+ // legitimately carry an old base revision after its first attempt was
458
+ // committed but its response was lost.
459
+ const existing = args.opId === undefined
460
+ ? undefined
461
+ : meta.operations?.find(operation => operation.opId === args.opId)
462
+ if (existing !== undefined) {
463
+ const existingHash = storedRequestFingerprint(existing)
464
+ if (existingHash !== requestHash) {
465
+ throw new Error(`opId 已被其他请求使用:${args.opId}`)
466
+ }
467
+ return { meta, operation: existing }
468
+ }
469
+
470
+ expireStaleProposals(meta, revision)
471
+
472
+ // An open user Version stays open until it reaches ten steps. A
473
+ // later user save joins the same logical Version; an Agent write closes
474
+ // any open user Version first. A semantic Agent Version supplies its own
475
+ // Host-owned id and persists all of its child commands as one batch.
476
+ let logicalVersionId = versionId
477
+ let logicalVersionStatus: WorkbookVersionStatus = requestedVersionStatus ?? 'final'
478
+ if (args.actor === 'agent') {
479
+ finalizeOpenUserVersions(meta)
480
+ logicalVersionStatus = 'final'
481
+ } else if (args.type === 'snapshot_replace' && (requestedVersionStatus === 'final' || versionSummary !== undefined)) {
482
+ // A named save or an import is a deliberate, self-contained Version: it
483
+ // captures the whole workbook and closes whatever session was open.
484
+ finalizeOpenUserVersions(meta)
485
+ logicalVersionId = undefined
486
+ logicalVersionStatus = 'final'
487
+ } else if (args.actor === 'user') {
488
+ // Every other user save — including a whole-snapshot save, which is how
489
+ // the browser persists most edits — is one Step of the editing session
490
+ // (PRD v3.1 §4). It used to finalize the Version and start a new one on
491
+ // every save, which is what produced 220 Versions of 「替换 workbook 快照」.
492
+ if (logicalVersionId === undefined) {
493
+ const open = (meta.operations ?? [])
494
+ .slice()
495
+ .reverse()
496
+ .find(operation => isOpenVersionStatus(operation.versionStatus) && operation.actor === 'user')
497
+ logicalVersionId = open?.versionId
498
+ }
499
+ if (logicalVersionId === undefined) {
500
+ logicalVersionId = nextContinuationVersionId(workbookId, `session:${randomUUID()}`)
501
+ }
502
+ logicalVersionStatus = 'open'
503
+ }
504
+ if (args.actor === 'user' && logicalVersionId !== undefined) {
505
+ const sameVersion = (meta.operations ?? []).filter(operation => operation.versionId === logicalVersionId)
506
+ const latest = sameVersion.at(-1)
507
+ // A pause longer than the merge window closes the open Version, so a new
508
+ // burst of typing becomes its own user-visible version (PRD §4.1).
509
+ if (latest !== undefined && isOpenVersionStatus(latest.versionStatus) && isIdlePast(latest.timestamp)) {
510
+ finalizeVersion(meta, logicalVersionId)
511
+ logicalVersionId = nextContinuationVersionId(workbookId, logicalVersionId)
512
+ } else if (latest !== undefined && !isOpenVersionStatus(latest.versionStatus)) {
513
+ // An Agent (or a full snapshot save) may have finalized the Version
514
+ // while this browser still had local steps in flight. Do not turn
515
+ // that benign race into a save failure; fork the unsent steps into a
516
+ // fresh logical Version and return its id so the client can continue
517
+ // joining it on the next save.
518
+ logicalVersionId = nextContinuationVersionId(workbookId, logicalVersionId)
519
+ }
520
+ const existingStepCount = logicalVersionId === versionId
521
+ ? sameVersion.reduce((total, operation) => total + workbookOperationSteps(operation).length, 0)
522
+ : 0
523
+ const incomingStepCount = args.type === 'batch' ? Math.max(1, payload.operations?.length ?? 0) : 1
524
+ if (existingStepCount + incomingStepCount > VERSION_STEP_LIMIT) {
525
+ throw new Error(`Version ${logicalVersionId} 最多 ${VERSION_STEP_LIMIT} 个 step,请拆分后再提交`)
526
+ }
527
+ logicalVersionStatus = existingStepCount + incomingStepCount >= VERSION_STEP_LIMIT ? 'final' : 'open'
528
+ }
529
+
530
+ let rebasedOperations: WorkbookOperation[] | undefined
531
+ let transformed = false
532
+ if (args.expectedRevision !== undefined && args.expectedRevision !== revision) {
533
+ const intervening = operationsSinceRevision(meta.operations ?? [], args.expectedRevision, revision)
534
+ if (intervening === null || !canTransformCellEdit(args.type, args.payload, intervening)) {
535
+ throw new WorkbookRevisionConflictError(
536
+ args.expectedRevision,
537
+ revision,
538
+ '工作簿存在无法自动转换的并发修改;请重新读取后重试或提交合并提案',
539
+ conflictingOperations(intervening ?? operationsSinceRevision(meta.operations ?? [], args.expectedRevision, revision) ?? []),
540
+ workbookId,
541
+ )
542
+ }
543
+ rebasedOperations = intervening
544
+ transformed = true
545
+ }
546
+
547
+ // Resolve the rows once at the mutation authority. The original row
548
+ // indexes are retained in the operation so every browser projection and
549
+ // replay removes exactly the same rows, even if duplicate values change
550
+ // later in the workbook.
551
+ const nextRevision = revision + 1
552
+ const operation: WorkbookOperation = {
553
+ opId: args.opId ?? randomUUID(),
554
+ workbookId,
555
+ revision: nextRevision,
556
+ parentRevision: revision,
557
+ baseRevision: args.expectedRevision ?? revision,
558
+ versionId: logicalVersionId,
559
+ versionStatus: logicalVersionStatus,
560
+ versionSummary,
561
+ versionKind,
562
+ versionIntent,
563
+ agentRunId,
564
+ lineage,
565
+ actor: args.actor,
566
+ type: args.type,
567
+ timestamp: new Date().toISOString(),
568
+ payload,
569
+ ...(args.type === 'snapshot_replace' && payload.snapshot !== undefined
570
+ ? { stepSummary: describeSnapshotReplace(meta.snapshot, payload.snapshot) }
571
+ : {}),
572
+ requestHash,
573
+ }
574
+ // `applyWorkbookOperation` mutates normal snapshots in place. Clone only
575
+ // at a checkpoint boundary, otherwise the old implementation stored the
576
+ // already-mutated object as the checkpoint and serialized it repeatedly.
577
+ const shouldCheckpoint = revision === 0 || nextRevision % CHECKPOINT_INTERVAL === 0 || operation.type === 'snapshot_replace'
578
+ if (shouldCheckpoint && (meta.checkpoints ?? []).length === 0) {
579
+ const stored = await readJsonFile(workbookCheckpointsPath(workbookId)) as { checkpoints?: unknown } | null
580
+ if (Array.isArray(stored?.checkpoints)) meta.checkpoints = stored.checkpoints.filter(checkpoint => isStoredCheckpoint(checkpoint))
581
+ }
582
+ const previousSnapshot = shouldCheckpoint ? cloneSnapshot(meta.snapshot) : null
583
+ meta.snapshot = applyWorkbookOperation(meta.snapshot, operation)
584
+ // Keep the metadata index aligned with structural sheet operations. The
585
+ // snapshot is authoritative, but callers also read this lightweight list.
586
+ meta.sheetOrder = asWorkbookSnapshot(meta.snapshot).sheetOrder.slice()
587
+ meta.revision = nextRevision
588
+ meta.updatedAt = operation.timestamp
589
+ expireStaleProposals(meta, nextRevision)
590
+ // Keep the complete operation payload so every Version remains replayable.
591
+ // Snapshot replacements are less frequent but are still first-class steps
592
+ // in the simple version model.
593
+ const logOperation = operation
594
+ meta.operations = [...(meta.operations ?? []), logOperation]
595
+ meta.checkpoints = compactCheckpoints(
596
+ previousSnapshot === null
597
+ ? (meta.checkpoints ?? [])
598
+ : [...(meta.checkpoints ?? []), { revision, actor: args.actor, timestamp: operation.timestamp, snapshot: previousSnapshot }],
599
+ )
600
+ pruneAutoVersionOperations(meta)
601
+ await writeWorkbook(meta, { persistSnapshot: shouldCheckpoint, persistCheckpoints: shouldCheckpoint })
602
+ publishWorkbookCollaboration(logOperation)
603
+ return { meta, operation: logOperation, rebasedOperations, transformed }
604
+ })
605
+ }
606
+
607
+ /**
608
+ * Commit a prepared Agent edit plan as one durable multi-file transaction.
609
+ *
610
+ * `workbook_edit` can contain several semantic Versions. The old implementation
611
+ * committed each Version independently, so a disk error or process exit could
612
+ * leave a partially applied request. This path applies all prepared Versions
613
+ * to one cloned metadata object and publishes them only after the single
614
+ * workbook transaction is durable.
615
+ *
616
+ * `dryRun` runs the exact same validation pipeline — opId checks, payload
617
+ * resolution, `applyWorkbookOperation` and revision accounting — and returns
618
+ * before the durable write. Sharing one path is what keeps a passing preview
619
+ * from failing on commit.
620
+ */
621
+ /**
622
+ * Fingerprint of one plan step exactly as the caller submitted it. The payload
623
+ * is resolved the same way the commit path resolves it, so the comparison sees
624
+ * the bytes that were stored.
625
+ */
626
+ function planStepFingerprint(meta: WorkbookMeta, actor: 'agent' | 'user', step: WorkbookMutationPlanStep): string {
627
+ return requestFingerprint({
628
+ actor,
629
+ type: step.type,
630
+ payload: resolveOperationPayload(meta.snapshot, step.type, step.payload),
631
+ versionId: step.versionId?.trim(),
632
+ versionStatus: 'final',
633
+ versionSummary: optionalVersionText(step.versionSummary, 'versionSummary', 280),
634
+ versionKind: normalizeVersionKind(step.versionKind),
635
+ versionIntent: optionalVersionText(step.versionIntent, 'versionIntent', 1000),
636
+ agentRunId: optionalVersionText(step.agentRunId, 'agentRunId', 160),
637
+ lineage: step.lineage,
638
+ })
639
+ }
640
+
641
+ export async function mutateWorkbookPlan(args: {
642
+ workbookId: string
643
+ actor: 'agent'
644
+ steps: WorkbookMutationPlanStep[]
645
+ expectedRevision?: number
646
+ dryRun?: boolean
647
+ }): Promise<WorkbookMutationPlanResult> {
648
+ const workbookId = normalizeWorkbookId(args.workbookId)
649
+ return withWorkbookQueue(workbookId, async () => {
650
+ if (args.steps.length === 0 || args.steps.length > 20) throw new Error('mutation plan 必须是 1 到 20 个 Version')
651
+ const current = await readWorkbook(workbookId)
652
+ if (current === null) throw new Error(`工作簿不存在:${args.workbookId}`)
653
+ const currentRevision = current.revision ?? 0
654
+ // A retry after a lost response is valid only when the complete plan was
655
+ // committed. A partially present plan is intentionally rejected instead
656
+ // of guessing which steps may be safe to replay.
657
+ const requestedIds = args.steps.map(step => step.opId).filter((opId): opId is string => opId !== undefined)
658
+ const existingById = new Map((current.operations ?? []).filter(operation => requestedIds.includes(operation.opId)).map(operation => [operation.opId, operation]))
659
+ if (existingById.size > 0 && existingById.size !== requestedIds.length) throw new Error('mutation plan 已部分提交;请先读取 workbook_history 再决定是否继续')
660
+ if (requestedIds.length === args.steps.length && existingById.size === requestedIds.length) {
661
+ // A replayed opId is only valid for the same request. Without this check
662
+ // a retry that changed its content would silently no-op and still report
663
+ // success — and a file import would even return a receipt describing a
664
+ // file that was never written.
665
+ for (const step of args.steps) {
666
+ const existing = existingById.get(step.opId!)!
667
+ if (storedRequestFingerprint(existing) !== planStepFingerprint(current, args.actor, step)) {
668
+ throw new Error(`opId 已被其他请求使用:${step.opId}`)
669
+ }
670
+ }
671
+ return { meta: current, operations: args.steps.map(step => existingById.get(step.opId!)!), replayed: true }
672
+ }
673
+ // The plan path used to reject any revision drift outright, which made an
674
+ // Agent plan fail with a bare 409 whenever a browser save landed between
675
+ // `workbook_read` and `workbook_edit`. It now accepts the same narrow
676
+ // transform as the single-operation path: when the caller only missed cell
677
+ // writes and every step is itself a cell write, the declarative plan (which
678
+ // is still entirely unwritten) is applied on top of the current revision.
679
+ // Structural steps keep failing, because their coordinates cannot be
680
+ // transformed here.
681
+ let rebasedOperations: WorkbookOperation[] | undefined
682
+ let transformed = false
683
+ if (args.expectedRevision !== undefined && args.expectedRevision !== currentRevision) {
684
+ const intervening = operationsSinceRevision(current.operations ?? [], args.expectedRevision, currentRevision)
685
+ if (intervening === null || !canTransformPlanCellEdits(args.steps, intervening)) {
686
+ throw new WorkbookRevisionConflictError(
687
+ args.expectedRevision,
688
+ currentRevision,
689
+ '工作簿存在并发修改;请重新读取后重试整个编辑计划',
690
+ conflictingOperations(intervening ?? operationsSinceRevision(current.operations ?? [], args.expectedRevision, currentRevision) ?? []),
691
+ args.workbookId,
692
+ )
693
+ }
694
+ rebasedOperations = intervening
695
+ transformed = true
696
+ }
697
+
698
+ const meta = cloneSnapshot(current) as WorkbookMeta
699
+ finalizeOpenUserVersions(meta)
700
+ let revision = currentRevision
701
+ const committed: WorkbookOperation[] = []
702
+ for (const step of args.steps) {
703
+ validateOpId(step.opId)
704
+ const versionId = step.versionId?.trim()
705
+ if (versionId !== undefined && (versionId.length === 0 || versionId.length > 240)) throw new Error('versionId 必须是 1 到 240 个字符')
706
+ const versionSummary = optionalVersionText(step.versionSummary, 'versionSummary', 280)
707
+ const versionKind = normalizeVersionKind(step.versionKind)
708
+ const versionIntent = optionalVersionText(step.versionIntent, 'versionIntent', 1000)
709
+ const agentRunId = optionalVersionText(step.agentRunId, 'agentRunId', 160)
710
+ const lineage = step.lineage
711
+ const payload = resolveOperationPayload(meta.snapshot, step.type, step.payload)
712
+ const requestHash = requestFingerprint({
713
+ actor: args.actor,
714
+ type: step.type,
715
+ payload,
716
+ versionId,
717
+ versionStatus: 'final',
718
+ versionSummary,
719
+ versionKind,
720
+ versionIntent,
721
+ agentRunId,
722
+ lineage,
723
+ })
724
+ const operation: WorkbookOperation = {
725
+ opId: step.opId ?? randomUUID(),
726
+ workbookId,
727
+ revision: revision + 1,
728
+ parentRevision: revision,
729
+ baseRevision: revision,
730
+ versionId,
731
+ versionStatus: 'final',
732
+ versionSummary,
733
+ versionKind,
734
+ versionIntent,
735
+ agentRunId,
736
+ lineage,
737
+ actor: args.actor,
738
+ type: step.type,
739
+ timestamp: new Date().toISOString(),
740
+ payload,
741
+ requestHash,
742
+ }
743
+ const shouldCheckpoint = revision === 0 || operation.revision % CHECKPOINT_INTERVAL === 0 || operation.type === 'snapshot_replace'
744
+ const previousSnapshot = shouldCheckpoint ? cloneSnapshot(meta.snapshot) : null
745
+ meta.snapshot = applyWorkbookOperation(meta.snapshot, operation)
746
+ revision = operation.revision
747
+ meta.sheetOrder = asWorkbookSnapshot(meta.snapshot).sheetOrder.slice()
748
+ meta.revision = revision
749
+ meta.updatedAt = operation.timestamp
750
+ expireStaleProposals(meta, revision)
751
+ meta.operations = [...(meta.operations ?? []), operation]
752
+ meta.checkpoints = compactCheckpoints(previousSnapshot === null ? (meta.checkpoints ?? []) : [...(meta.checkpoints ?? []), { revision: revision - 1, actor: args.actor, timestamp: operation.timestamp, snapshot: previousSnapshot }])
753
+ committed.push(operation)
754
+ }
755
+ if (args.dryRun === true) return { meta, operations: committed, dryRun: true, ...(rebasedOperations === undefined ? {} : { rebasedOperations, transformed }) }
756
+ pruneAutoVersionOperations(meta)
757
+ await writeWorkbook(meta, { persistSnapshot: true, persistCheckpoints: true })
758
+ for (const operation of committed) publishWorkbookCollaboration(operation)
759
+ return { meta, operations: committed, ...(rebasedOperations === undefined ? {} : { rebasedOperations, transformed }) }
760
+ })
761
+ }
762
+
763
+ /**
764
+ * Return the durable operation window needed to rebase a request. `null`
765
+ * means the tail has already been compacted, so accepting the request would
766
+ * be unsafe because the server can no longer prove what changed in between.
767
+ */
768
+ function operationsSinceRevision(operations: WorkbookOperation[], baseRevision: number, currentRevision: number): WorkbookOperation[] | null {
769
+ if (baseRevision >= currentRevision) return []
770
+ const ordered = operations.filter(operation => operation.revision > baseRevision && operation.revision <= currentRevision).sort((left, right) => left.revision - right.revision)
771
+ if (ordered.length === 0 || ordered[0]?.revision !== baseRevision + 1 || ordered[ordered.length - 1]?.revision !== currentRevision) return null
772
+ for (let index = 1; index < ordered.length; index += 1) {
773
+ if (ordered[index]?.revision !== (ordered[index - 1]?.revision ?? 0) + 1) return null
774
+ }
775
+ return ordered
776
+ }
777
+
778
+ /** Mark proposals based on an older revision as stale instead of leaving them
779
+ * visible forever and guaranteed to fail on commit. */
780
+ function expireStaleProposals(meta: WorkbookMeta, currentRevision: number): void {
781
+ const timestamp = new Date().toISOString()
782
+ meta.proposals = (meta.proposals ?? []).map(proposal => proposal.status === 'pending' && proposal.baseRevision < currentRevision
783
+ ? { ...proposal, status: 'expired', updatedAt: timestamp }
784
+ : proposal)
785
+ }
786
+
787
+ /** Collect cell coordinates for the intentionally narrow safe transform. */
788
+ function collectCellKeys(type: WorkbookOperationType, payload: WorkbookOperationPayload, target: Set<string>): boolean {
789
+ if (type === 'update_cells') {
790
+ const sheetId = payload.sheetId ?? payload.sheetName
791
+ if (sheetId === undefined) return false
792
+ for (const cell of payload.cells ?? []) target.add(`${sheetId}:${cell.row}:${cell.column}`)
793
+ return (payload.cells ?? []).length > 0
794
+ }
795
+ if (type === 'batch') {
796
+ const operations = payload.operations ?? []
797
+ return operations.length > 0 && operations.every(operation => collectCellKeys(operation.type, operation.payload, target))
798
+ }
799
+ return false
800
+ }
801
+
802
+ /**
803
+ * A small OT-like transform for cell writes. The Host is the single mutation
804
+ * authority and replays the accepted operation against the latest snapshot,
805
+ * so the revision order is the conflict rule: the later committed cell write
806
+ * wins, including when both writers touched the same coordinate. Structural
807
+ * edits, formatting and snapshot replacements remain strict conflicts because
808
+ * their coordinates or workbook shape cannot be safely transformed here.
809
+ */
810
+ function canTransformCellEdit(type: WorkbookOperationType, payload: WorkbookOperationPayload, intervening: WorkbookOperation[]): boolean {
811
+ const incoming = new Set<string>()
812
+ if (!collectCellKeys(type, payload, incoming)) return false
813
+ for (const operation of intervening) {
814
+ const interveningCells = new Set<string>()
815
+ if (!collectCellKeys(operation.type, operation.payload, interveningCells)) return false
816
+ }
817
+ return true
818
+ }
819
+
820
+ /**
821
+ * Plan-level counterpart of `canTransformCellEdit`: every step in the plan must
822
+ * be a pure cell write before the whole plan may ride on a newer revision. One
823
+ * structural or formatting step is enough to keep the strict conflict, because
824
+ * the surrounding row/column coordinates would have moved underneath it.
825
+ */
826
+ function canTransformPlanCellEdits(steps: WorkbookMutationPlanStep[], intervening: WorkbookOperation[]): boolean {
827
+ if (steps.length === 0) return false
828
+ return steps.every(step => canTransformCellEdit(step.type, step.payload, intervening))
829
+ }
830
+
831
+ /** Build and persist a durable proposal without changing the workbook revision. */
832
+ export async function previewWorkbookChanges(args: {
833
+ workbookId: string
834
+ actor: 'agent' | 'user'
835
+ operations: Array<{ type: WorkbookOperationType; payload: WorkbookOperationPayload }>
836
+ expectedRevision?: number
837
+ proposalId?: string
838
+ summary?: string
839
+ }): Promise<WorkbookProposalResult> {
840
+ const workbookId = normalizeWorkbookId(args.workbookId)
841
+ return withWorkbookQueue(workbookId, async () => {
842
+ let meta = await readWorkbook(workbookId)
843
+ if (meta === null) throw new Error(`工作簿不存在:${args.workbookId}`)
844
+ meta = cloneSnapshot(meta) as WorkbookMeta
845
+ const baseRevision = meta.revision ?? 0
846
+ expireStaleProposals(meta, baseRevision)
847
+ if (args.expectedRevision !== undefined && args.expectedRevision !== baseRevision) {
848
+ throw new WorkbookRevisionConflictError(args.expectedRevision, baseRevision, undefined, [], workbookId)
849
+ }
850
+ const proposalId = args.proposalId ?? randomUUID()
851
+ const existing = (meta.proposals ?? []).find(proposal => proposal.id === proposalId)
852
+ if (existing !== undefined) return { meta, proposal: existing }
853
+ let nextSnapshot = cloneSnapshot(meta.snapshot)
854
+ for (const [index, operation] of args.operations.entries()) {
855
+ nextSnapshot = applyWorkbookOperation(nextSnapshot, {
856
+ opId: `${proposalId}:${index}`,
857
+ workbookId,
858
+ revision: baseRevision + 1,
859
+ actor: args.actor,
860
+ timestamp: new Date().toISOString(),
861
+ type: operation.type,
862
+ payload: operation.payload,
863
+ })
864
+ }
865
+ const changes = diffWorkbookSnapshots(meta.snapshot, nextSnapshot, collectDiffScopes(args.operations))
866
+ if (changes.length === 0) throw new Error('提案没有产生任何变化')
867
+ validateSnapshotBounds(nextSnapshot)
868
+ const timestamp = new Date().toISOString()
869
+ const proposal: WorkbookProposal = {
870
+ id: proposalId,
871
+ workbookId,
872
+ actor: args.actor,
873
+ baseRevision,
874
+ createdAt: timestamp,
875
+ updatedAt: timestamp,
876
+ status: 'pending',
877
+ summary: args.summary?.trim() || `待确认修改(${changes.length} 项)`,
878
+ changes: changes.slice(0, DIFF_CHANGE_LIMIT),
879
+ snapshot: nextSnapshot,
880
+ }
881
+ // Non-pending records are kept, not filtered away. This used to drop every
882
+ // proposal that was not `pending`, which deleted the very record
883
+ // `commitWorkbookProposal` needs to answer a retry after a lost response —
884
+ // so a retry answered `提案不存在` instead of returning the operation it had
885
+ // already committed. Trimming is the store's job.
886
+ meta.proposals = [...(meta.proposals ?? []), proposal]
887
+ await writeWorkbook(meta)
888
+ return { meta, proposal }
889
+ })
890
+ }
891
+
892
+ /** Commit a proposal atomically after checking the revision it was previewed on. */
893
+ export async function commitWorkbookProposal(args: {
894
+ workbookId: string
895
+ proposalId: string
896
+ actor: 'agent' | 'user'
897
+ expectedRevision?: number
898
+ }): Promise<WorkbookMutationResult> {
899
+ const workbookId = normalizeWorkbookId(args.workbookId)
900
+ return withWorkbookQueue(workbookId, async () => {
901
+ let meta = await readWorkbook(workbookId, { includeCheckpoints: true })
902
+ if (meta === null) throw new Error(`工作簿不存在:${args.workbookId}`)
903
+ meta = cloneSnapshot(meta) as WorkbookMeta
904
+ const proposal = (meta.proposals ?? []).find(item => item.id === args.proposalId)
905
+ if (proposal === undefined) throw new Error(`提案不存在:${args.proposalId}`)
906
+ if (proposal.status === 'committed') {
907
+ const existing = (meta.operations ?? []).find(item => item.opId === `proposal:${proposal.id}`)
908
+ if (existing !== undefined) return { meta, operation: existing }
909
+ }
910
+ if (proposal.status !== 'pending') throw new Error(`提案当前状态为 ${proposal.status},不能提交`)
911
+ const revision = meta.revision ?? 0
912
+ const expected = args.expectedRevision ?? proposal.baseRevision
913
+ if (expected !== revision || proposal.baseRevision !== revision) {
914
+ throw new WorkbookRevisionConflictError(expected, revision, undefined, [], args.workbookId)
915
+ }
916
+ finalizeOpenUserVersions(meta)
917
+ const previousSnapshot = cloneSnapshot(meta.snapshot)
918
+ const timestamp = new Date().toISOString()
919
+ const operation: WorkbookOperation = {
920
+ opId: `proposal:${proposal.id}`,
921
+ workbookId,
922
+ revision: revision + 1,
923
+ parentRevision: revision,
924
+ baseRevision: revision,
925
+ actor: args.actor,
926
+ type: 'snapshot_replace',
927
+ timestamp,
928
+ payload: { snapshot: proposal.snapshot },
929
+ requestHash: stableJson({ actor: args.actor, type: 'snapshot_replace', payload: { snapshot: proposal.snapshot } }),
930
+ }
931
+ meta.snapshot = applyWorkbookOperation(meta.snapshot, operation)
932
+ meta.sheetOrder = asWorkbookSnapshot(meta.snapshot).sheetOrder.slice()
933
+ meta.revision = operation.revision
934
+ meta.updatedAt = timestamp
935
+ expireStaleProposals(meta, operation.revision)
936
+ const logOperation = operation
937
+ meta.operations = [...(meta.operations ?? []), logOperation]
938
+ meta.checkpoints = compactCheckpoints([...(meta.checkpoints ?? []), { revision, actor: args.actor, timestamp, snapshot: previousSnapshot }])
939
+ meta.proposals = (meta.proposals ?? []).map(item => item.id === proposal.id
940
+ ? { ...item, status: 'committed', updatedAt: timestamp, committedRevision: operation.revision }
941
+ : item)
942
+ await writeWorkbook(meta, { persistSnapshot: true, persistCheckpoints: true })
943
+ publishWorkbookCollaboration(logOperation)
944
+ return { meta, operation: logOperation }
945
+ })
946
+ }
947
+
948
+ /** Reject a pending proposal without changing workbook data or revision. */
949
+ export async function rejectWorkbookProposal(workbookId: string, proposalId: string): Promise<WorkbookMeta> {
950
+ const normalizedId = normalizeWorkbookId(workbookId)
951
+ return withWorkbookQueue(normalizedId, async () => {
952
+ let meta = await readWorkbook(normalizedId)
953
+ if (meta === null) throw new Error(`工作簿不存在:${workbookId}`)
954
+ meta = cloneSnapshot(meta) as WorkbookMeta
955
+ const proposal = (meta.proposals ?? []).find(item => item.id === proposalId)
956
+ if (proposal === undefined) throw new Error(`提案不存在:${proposalId}`)
957
+ const timestamp = new Date().toISOString()
958
+ meta.proposals = (meta.proposals ?? []).map(item => item.id === proposalId ? { ...item, status: 'rejected', updatedAt: timestamp } : item)
959
+ await writeWorkbook(meta)
960
+ return meta
961
+ })
962
+ }
963
+
964
+ /** Restore any replayable previous version as a new revision. */
965
+ export async function rollbackWorkbook(args: {
966
+ workbookId: string
967
+ targetRevision: number
968
+ expectedRevision?: number
969
+ actor?: 'agent' | 'user'
970
+ opId?: string
971
+ /** User-facing summary for the restored Version, e.g. `恢复到 V3`. */
972
+ summary?: string
973
+ lineage?: LineageContext
974
+ }): Promise<WorkbookMutationResult> {
975
+ const workbookId = normalizeWorkbookId(args.workbookId)
976
+ return withWorkbookQueue(workbookId, async () => {
977
+ let meta = await readWorkbook(workbookId, { includeCheckpoints: true })
978
+ if (meta === null) throw new Error(`工作簿不存在:${args.workbookId}`)
979
+ meta = cloneSnapshot(meta) as WorkbookMeta
980
+ const revision = meta.revision ?? 0
981
+ validateOpId(args.opId)
982
+ const existing = args.opId === undefined
983
+ ? undefined
984
+ : (meta.operations ?? []).find(operation => operation.opId === args.opId)
985
+ if (existing !== undefined) {
986
+ if (existing.type !== 'snapshot_replace' || existing.parentRevision !== args.targetRevision) {
987
+ throw new Error(`opId 已被其他请求使用:${args.opId}`)
988
+ }
989
+ return { meta, operation: existing }
990
+ }
991
+ if (args.expectedRevision !== undefined && args.expectedRevision !== revision) {
992
+ throw new WorkbookRevisionConflictError(args.expectedRevision, revision, undefined, [], workbookId)
993
+ }
994
+ if (args.targetRevision === revision) throw new Error(`已经位于 revision ${args.targetRevision}`)
995
+ const target = await readWorkbookAtRevision(workbookId, args.targetRevision)
996
+ if (target === null) throw new Error(`找不到可重放的 revision ${args.targetRevision}`)
997
+ finalizeOpenUserVersions(meta)
998
+ const operation: WorkbookOperation = {
999
+ opId: args.opId ?? `rollback:${args.targetRevision}:${randomUUID()}`,
1000
+ workbookId,
1001
+ revision: revision + 1,
1002
+ parentRevision: args.targetRevision,
1003
+ baseRevision: revision,
1004
+ versionId: `restore:${workbookId}:${randomUUID().slice(0, 12)}`,
1005
+ versionStatus: 'restored',
1006
+ versionKind: 'named',
1007
+ versionSummary: optionalVersionText(args.summary, 'summary', 280) ?? `恢复到 revision ${args.targetRevision}`,
1008
+ restoredFromRevision: args.targetRevision,
1009
+ actor: args.actor ?? 'agent',
1010
+ type: 'snapshot_replace',
1011
+ timestamp: new Date().toISOString(),
1012
+ lineage: args.lineage,
1013
+ payload: { snapshot: target.snapshot },
1014
+ }
1015
+ const previousSnapshot = cloneSnapshot(meta.snapshot)
1016
+ meta.snapshot = applyWorkbookOperation(meta.snapshot, operation)
1017
+ meta.sheetOrder = asWorkbookSnapshot(meta.snapshot).sheetOrder.slice()
1018
+ meta.revision = operation.revision
1019
+ meta.updatedAt = operation.timestamp
1020
+ expireStaleProposals(meta, operation.revision)
1021
+ // Rollback is a normal new version whose logical parent is the target
1022
+ // version. Keep the snapshot payload so this version can be replayed.
1023
+ const logOperation = operation
1024
+ meta.operations = [...(meta.operations ?? []), logOperation]
1025
+ meta.checkpoints = compactCheckpoints([...(meta.checkpoints ?? []), { revision, actor: operation.actor, timestamp: operation.timestamp, snapshot: previousSnapshot }])
1026
+ await writeWorkbook(meta, { persistSnapshot: true, persistCheckpoints: true })
1027
+ publishWorkbookCollaboration(logOperation)
1028
+ return { meta, operation: logOperation }
1029
+ })
1030
+ }
1031
+
1032
+ /**
1033
+ * What one Step changed: the diff between the state before it and the state
1034
+ * after it (PRD v3.3 §7.2).
1035
+ *
1036
+ * Step replay used to hand the browser a snapshot and nothing else, so the
1037
+ * viewport stayed wherever the previous state happened to leave it and the user
1038
+ * had to hunt for the cell that moved. This answers "which cells, and where" in
1039
+ * the same request as the snapshot, so the canvas can scroll to and tint them.
1040
+ */
1041
+ export async function readStepChanges(workbookId: string, targetRevision: number, stepIndex: number): Promise<WorkbookChange[] | null> {
1042
+ const after = await readWorkbookAtStep(workbookId, targetRevision, stepIndex)
1043
+ if (after === null) return null
1044
+ const before = stepIndex === 0
1045
+ ? await readWorkbookAtRevision(workbookId, (after.baseRevision ?? 0))
1046
+ : await readWorkbookAtStep(workbookId, targetRevision, stepIndex - 1)
1047
+ if (before === null) return null
1048
+ return diffWorkbookSnapshots(before.snapshot, after.snapshot)
1049
+ }
1050
+
1051
+ /** Reconstruct the state after one step inside a Version. This starts from
1052
+ * the revision before the Version's first physical operation, then applies
1053
+ * only the requested ordered children so the UI can replay it step by step. */
1054
+ export async function readWorkbookAtStep(workbookId: string, targetRevision: number, stepIndex: number): Promise<{ snapshot: unknown; revision: number; step: number; stepCount: number; baseRevision?: number } | null> {
1055
+ if (!Number.isInteger(stepIndex) || stepIndex < 0) return null
1056
+ const meta = await readWorkbook(workbookId, { includeCheckpoints: true })
1057
+ if (meta === null || !Number.isInteger(targetRevision) || targetRevision <= 0 || targetRevision > (meta.revision ?? 0)) return null
1058
+ const operation = (meta.operations ?? []).find(item => item.revision === targetRevision)
1059
+ if (operation === undefined) return null
1060
+ const versionId = operation.versionId ?? `revision:${operation.revision}`
1061
+ const versionOperations = (meta.operations ?? [])
1062
+ .filter(item => (item.versionId ?? `revision:${item.revision}`) === versionId && item.revision <= targetRevision)
1063
+ .sort((left, right) => left.revision - right.revision)
1064
+ const firstOperation = versionOperations[0] ?? operation
1065
+ const steps = versionOperations.flatMap(item => workbookOperationSteps(item))
1066
+ if (stepIndex >= steps.length) return null
1067
+ // Legacy snapshot_replace records may have lost their payload during the
1068
+ // old 200-operation compaction pass. If a checkpoint was written exactly at
1069
+ // that revision, it is still a valid historical state even though the
1070
+ // opaque operation itself cannot be reapplied from the previous revision.
1071
+ if (versionOperations.length === 1 && firstOperation.revision === targetRevision
1072
+ && firstOperation.type === 'snapshot_replace' && firstOperation.payload.snapshot === undefined) {
1073
+ const checkpointState = await readWorkbookAtRevision(workbookId, targetRevision)
1074
+ // The start state is unrecoverable here, so report the step as its own
1075
+ // baseline: a diff against it is empty, which is the honest answer.
1076
+ if (checkpointState !== null) return { ...checkpointState, step: 0, stepCount: 1, baseRevision: targetRevision }
1077
+ }
1078
+ const previous = await readWorkbookAtRevision(workbookId, Math.max(0, firstOperation.revision - 1))
1079
+ if (previous === null) return null
1080
+ let snapshot = cloneSnapshot(previous.snapshot)
1081
+ for (let index = 0; index <= stepIndex; index += 1) {
1082
+ const step = steps[index]
1083
+ if (step === undefined) return null
1084
+ snapshot = applyWorkbookOperation(snapshot, step)
1085
+ }
1086
+ return { snapshot, revision: targetRevision, step: stepIndex, stepCount: steps.length, baseRevision: Math.max(0, firstOperation.revision - 1) }
1087
+ }
1088
+
1089
+
1090
+ function validateOpId(opId: string | undefined): void {
1091
+ if (opId !== undefined && (opId.trim().length === 0 || opId.length > 256)) {
1092
+ throw new Error('opId 必须是 1 到 256 个字符')
1093
+ }
1094
+ }
1095
+
1096
+ /** Deterministic JSON used for idempotency request fingerprints. */
1097
+ function compactCheckpoints(checkpoints: WorkbookCheckpointLike[]): WorkbookCheckpointLike[] {
1098
+ const unique = new Map<number, WorkbookCheckpointLike>()
1099
+ for (const checkpoint of checkpoints) unique.set(checkpoint.revision, checkpoint)
1100
+ const ordered = [...unique.values()].sort((left, right) => left.revision - right.revision)
1101
+ const latestRevision = ordered.at(-1)?.revision
1102
+ // Migrate legacy "every mutation" checkpoints lazily. A checkpoint at
1103
+ // revision N represents the state before N+1, so interval boundaries are
1104
+ // N+1 = 20, 40, ...; keep revision 0 and the latest legacy point as well.
1105
+ return ordered
1106
+ .filter(checkpoint => checkpoint.revision === 0
1107
+ || (checkpoint.revision + 1) % CHECKPOINT_INTERVAL === 0
1108
+ || checkpoint.revision === latestRevision)
1109
+ }
1110
+
1111
+ type WorkbookCheckpointLike = { revision: number; actor: 'agent' | 'user'; timestamp: string; snapshot: unknown }