@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.
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +16766 -0
- package/lib/contract-envelope.d.ts +91 -0
- package/lib/contract-envelope.js +106 -0
- package/lib/contract-layout.d.ts +130 -0
- package/lib/contract-layout.js +138 -0
- package/lib/contract-refs.d.ts +39 -0
- package/lib/contract-refs.js +9 -0
- package/lib/contract-version.d.ts +311 -0
- package/lib/contract-version.js +270 -0
- package/lib/contract-wire.d.ts +147 -0
- package/lib/contract-wire.js +99 -0
- package/lib/contracts.d.ts +12 -0
- package/lib/contracts.js +12 -0
- package/lib/types/audit.d.ts +42 -0
- package/lib/types/audit.js +39 -0
- package/lib/types/client/ProjectImportMenu.d.ts +30 -0
- package/lib/types/client/ProjectImportMenu.js +70 -0
- package/lib/types/client/SheetPanel.d.ts +33 -0
- package/lib/types/client/SheetPanel.js +1766 -0
- package/lib/types/client/SheetToggle.d.ts +18 -0
- package/lib/types/client/SheetToggle.js +26 -0
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +110 -0
- package/lib/types/client/persistence/changes.d.ts +15 -0
- package/lib/types/client/persistence/changes.js +51 -0
- package/lib/types/client/persistence/ids.d.ts +34 -0
- package/lib/types/client/persistence/ids.js +48 -0
- package/lib/types/client/persistence/merge.d.ts +35 -0
- package/lib/types/client/persistence/merge.js +362 -0
- package/lib/types/client/store.d.ts +61 -0
- package/lib/types/client/store.js +152 -0
- package/lib/types/client/surface-context.d.ts +27 -0
- package/lib/types/client/surface-context.js +104 -0
- package/lib/types/client/surface-control.d.ts +2 -0
- package/lib/types/client/surface-control.js +12 -0
- package/lib/types/client/transport/workbookApi.d.ts +159 -0
- package/lib/types/client/transport/workbookApi.js +246 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
- package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
- package/lib/types/client/univer/WorkbookCanvas.js +87 -0
- package/lib/types/client/univer/WorkbookView.d.ts +3 -0
- package/lib/types/client/univer/WorkbookView.js +2 -0
- package/lib/types/client/univer/loader.d.ts +18 -0
- package/lib/types/client/univer/loader.js +81 -0
- package/lib/types/client/univer/standalone-entry.d.ts +1 -0
- package/lib/types/client/univer/standalone-entry.js +19 -0
- package/lib/types/client/versions/VersionChrome.d.ts +33 -0
- package/lib/types/client/versions/VersionChrome.js +21 -0
- package/lib/types/client/versions/inspect.d.ts +70 -0
- package/lib/types/client/versions/inspect.js +88 -0
- package/lib/types/client/versions/preview-decoration.d.ts +11 -0
- package/lib/types/client/versions/preview-decoration.js +54 -0
- package/lib/types/client/versions/records.d.ts +19 -0
- package/lib/types/client/versions/records.js +70 -0
- package/lib/types/client/workspace-host.d.ts +61 -0
- package/lib/types/client/workspace-host.js +124 -0
- package/lib/types/collaboration-hub.d.ts +31 -0
- package/lib/types/collaboration-hub.js +69 -0
- package/lib/types/contract/ids.d.ts +24 -0
- package/lib/types/contract/ids.js +46 -0
- package/lib/types/contract/mutations.d.ts +25 -0
- package/lib/types/contract/mutations.js +782 -0
- package/lib/types/contract/operations.d.ts +54 -0
- package/lib/types/contract/operations.js +203 -0
- package/lib/types/contract/snapshots.d.ts +176 -0
- package/lib/types/contract/snapshots.js +351 -0
- package/lib/types/contract/univer-api-catalog.d.ts +29 -0
- package/lib/types/contract/univer-api-catalog.js +264 -0
- package/lib/types/errors.d.ts +8 -0
- package/lib/types/errors.js +12 -0
- package/lib/types/http/assets-routes.d.ts +3 -0
- package/lib/types/http/assets-routes.js +169 -0
- package/lib/types/http/collaboration.d.ts +10 -0
- package/lib/types/http/collaboration.js +154 -0
- package/lib/types/http/history.d.ts +15 -0
- package/lib/types/http/history.js +134 -0
- package/lib/types/http/project-files.d.ts +79 -0
- package/lib/types/http/project-files.js +206 -0
- package/lib/types/http/project-scope.d.ts +50 -0
- package/lib/types/http/project-scope.js +52 -0
- package/lib/types/http/routes.d.ts +98 -0
- package/lib/types/http/routes.js +323 -0
- package/lib/types/http/standalone.d.ts +9 -0
- package/lib/types/http/standalone.js +31 -0
- package/lib/types/http/trunk.d.ts +10 -0
- package/lib/types/http/trunk.js +322 -0
- package/lib/types/http/worktree.d.ts +9 -0
- package/lib/types/http/worktree.js +145 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.js +156 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/invariant.js +15 -0
- package/lib/types/request-params.d.ts +21 -0
- package/lib/types/request-params.js +76 -0
- package/lib/types/store/paths.d.ts +68 -0
- package/lib/types/store/paths.js +148 -0
- package/lib/types/store/project.d.ts +88 -0
- package/lib/types/store/project.js +120 -0
- package/lib/types/store/read.d.ts +110 -0
- package/lib/types/store/read.js +404 -0
- package/lib/types/store/retention.d.ts +27 -0
- package/lib/types/store/retention.js +111 -0
- package/lib/types/store/write.d.ts +22 -0
- package/lib/types/store/write.js +210 -0
- package/lib/types/tools/arguments.d.ts +40 -0
- package/lib/types/tools/arguments.js +130 -0
- package/lib/types/tools/cells.d.ts +50 -0
- package/lib/types/tools/cells.js +298 -0
- package/lib/types/tools/index.d.ts +2 -0
- package/lib/types/tools/index.js +250 -0
- package/lib/types/tools/render.d.ts +47 -0
- package/lib/types/tools/render.js +290 -0
- package/lib/types/tools/univer-api.d.ts +18 -0
- package/lib/types/tools/univer-api.js +198 -0
- package/lib/types/tools/workbook-catalog.d.ts +3 -0
- package/lib/types/tools/workbook-catalog.js +83 -0
- package/lib/types/tools/workbook-edit.d.ts +192 -0
- package/lib/types/tools/workbook-edit.js +912 -0
- package/lib/types/tools/workbook-find.d.ts +11 -0
- package/lib/types/tools/workbook-find.js +89 -0
- package/lib/types/tools/workbook-history.d.ts +2 -0
- package/lib/types/tools/workbook-history.js +59 -0
- package/lib/types/tools/workbook-read.d.ts +21 -0
- package/lib/types/tools/workbook-read.js +204 -0
- package/lib/types/types.d.ts +377 -0
- package/lib/types/types.js +34 -0
- package/lib/types/univer/apply-operation.d.ts +14 -0
- package/lib/types/univer/apply-operation.js +398 -0
- package/lib/types/univer/chunk.d.ts +68 -0
- package/lib/types/univer/chunk.js +16 -0
- package/lib/types/univer/decoration.d.ts +34 -0
- package/lib/types/univer/decoration.js +88 -0
- package/lib/types/univer/diagnostics.d.ts +31 -0
- package/lib/types/univer/diagnostics.js +141 -0
- package/lib/types/univer/engine.d.ts +33 -0
- package/lib/types/univer/engine.js +338 -0
- package/lib/types/univer/entry.d.ts +8 -0
- package/lib/types/univer/entry.js +11 -0
- package/lib/types/univer/facade.d.ts +107 -0
- package/lib/types/univer/facade.js +1 -0
- package/lib/types/univer/fonts.d.ts +202 -0
- package/lib/types/univer/fonts.js +140 -0
- package/lib/types/univer/plugins/collaboration.d.ts +23 -0
- package/lib/types/univer/plugins/collaboration.js +47 -0
- package/lib/types/univer/plugins/export.d.ts +39 -0
- package/lib/types/univer/plugins/export.js +246 -0
- package/lib/types/univer/plugins/history.d.ts +24 -0
- package/lib/types/univer/plugins/history.js +50 -0
- package/lib/types/univer/plugins/version.d.ts +13 -0
- package/lib/types/univer/plugins/version.js +13 -0
- package/lib/types/version/diff.d.ts +36 -0
- package/lib/types/version/diff.js +156 -0
- package/lib/types/version/grouping.d.ts +7 -0
- package/lib/types/version/grouping.js +94 -0
- package/lib/types/version/records.d.ts +17 -0
- package/lib/types/version/records.js +102 -0
- package/lib/types/version/session.d.ts +36 -0
- package/lib/types/version/session.js +89 -0
- package/lib/types/version/summary.d.ts +24 -0
- package/lib/types/version/summary.js +111 -0
- package/lib/types/workbook-manager.d.ts +191 -0
- package/lib/types/workbook-manager.js +935 -0
- package/lib/types/xlsx-export-worker.d.ts +5 -0
- package/lib/types/xlsx-export-worker.js +24 -0
- package/lib/types/xlsx-export.d.ts +21 -0
- package/lib/types/xlsx-export.js +371 -0
- package/lib/types/xlsx-import-worker.d.ts +1 -0
- package/lib/types/xlsx-import-worker.js +19 -0
- package/lib/univer-client.js +690 -0
- package/lib/workbook-page.js +23826 -0
- package/lib/xlsx-export-worker.js +104 -0
- package/lib/xlsx-import-worker.js +46 -0
- package/package.json +106 -3
- package/src/audit.ts +58 -0
- package/src/client/ProjectImportMenu.tsx +100 -0
- package/src/client/SheetPanel.module.css +1578 -0
- package/src/client/SheetPanel.tsx +1934 -0
- package/src/client/SheetToggle.module.css +73 -0
- package/src/client/SheetToggle.tsx +65 -0
- package/src/client/contract.d.ts +19 -0
- package/src/client/index.ts +131 -0
- package/src/client/persistence/changes.ts +58 -0
- package/src/client/persistence/ids.ts +60 -0
- package/src/client/persistence/merge.ts +392 -0
- package/src/client/store.ts +188 -0
- package/src/client/surface-context.ts +123 -0
- package/src/client/surface-control.ts +13 -0
- package/src/client/transport/workbookApi.ts +355 -0
- package/src/client/univer/SheetView.module.css +110 -0
- package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
- package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
- package/src/client/univer/WorkbookCanvas.tsx +128 -0
- package/src/client/univer/WorkbookView.module.css +30 -0
- package/src/client/univer/WorkbookView.tsx +3 -0
- package/src/client/univer/loader.ts +90 -0
- package/src/client/univer/react-dom-client.d.ts +10 -0
- package/src/client/univer/standalone-entry.tsx +19 -0
- package/src/client/univer/standalone.css +12 -0
- package/src/client/univer/univer-locales.d.ts +89 -0
- package/src/client/univer/univer-modules.d.ts +26 -0
- package/src/client/versions/VersionChrome.tsx +114 -0
- package/src/client/versions/inspect.ts +138 -0
- package/src/client/versions/preview-decoration.ts +54 -0
- package/src/client/versions/records.ts +78 -0
- package/src/client/workspace-host.ts +147 -0
- package/src/collaboration-hub.ts +83 -0
- package/src/contract/ids.ts +50 -0
- package/src/contract/mutations.ts +791 -0
- package/src/contract/operations.ts +221 -0
- package/src/contract/snapshots.ts +383 -0
- package/src/contract/univer-api-catalog.ts +289 -0
- package/src/css-modules.d.ts +6 -0
- package/src/errors.ts +25 -0
- package/src/http/assets-routes.ts +177 -0
- package/src/http/collaboration.ts +144 -0
- package/src/http/history.ts +151 -0
- package/src/http/project-files.ts +227 -0
- package/src/http/project-scope.ts +77 -0
- package/src/http/routes.ts +364 -0
- package/src/http/standalone.ts +32 -0
- package/src/http/trunk.ts +322 -0
- package/src/http/worktree.ts +119 -0
- package/src/index.ts +165 -0
- package/src/invariant.ts +17 -0
- package/src/request-params.ts +92 -0
- package/src/store/paths.ts +165 -0
- package/src/store/project.ts +137 -0
- package/src/store/read.ts +454 -0
- package/src/store/retention.ts +106 -0
- package/src/store/write.ts +230 -0
- package/src/tools/arguments.ts +128 -0
- package/src/tools/cells.ts +298 -0
- package/src/tools/index.ts +274 -0
- package/src/tools/render.ts +301 -0
- package/src/tools/univer-api.ts +243 -0
- package/src/tools/workbook-catalog.ts +94 -0
- package/src/tools/workbook-edit.ts +987 -0
- package/src/tools/workbook-find.ts +80 -0
- package/src/tools/workbook-history.ts +63 -0
- package/src/tools/workbook-read.ts +210 -0
- package/src/types.ts +432 -0
- package/src/univer/apply-operation.ts +369 -0
- package/src/univer/chunk.ts +77 -0
- package/src/univer/decoration.ts +111 -0
- package/src/univer/diagnostics.ts +136 -0
- package/src/univer/engine.ts +370 -0
- package/src/univer/entry.ts +14 -0
- package/src/univer/facade.ts +91 -0
- package/src/univer/fonts.ts +148 -0
- package/src/univer/overrides.css +33 -0
- package/src/univer/plugins/collaboration.ts +47 -0
- package/src/univer/plugins/export.ts +236 -0
- package/src/univer/plugins/history.ts +49 -0
- package/src/univer/plugins/version.ts +13 -0
- package/src/version/diff.ts +169 -0
- package/src/version/grouping.ts +96 -0
- package/src/version/records.ts +113 -0
- package/src/version/session.ts +90 -0
- package/src/version/summary.ts +117 -0
- package/src/workbook-manager.ts +1111 -0
- package/src/xlsx-export-worker.ts +34 -0
- package/src/xlsx-export.ts +373 -0
- 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 }
|