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