@taskforcehq/taskforce 0.3.301 → 0.3.302

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 (147) hide show
  1. package/dist/Taskforce.module.css +26 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +13 -24
  4. package/dist/TaskforceCore.test.js +167 -152
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  14. package/dist/components/views/PlansPage.js +9 -8
  15. package/dist/components/views/StandaloneLayout.js +466 -204
  16. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  17. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +43 -0
  18. package/dist/components/views/standalone/modals/AccountHubModal.js +22 -0
  19. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  20. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  21. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  22. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  23. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  24. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  25. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  26. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  27. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  28. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  29. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  30. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  31. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  32. package/dist/components/views/standalone/modals/types.js +1 -0
  33. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  34. package/dist/components/views/teamManagementAccess.js +2 -2
  35. package/dist/components/views/teamManagementAccess.test.js +3 -3
  36. package/dist/config/deployment.js +2 -2
  37. package/dist/config/deployment.test.js +5 -3
  38. package/dist/core/AiProfileService.js +7 -2
  39. package/dist/core/AiProfiles.test.js +19 -0
  40. package/dist/core/SyncReconciliationService.d.ts +2 -0
  41. package/dist/core/SyncReconciliationService.js +3 -0
  42. package/dist/core/TaskAuditTimeline.test.js +40 -0
  43. package/dist/core/Taskforce.d.ts +49 -0
  44. package/dist/core/Taskforce.ids.test.js +133 -0
  45. package/dist/core/Taskforce.js +241 -78
  46. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  47. package/dist/core/UserProfileAvatarDrafts.test.js +102 -0
  48. package/dist/core/taskReferencePolicy.d.ts +28 -0
  49. package/dist/core/taskReferencePolicy.js +189 -0
  50. package/dist/core/types.d.ts +2 -0
  51. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  52. package/dist/hooks/useTaskData.js +3 -2
  53. package/dist/hooks/useTaskData.test.js +66 -0
  54. package/dist/hooks/useTaskforce.d.ts +5 -0
  55. package/dist/hooks/useTaskforce.js +63 -15
  56. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  57. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  58. package/dist/mcp/aiProfileBootstrap.js +80 -0
  59. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  60. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  61. package/dist/mcp/clientIntegrations.d.ts +2 -8
  62. package/dist/mcp/clientIntegrations.js +21 -40
  63. package/dist/mcp/clientIntegrations.test.js +28 -2
  64. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  65. package/dist/mcp/clientProfileDefaults.js +37 -0
  66. package/dist/mcp/runtime.d.ts +10 -7
  67. package/dist/mcp/runtime.js +41 -19
  68. package/dist/mcp/runtime.test.js +49 -2
  69. package/dist/migrations/taskSchemaMigrations.js +53 -1
  70. package/dist/qaOpen.test.d.ts +1 -0
  71. package/dist/qaOpen.test.js +56 -0
  72. package/dist/server/index.cookieProxy.test.js +1 -0
  73. package/dist/server/index.d.ts +1 -0
  74. package/dist/server/index.js +15 -1
  75. package/dist/server/index.test.js +14 -1
  76. package/dist/server/routes/admin.js +25 -22
  77. package/dist/server/routes/auth.d.ts +5 -0
  78. package/dist/server/routes/auth.js +350 -16
  79. package/dist/server/routes/billing.js +11 -1
  80. package/dist/server/routes/billing.test.js +32 -2
  81. package/dist/server/routes/documents.js +6 -1
  82. package/dist/server/routes/shared.d.ts +1 -1
  83. package/dist/server/routes/shared.js +11 -3
  84. package/dist/server/routes/sync.integration.test.js +130 -0
  85. package/dist/server/routes/sync.js +226 -1924
  86. package/dist/server/routes/tasks.js +18 -4
  87. package/dist/server/routes.js +26 -19
  88. package/dist/server/routes.test.js +352 -6
  89. package/dist/shared/runtimeContract.d.ts +1 -1
  90. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  91. package/dist/sync/collaborationSyncPayload.js +131 -0
  92. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  93. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  94. package/dist/sync/collaborationSyncState.d.ts +15 -0
  95. package/dist/sync/collaborationSyncState.js +60 -0
  96. package/dist/sync/contentSyncPayload.d.ts +45 -0
  97. package/dist/sync/contentSyncPayload.js +104 -0
  98. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  99. package/dist/sync/contentSyncPayload.test.js +104 -0
  100. package/dist/sync/contentSyncState.d.ts +88 -0
  101. package/dist/sync/contentSyncState.js +743 -0
  102. package/dist/sync/planningSyncPayload.d.ts +9 -0
  103. package/dist/sync/planningSyncPayload.js +82 -0
  104. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  105. package/dist/sync/planningSyncPayload.test.js +68 -0
  106. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  107. package/dist/sync/syncApplyHandlers.js +445 -0
  108. package/dist/sync/taskSyncPayload.d.ts +33 -0
  109. package/dist/sync/taskSyncPayload.js +126 -0
  110. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  111. package/dist/sync/taskSyncPayload.test.js +54 -0
  112. package/dist/sync/workspacePullFeed.d.ts +28 -0
  113. package/dist/sync/workspacePullFeed.js +301 -0
  114. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  115. package/dist/sync/workspacePullFeed.test.js +166 -0
  116. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  117. package/dist/sync/workspaceSyncModel.js +9 -2
  118. package/dist/sync/workspaceSyncModel.test.js +29 -0
  119. package/dist/test/setup.js +1 -1
  120. package/dist/types.d.ts +1 -0
  121. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-JG5jc3he.js} +1 -1
  122. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-Dvi02Y7q.js} +1 -1
  123. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-iys3zfca.js} +2 -2
  124. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-DJvLJI3A.js} +1 -1
  125. package/dist/ui/assets/PlansPage-BMPlpFpw.js +1 -0
  126. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +8 -0
  127. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  128. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-BguHeQaV.js} +1 -1
  129. package/dist/ui/assets/index-MXeWoebC.css +1 -0
  130. package/dist/ui/assets/index-YJ4k5QNa.js +6 -0
  131. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-C8EED3Oh.js} +1 -1
  132. package/dist/ui/index.html +3 -3
  133. package/dist/utils/billingStatusCache.d.ts +21 -0
  134. package/dist/utils/billingStatusCache.js +70 -0
  135. package/dist/utils/taskActivity.js +17 -4
  136. package/dist/utils/taskActivity.test.js +27 -0
  137. package/dist/utils/taskNormalization.js +10 -2
  138. package/dist/utils/taskReferences.d.ts +11 -1
  139. package/dist/utils/taskReferences.js +33 -1
  140. package/package.json +2 -1
  141. package/scripts/qa-open-lib.mjs +175 -0
  142. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  143. package/scripts/qa-open.mjs +352 -0
  144. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  145. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  146. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  147. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -1,13 +1,19 @@
1
- import * as fs from 'fs';
2
1
  import * as path from 'path';
3
- import { createHash, randomUUID } from 'crypto';
4
2
  import { parseJsonBody } from '../routes.js';
5
3
  import { normalizeTaskDataRelativePath, ObjectStorageClient } from '../../storage/objectStorageClient.js';
6
- import { WorkspaceAssetStore } from '../../storage/workspaceAssetStore.js';
4
+ import { WorkspaceAssetStore, } from '../../storage/workspaceAssetStore.js';
7
5
  import { resolveSessionCookieAttributesForRequest } from '../auth.js';
8
6
  import { decryptDocument, ENCRYPTION_PREFIX } from '../../sync/encryptionService.js';
9
7
  import { isAiProfileSurfaceType } from '../../shared/aiProfileSurfaceType.js';
10
- import { compareWorkspaceMutationOrdering, getTaskMutationTieBreak, getTaskMutationWatermark } from '../../sync/workspaceSyncSurface.js';
8
+ import { getTaskMutationTieBreak, getTaskMutationWatermark } from '../../sync/workspaceSyncSurface.js';
9
+ import { normalizeTaskSyncPayload, taskSyncPayloadToCoreTask } from '../../sync/taskSyncPayload.js';
10
+ import { normalizeBinaryAssetDeleteSyncPayload, normalizeBinaryAssetSyncPayload, normalizeDocumentDeleteSyncPayload, normalizeDocumentSyncPayload, serializeBinaryAssetSyncPayload, serializeDocumentSyncPayload } from '../../sync/contentSyncPayload.js';
11
+ import { createContentSyncState } from '../../sync/contentSyncState.js';
12
+ import { normalizeAiProfileSyncPayload, normalizeAnnotatedAttachmentSessionSyncPayload, normalizeDeleteSyncPayload, normalizeDocumentReviewCommentSyncPayload, normalizeDocumentReviewSessionSyncPayload, } from '../../sync/collaborationSyncPayload.js';
13
+ import { createCollaborationSyncState } from '../../sync/collaborationSyncState.js';
14
+ import { buildWorkspacePullFeed } from '../../sync/workspacePullFeed.js';
15
+ import { normalizeInitiativeSyncPayload, normalizeTaxonomySyncPayload, normalizeWorkstreamSyncPayload, } from '../../sync/planningSyncPayload.js';
16
+ import { applyAiProfileAndMemberSyncChanges, applyCollaborationSyncChanges, applyContentSyncChanges, applyPlanningSyncChanges, resolveSyncConflictOrdering, } from '../../sync/syncApplyHandlers.js';
11
17
  import { applyWorkspaceRepair } from '../../sync/workspaceRepair.js';
12
18
  function decodeWorkspaceSyncCursor(raw) {
13
19
  const value = String(raw || '').trim();
@@ -174,790 +180,38 @@ export function registerSyncRoutes(deps) {
174
180
  return normalized;
175
181
  return null;
176
182
  };
177
- const resolveCanonicalAssetSyncLinkState = (asset, workspaceId) => {
178
- if (!workspaceAssetStore) {
179
- return {
180
- primaryLink: null,
181
- effectiveUpdatedAt: String(asset.updatedAt || asset.createdAt || '').trim() || new Date().toISOString()
182
- };
183
- }
184
- const links = workspaceAssetStore.listLinksForAsset(asset.assetId, workspaceId, { includeDeleted: true });
185
- const activeLinks = links.filter((entry) => !entry.deletedAt);
186
- const primaryLink = activeLinks[0] || null;
187
- let effectiveUpdatedAt = String(asset.updatedAt || asset.createdAt || '').trim() || new Date().toISOString();
188
- for (const link of links) {
189
- const linkUpdatedAt = String(link.updatedAt || link.createdAt || '').trim();
190
- if (linkUpdatedAt && linkUpdatedAt > effectiveUpdatedAt)
191
- effectiveUpdatedAt = linkUpdatedAt;
192
- }
193
- return { primaryLink, effectiveUpdatedAt };
194
- };
195
- const getCanonicalSyncDocumentMetadata = (relativePath, workspaceId) => {
196
- if (!workspaceAssetStore)
197
- return {};
198
- const asset = workspaceAssetStore.getByStorageKey(relativePath, workspaceId);
199
- if (!asset || asset.kind !== 'document')
200
- return {};
201
- const { primaryLink } = resolveCanonicalAssetSyncLinkState(asset, workspaceId);
202
- return {
203
- assetId: asset.assetId,
204
- documentId: asset.documentId,
205
- referenceNumber: asset.referenceNumber,
206
- version: asset.version,
207
- taskId: primaryLink?.taskId || null,
208
- logicalName: asset.logicalName || null,
209
- caption: primaryLink?.displayName || null,
210
- originalFilename: asset.originalFilename || path.basename(relativePath),
211
- linkRole: primaryLink?.role === 'reference' ? 'reference' : 'attachment'
212
- };
213
- };
214
- const hasDocumentSyncMetadataDrift = (workspaceId, doc, options) => {
215
- if (!workspaceAssetStore)
216
- return false;
217
- const existingAsset = workspaceAssetStore.getByStorageKey(doc.path, workspaceId);
218
- if (!existingAsset || existingAsset.kind !== 'document')
219
- return false;
220
- const { primaryLink } = resolveCanonicalAssetSyncLinkState(existingAsset, workspaceId);
221
- const normalizedIncomingReference = Number.isFinite(Number(doc.referenceNumber))
222
- ? Math.max(1, Math.floor(Number(doc.referenceNumber)))
223
- : null;
224
- const normalizedIncomingVersion = Number.isFinite(Number(doc.version))
225
- ? Math.max(1, Math.floor(Number(doc.version)))
226
- : null;
227
- const normalizedIncomingDocumentId = typeof doc.documentId === 'string' && doc.documentId.trim().length > 0
228
- ? doc.documentId.trim()
229
- : null;
230
- const normalizedIncomingLogicalName = typeof doc.logicalName === 'string' && doc.logicalName.trim().length > 0
231
- ? doc.logicalName.trim()
232
- : null;
233
- const normalizedIncomingCaption = typeof doc.caption === 'string' && doc.caption.trim().length > 0
234
- ? doc.caption.trim()
235
- : null;
236
- const normalizedIncomingFilename = typeof doc.originalFilename === 'string' && doc.originalFilename.trim().length > 0
237
- ? doc.originalFilename.trim()
238
- : null;
239
- const normalizedIncomingTaskId = typeof doc.taskId === 'string' && doc.taskId.trim().length > 0
240
- ? doc.taskId.trim()
183
+ const contentSyncState = createContentSyncState({
184
+ core,
185
+ workspaceAssetStore,
186
+ getObjectStorageClient,
187
+ normalizeWorkspaceSyncDocumentPath,
188
+ normalizeTaskDataRelativePath: (rawPath) => normalizeTaskDataRelativePath(String(rawPath || '').trim()),
189
+ recordSyncDiagnostic,
190
+ workspaceDocumentSnapshotCache,
191
+ });
192
+ const collaborationSyncState = createCollaborationSyncState({ core });
193
+ const hasTaskReferenceNumberDrift = (workspaceId, task) => {
194
+ const taskId = String(task?.id || '').trim();
195
+ const incomingReferenceNumber = Number.isFinite(Number(task?.referenceNumber))
196
+ ? Math.max(1, Math.floor(Number(task?.referenceNumber)))
241
197
  : null;
242
- const normalizedIncomingLinkRole = doc.linkRole === 'reference' ? 'reference' : 'attachment';
243
- const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true;
244
- if (!allowReferenceNumberReassignment
245
- && existingAsset.referenceNumber
246
- && normalizedIncomingReference
247
- && existingAsset.referenceNumber !== normalizedIncomingReference) {
248
- recordSyncDiagnostic(workspaceId, 'apply', 'Detected document reference number mismatch during normal sync.', {
249
- source: 'documents.referenceNumber.mismatch',
250
- path: doc.path,
251
- assetId: existingAsset.assetId,
252
- existingReferenceNumber: existingAsset.referenceNumber,
253
- incomingReferenceNumber: normalizedIncomingReference
254
- });
255
- }
256
- return (allowReferenceNumberReassignment && existingAsset.referenceNumber !== normalizedIncomingReference)
257
- || existingAsset.version !== normalizedIncomingVersion
258
- || (existingAsset.documentId || null) !== normalizedIncomingDocumentId
259
- || (existingAsset.logicalName || null) !== normalizedIncomingLogicalName
260
- || (existingAsset.originalFilename || null) !== normalizedIncomingFilename
261
- || (primaryLink?.taskId || null) !== normalizedIncomingTaskId
262
- || (primaryLink?.displayName || null) !== normalizedIncomingCaption
263
- || ((primaryLink?.role === 'reference' ? 'reference' : 'attachment')) !== normalizedIncomingLinkRole;
264
- };
265
- const hasBinaryAssetSyncMetadataDrift = (workspaceId, asset, options) => {
266
- if (!workspaceAssetStore)
198
+ if (!incomingReferenceNumber)
267
199
  return false;
268
- const existingAsset = workspaceAssetStore.getByStorageKey(asset.path, workspaceId);
269
- if (!existingAsset || (existingAsset.kind !== 'image' && existingAsset.kind !== 'file'))
200
+ const existingTask = core.resolveTaskIdentifier(`T-${incomingReferenceNumber}`, workspaceId);
201
+ if (!existingTask || existingTask.id === taskId)
270
202
  return false;
271
- const { primaryLink } = resolveCanonicalAssetSyncLinkState(existingAsset, workspaceId);
272
- const normalizedIncomingReference = Number.isFinite(Number(asset.referenceNumber))
273
- ? Math.max(1, Math.floor(Number(asset.referenceNumber)))
274
- : null;
275
- const normalizedIncomingLogicalName = typeof asset.logicalName === 'string' && asset.logicalName.trim().length > 0
276
- ? asset.logicalName.trim()
277
- : null;
278
- const normalizedIncomingCaption = typeof asset.caption === 'string' && asset.caption.trim().length > 0
279
- ? asset.caption.trim()
280
- : null;
281
- const normalizedIncomingFilename = typeof asset.originalFilename === 'string' && asset.originalFilename.trim().length > 0
282
- ? asset.originalFilename.trim()
283
- : null;
284
- const normalizedIncomingTaskId = typeof asset.taskId === 'string' && asset.taskId.trim().length > 0
285
- ? asset.taskId.trim()
286
- : null;
287
- const normalizedIncomingLinkRole = asset.linkRole === 'reference'
288
- ? 'reference'
289
- : asset.kind === 'image'
290
- ? 'image'
291
- : 'attachment';
292
- const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true;
293
- if (!allowReferenceNumberReassignment
294
- && existingAsset.referenceNumber
295
- && normalizedIncomingReference
296
- && existingAsset.referenceNumber !== normalizedIncomingReference) {
297
- recordSyncDiagnostic(workspaceId, 'apply', 'Detected asset reference number mismatch during normal sync.', {
298
- source: 'assets.referenceNumber.mismatch',
299
- path: asset.path,
300
- assetId: existingAsset.assetId,
301
- existingReferenceNumber: existingAsset.referenceNumber,
302
- incomingReferenceNumber: normalizedIncomingReference
303
- });
304
- }
305
- return (allowReferenceNumberReassignment && existingAsset.referenceNumber !== normalizedIncomingReference)
306
- || (existingAsset.logicalName || null) !== normalizedIncomingLogicalName
307
- || (existingAsset.originalFilename || null) !== normalizedIncomingFilename
308
- || (primaryLink?.taskId || null) !== normalizedIncomingTaskId
309
- || (primaryLink?.displayName || null) !== normalizedIncomingCaption
310
- || ((primaryLink?.role === 'reference'
311
- ? 'reference'
312
- : existingAsset.kind === 'image'
313
- ? 'image'
314
- : 'attachment')) !== normalizedIncomingLinkRole;
315
- };
316
- const reconcileWorkspaceSyncLinks = (input) => {
317
- if (!workspaceAssetStore)
318
- return;
319
- const workspaceId = String(input.workspaceId || '').trim() || 'default';
320
- const assetId = String(input.assetId || '').trim();
321
- if (!assetId)
322
- return;
323
- const desiredTaskId = String(input.taskId || '').trim();
324
- const desiredRole = input.role === 'reference'
325
- ? 'reference'
326
- : input.role === 'image'
327
- ? 'image'
328
- : 'attachment';
329
- const linkUpdatedAt = String(input.updatedAt || '').trim() || new Date().toISOString();
330
- const existingLinks = workspaceAssetStore.listLinksForAsset(assetId, workspaceId, { includeDeleted: true });
331
- if (desiredTaskId) {
332
- workspaceAssetStore.upsertLink({
333
- workspaceId,
334
- assetId,
335
- taskId: desiredTaskId,
336
- role: desiredRole,
337
- displayName: typeof input.displayName === 'string' && input.displayName.trim().length > 0 ? input.displayName.trim() : null,
338
- updatedAt: linkUpdatedAt,
339
- deletedAt: null
340
- });
341
- }
342
- // Only clean up stale links when we have an explicit incoming taskId. If the incoming
343
- // asset-upsert carries no taskId, there is no authoritative association to enforce, so
344
- // preserve all existing links rather than wiping them.
345
- if (desiredTaskId) {
346
- for (const link of existingLinks) {
347
- if (link.taskId === desiredTaskId && link.role === desiredRole)
348
- continue;
349
- if (link.deletedAt)
350
- continue;
351
- workspaceAssetStore.deleteLink({
352
- workspaceId,
353
- assetId,
354
- taskId: link.taskId,
355
- role: link.role
356
- });
357
- }
358
- }
359
- };
360
- const listWorkspaceSyncDocumentDeletesSnapshot = (workspaceId = 'default') => {
361
- if (!workspaceAssetStore)
362
- return [];
363
- const entries = workspaceAssetStore.listAllByWorkspace(workspaceId, {
364
- includeDeleted: true,
365
- kind: 'document'
366
- })
367
- .filter((asset) => typeof asset.deletedAt === 'string' && asset.deletedAt.trim().length > 0)
368
- .map((asset) => {
369
- const normalizedPath = normalizeWorkspaceSyncDocumentPath(asset.storageKey);
370
- if (!normalizedPath)
371
- return null;
372
- return {
373
- path: normalizedPath,
374
- deletedAt: String(asset.deletedAt || '').trim(),
375
- assetId: asset.assetId
376
- };
377
- });
378
- return entries.filter((entry) => Boolean(entry && entry.path && entry.deletedAt));
379
- };
380
- const listWorkspaceSyncDocumentReviewSessionsSnapshot = (workspaceId = 'default') => {
381
- return core.listDocumentReviewSessionsForSync({ workspaceId, includeDeleted: true })
382
- .map((session) => ({
383
- id: session.id,
384
- assetId: session.assetId,
385
- documentId: session.documentId,
386
- documentVersion: session.documentVersion,
387
- title: session.title,
388
- status: session.status,
389
- comments: session.comments,
390
- createdByActorId: session.createdByActorId,
391
- updatedByActorId: session.updatedByActorId,
392
- createdAt: session.createdAt,
393
- updatedAt: session.updatedAt,
394
- deletedAt: session.deletedAt,
395
- }))
396
- .filter((session) => session.id.length > 0 && session.assetId.length > 0);
397
- };
398
- const listWorkspaceSyncDocumentReviewCommentsSnapshot = (workspaceId = 'default') => {
399
- return core.listDocumentReviewCommentsForSync({ workspaceId, includeDeleted: true })
400
- .map((comment) => ({
401
- id: comment.id,
402
- sessionId: comment.sessionId,
403
- body: comment.body,
404
- anchor: comment.anchor,
405
- order: comment.order,
406
- authorActorId: comment.authorActorId,
407
- createdAt: comment.createdAt,
408
- updatedAt: comment.updatedAt,
409
- deletedAt: comment.deletedAt,
410
- }))
411
- .filter((comment) => comment.id.length > 0 && comment.sessionId.length > 0);
412
- };
413
- const listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot = (workspaceId = 'default') => {
414
- return core.listAnnotatedAttachmentSessionsForSync({ workspaceId, includeDeleted: true })
415
- .map((session) => ({
416
- id: session.id,
417
- workspaceId: session.workspaceId,
418
- taskId: session.taskId,
419
- baseImageAssetId: session.baseImageAssetId,
420
- title: session.title,
421
- globalInstruction: session.globalInstruction,
422
- annotations: session.annotations,
423
- createdByActorId: session.createdByActorId,
424
- updatedByActorId: session.updatedByActorId,
425
- createdAt: session.createdAt,
426
- updatedAt: session.updatedAt,
427
- deletedAt: session.deletedAt,
428
- }))
429
- .filter((session) => (session.id.length > 0
430
- && session.workspaceId.length > 0
431
- && session.baseImageAssetId.length > 0));
432
- };
433
- const listWorkspaceSyncDocumentsSnapshot = async (basePath, workspaceId = 'default') => {
434
- const root = path.resolve(basePath);
435
- const objectStorageClient = getObjectStorageClient();
436
- const candidatePaths = new Set();
437
- const docsRoot = path.join(root, 'docs');
438
- const collectMarkdownPaths = (directory) => {
439
- if (!fs.existsSync(directory))
440
- return;
441
- const entries = fs.readdirSync(directory, { withFileTypes: true });
442
- for (const entry of entries) {
443
- const absolutePath = path.join(directory, entry.name);
444
- if (entry.isDirectory()) {
445
- collectMarkdownPaths(absolutePath);
446
- continue;
447
- }
448
- if (!entry.isFile())
449
- continue;
450
- const relativeFromRoot = path.relative(root, absolutePath).replace(/\\/g, '/');
451
- const normalized = normalizeWorkspaceSyncDocumentPath(relativeFromRoot);
452
- if (normalized)
453
- candidatePaths.add(normalized);
454
- }
455
- };
456
- collectMarkdownPaths(docsRoot);
457
- if (workspaceAssetStore) {
458
- const canonicalAssets = workspaceAssetStore.listAllByWorkspace(workspaceId, {
459
- includeDeleted: false,
460
- kind: 'document'
461
- });
462
- for (const asset of canonicalAssets) {
463
- const normalized = normalizeWorkspaceSyncDocumentPath(asset.storageKey);
464
- if (normalized)
465
- candidatePaths.add(normalized);
466
- }
467
- }
468
- const uniquePaths = Array.from(candidatePaths).sort((a, b) => a.localeCompare(b));
469
- const workspaceCache = workspaceDocumentSnapshotCache.get(workspaceId) || new Map();
470
- workspaceDocumentSnapshotCache.set(workspaceId, workspaceCache);
471
- const remainingPaths = new Set(uniquePaths);
472
- const docs = [];
473
- for (const relativePath of uniquePaths) {
474
- try {
475
- const canonicalAsset = workspaceAssetStore?.getByStorageKey(relativePath, workspaceId);
476
- let stat = null;
477
- let content = null;
478
- let updatedAt = '';
479
- if (canonicalAsset?.kind === 'document') {
480
- updatedAt = resolveCanonicalAssetSyncLinkState(canonicalAsset, workspaceId).effectiveUpdatedAt;
481
- if (canonicalAsset.storageProvider === 'r2' && objectStorageClient) {
482
- const object = await objectStorageClient.getObject(relativePath);
483
- if (!object?.body)
484
- continue;
485
- content = object.body.toString('utf8');
486
- }
487
- else {
488
- const absPath = path.resolve(path.join(root, relativePath));
489
- if (absPath !== root && !absPath.startsWith(`${root}${path.sep}`))
490
- continue;
491
- if (!fs.existsSync(absPath))
492
- continue;
493
- stat = fs.statSync(absPath);
494
- if (!stat.isFile())
495
- continue;
496
- content = fs.readFileSync(absPath, 'utf8');
497
- }
498
- }
499
- else {
500
- const absPath = path.resolve(path.join(basePath, relativePath));
501
- const safeRoot = path.resolve(basePath);
502
- if (absPath !== safeRoot && !absPath.startsWith(`${safeRoot}${path.sep}`))
503
- continue;
504
- stat = fs.statSync(absPath);
505
- if (!stat.isFile())
506
- continue;
507
- content = fs.readFileSync(absPath, 'utf8');
508
- }
509
- if (content == null)
510
- continue;
511
- const cached = workspaceCache.get(relativePath);
512
- if (cached
513
- && cached.size === Buffer.byteLength(content, 'utf8')
514
- && (canonicalAsset
515
- ? cached.updatedAt === updatedAt
516
- : (stat != null && cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs))) {
517
- docs.push({
518
- path: relativePath,
519
- updatedAt: cached.updatedAt,
520
- content: cached.content,
521
- ...getCanonicalSyncDocumentMetadata(relativePath, workspaceId)
522
- });
523
- continue;
524
- }
525
- if (!canonicalAsset) {
526
- const contentHash = createHash('sha256').update(content).digest('hex');
527
- const existing = core.getDocumentWatermark(workspaceId, relativePath);
528
- if (existing && existing.contentHash === contentHash) {
529
- updatedAt = existing.updatedAt;
530
- }
531
- else {
532
- updatedAt = stat && Number.isFinite(stat.mtimeMs)
533
- ? new Date(stat.mtimeMs).toISOString()
534
- : new Date().toISOString();
535
- try {
536
- core.upsertDocumentWatermark(workspaceId, relativePath, contentHash, updatedAt);
537
- }
538
- catch (error) {
539
- recordSyncDiagnostic(workspaceId, 'pull', 'Failed to persist document watermark during snapshot.', {
540
- source: 'documents.snapshot.upsertWatermark',
541
- path: relativePath,
542
- error: String(error?.message || error || '')
543
- });
544
- }
545
- }
546
- }
547
- docs.push({
548
- path: relativePath,
549
- updatedAt,
550
- content,
551
- ...getCanonicalSyncDocumentMetadata(relativePath, workspaceId)
552
- });
553
- workspaceCache.set(relativePath, {
554
- path: relativePath,
555
- mtimeMs: stat?.mtimeMs ?? 0,
556
- ctimeMs: stat?.ctimeMs ?? 0,
557
- size: Buffer.byteLength(content, 'utf8'),
558
- updatedAt,
559
- content
560
- });
561
- }
562
- catch (error) {
563
- recordSyncDiagnostic(workspaceId, 'pull', 'Failed to read sync document snapshot entry.', {
564
- source: 'documents.snapshot.readEntry',
565
- path: relativePath,
566
- error: String(error?.message || error || '')
567
- });
568
- }
569
- }
570
- for (const cachedPath of Array.from(workspaceCache.keys())) {
571
- if (!remainingPaths.has(cachedPath))
572
- workspaceCache.delete(cachedPath);
573
- }
574
- return docs;
575
- };
576
- const listWorkspaceSyncBinaryAssetsSnapshot = async (basePath, workspaceId = 'default') => {
577
- if (!workspaceAssetStore)
578
- return [];
579
- const root = path.resolve(basePath);
580
- const objectStorageClient = getObjectStorageClient();
581
- const assets = workspaceAssetStore.listAllByWorkspace(workspaceId, {
582
- includeDeleted: false
583
- });
584
- const snapshots = [];
585
- for (const asset of assets) {
586
- if (asset.kind !== 'image' && asset.kind !== 'file')
587
- continue;
588
- // External references are not syncable binary payloads.
589
- if (asset.storageProvider === 'external')
590
- continue;
591
- const storageKey = String(asset.storageKey || '').trim();
592
- if (!storageKey)
593
- continue;
594
- // Skip legacy task-data assets — they cannot be received on the other end
595
- if (!normalizeTaskDataRelativePath(storageKey))
596
- continue;
597
- try {
598
- let buffer = null;
599
- if (asset.storageProvider === 'r2' && objectStorageClient) {
600
- const object = await objectStorageClient.getObject(storageKey);
601
- buffer = object?.body || null;
602
- }
603
- else {
604
- const absolutePath = path.resolve(root, storageKey);
605
- if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`))
606
- continue;
607
- if (!fs.existsSync(absolutePath)) {
608
- recordSyncDiagnostic(workspaceId, 'push', 'Asset registered in DB but file missing from disk — skipped from sync payload.', {
609
- source: 'assets.snapshot.missingFile',
610
- assetId: asset.assetId,
611
- path: storageKey
612
- });
613
- continue;
614
- }
615
- buffer = fs.readFileSync(absolutePath);
616
- }
617
- if (!buffer) {
618
- recordSyncDiagnostic(workspaceId, 'push', 'Asset registered in DB but content could not be read — skipped from sync payload.', {
619
- source: 'assets.snapshot.emptyBuffer',
620
- assetId: asset.assetId,
621
- path: storageKey,
622
- storageProvider: asset.storageProvider
623
- });
624
- continue;
625
- }
626
- const { primaryLink, effectiveUpdatedAt } = resolveCanonicalAssetSyncLinkState(asset, workspaceId);
627
- snapshots.push({
628
- path: storageKey,
629
- updatedAt: effectiveUpdatedAt,
630
- contentBase64: buffer.toString('base64'),
631
- assetId: asset.assetId,
632
- kind: asset.kind,
633
- mimeType: asset.mimeType || 'application/octet-stream',
634
- referenceNumber: asset.referenceNumber,
635
- taskId: primaryLink?.taskId || null,
636
- logicalName: asset.logicalName || null,
637
- caption: primaryLink?.displayName || null,
638
- originalFilename: asset.originalFilename || path.basename(storageKey),
639
- linkRole: primaryLink?.role || (asset.kind === 'image' ? 'image' : 'attachment')
640
- });
641
- }
642
- catch (error) {
643
- recordSyncDiagnostic(workspaceId, 'pull', 'Failed to read binary asset snapshot entry.', {
644
- source: 'assets.snapshot.readEntry',
645
- path: storageKey,
646
- error: String(error?.message || error || '')
647
- });
648
- }
649
- }
650
- return snapshots;
651
- };
652
- const listWorkspaceSyncBinaryAssetDeletesSnapshot = (workspaceId = 'default') => {
653
- if (!workspaceAssetStore)
654
- return [];
655
- const entries = workspaceAssetStore.listAllByWorkspace(workspaceId, {
656
- includeDeleted: true
657
- })
658
- .filter((asset) => ((asset.kind === 'image' || asset.kind === 'file')
659
- && asset.storageProvider !== 'external'
660
- && typeof asset.deletedAt === 'string'
661
- && asset.deletedAt.trim().length > 0))
662
- .map((asset) => {
663
- const normalizedPath = normalizeTaskDataRelativePath(asset.storageKey);
664
- if (!normalizedPath)
665
- return null;
666
- return {
667
- path: normalizedPath,
668
- deletedAt: String(asset.deletedAt || '').trim(),
669
- assetId: asset.assetId
670
- };
203
+ recordSyncDiagnostic(workspaceId, 'apply', 'Detected task reference number mismatch during normal sync.', {
204
+ source: 'tasks.referenceNumber.mismatch',
205
+ taskId: taskId || null,
206
+ taskTitle: typeof task?.title === 'string' ? task.title.trim() || null : null,
207
+ existingTaskId: existingTask.id,
208
+ existingTaskTitle: existingTask.title || null,
209
+ referenceLabel: `T-${incomingReferenceNumber}`,
210
+ existingReferenceNumber: existingTask.referenceNumber ?? incomingReferenceNumber,
211
+ incomingReferenceNumber
671
212
  });
672
- return entries.filter((entry) => Boolean(entry && entry.path && entry.deletedAt));
673
- };
674
- const writeWorkspaceSyncDocument = async (basePath, doc, workspaceId = 'default', options) => {
675
- const normalizedPath = normalizeWorkspaceSyncDocumentPath(doc.path);
676
- if (!normalizedPath)
677
- return;
678
- const root = path.resolve(basePath);
679
- const targetPath = path.resolve(root, normalizedPath);
680
- if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
681
- return;
682
- const objectStorageClient = getObjectStorageClient();
683
- const content = String(doc.content || '');
684
- if (objectStorageClient) {
685
- try {
686
- await objectStorageClient.putObject(normalizedPath, Buffer.from(content, 'utf8'), 'text/markdown');
687
- }
688
- catch (error) {
689
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to write document to object storage.', {
690
- source: 'documents.write.putObject',
691
- path: normalizedPath,
692
- error: String(error?.message || error || '')
693
- });
694
- return;
695
- }
696
- }
697
- else {
698
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
699
- fs.writeFileSync(targetPath, content, 'utf8');
700
- }
701
- const updatedAt = String(doc.updatedAt || '').trim();
702
- const watermarkMs = Date.parse(updatedAt);
703
- if (!objectStorageClient && Number.isFinite(watermarkMs) && watermarkMs > 0) {
704
- const when = new Date(watermarkMs);
705
- fs.utimesSync(targetPath, when, when);
706
- }
707
- // Persist content hash + timestamp to SQLite so the snapshot function uses a
708
- // stable watermark instead of relying on non-deterministic filesystem mtime.
709
- try {
710
- const contentHash = createHash('sha256').update(content).digest('hex');
711
- core.upsertDocumentWatermark(workspaceId, normalizedPath, contentHash, updatedAt || new Date().toISOString());
712
- if (workspaceAssetStore) {
713
- const existing = workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
714
- const effectiveUpdatedAt = updatedAt || new Date().toISOString();
715
- const originalFilename = String(doc.originalFilename || existing?.originalFilename || path.basename(normalizedPath) || 'document.md').trim() || 'document.md';
716
- const asset = workspaceAssetStore.upsertAsset({
717
- assetId: String(doc.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`),
718
- workspaceId,
719
- kind: 'document',
720
- logicalName: typeof doc.logicalName === 'string' && doc.logicalName.trim().length > 0
721
- ? doc.logicalName.trim()
722
- : existing?.logicalName || null,
723
- originalFilename,
724
- mimeType: existing?.mimeType || 'text/markdown',
725
- storageProvider: objectStorageClient ? 'r2' : 'local',
726
- storageKey: normalizedPath,
727
- contentSha256: contentHash,
728
- sizeBytes: Buffer.byteLength(content, 'utf8'),
729
- documentId: String(doc.documentId || existing?.documentId || `doc-${randomUUID().replace(/-/g, '')}`),
730
- referenceNumber: Number.isFinite(Number(doc.referenceNumber))
731
- ? Math.max(1, Math.floor(Number(doc.referenceNumber)))
732
- : existing?.referenceNumber || null,
733
- version: Number.isFinite(Number(doc.version))
734
- ? Math.max(1, Math.floor(Number(doc.version)))
735
- : Math.max(1, Number(existing?.version || 0) + 1),
736
- isLatest: true,
737
- scanStatus: existing?.scanStatus || 'clean',
738
- scanEngine: existing?.scanEngine || null,
739
- scanDetails: existing?.scanDetails || null,
740
- createdByUserId: existing?.createdByUserId || null,
741
- createdAt: existing?.createdAt || effectiveUpdatedAt,
742
- updatedAt: effectiveUpdatedAt,
743
- deletedAt: null,
744
- purgeAfter: null,
745
- allowReferenceNumberReassignment: options?.allowReferenceNumberReassignment === true
746
- });
747
- const taskId = String(doc.taskId || '').trim();
748
- reconcileWorkspaceSyncLinks({
749
- workspaceId,
750
- assetId: asset.assetId,
751
- taskId,
752
- displayName: typeof doc.caption === 'string' && doc.caption.trim().length > 0 ? doc.caption.trim() : null,
753
- role: doc.linkRole === 'reference' ? 'reference' : 'attachment',
754
- updatedAt: effectiveUpdatedAt
755
- });
756
- }
757
- }
758
- catch (error) {
759
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to persist document watermark after write.', {
760
- source: 'documents.write.upsertWatermark',
761
- path: normalizedPath,
762
- error: String(error?.message || error || '')
763
- });
764
- }
765
- workspaceDocumentSnapshotCache.get(workspaceId)?.delete(normalizedPath);
766
- };
767
- const writeWorkspaceSyncBinaryAsset = async (basePath, asset, workspaceId = 'default', options) => {
768
- const normalizedPath = normalizeTaskDataRelativePath(asset.path);
769
- if (!normalizedPath)
770
- return;
771
- const root = path.resolve(basePath);
772
- const targetPath = path.resolve(root, normalizedPath);
773
- if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
774
- return;
775
- const objectStorageClient = getObjectStorageClient();
776
- const buffer = Buffer.from(String(asset.contentBase64 || ''), 'base64');
777
- if (objectStorageClient) {
778
- try {
779
- await objectStorageClient.putObject(normalizedPath, buffer, String(asset.mimeType || 'application/octet-stream').trim() || 'application/octet-stream');
780
- }
781
- catch (error) {
782
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to write binary asset to object storage.', {
783
- source: 'assets.write.putObject',
784
- path: normalizedPath,
785
- error: String(error?.message || error || '')
786
- });
787
- return;
788
- }
789
- }
790
- else {
791
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
792
- fs.writeFileSync(targetPath, buffer);
793
- }
794
- const updatedAt = String(asset.updatedAt || '').trim();
795
- const watermarkMs = Date.parse(updatedAt);
796
- if (!objectStorageClient && Number.isFinite(watermarkMs) && watermarkMs > 0) {
797
- const when = new Date(watermarkMs);
798
- fs.utimesSync(targetPath, when, when);
799
- }
800
- if (!workspaceAssetStore)
801
- return;
802
- try {
803
- const existing = workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
804
- const effectiveUpdatedAt = updatedAt || new Date().toISOString();
805
- const assetRecord = workspaceAssetStore.upsertAsset({
806
- assetId: String(asset.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`),
807
- workspaceId,
808
- kind: asset.kind === 'image' ? 'image' : 'file',
809
- logicalName: typeof asset.logicalName === 'string' && asset.logicalName.trim().length > 0
810
- ? asset.logicalName.trim()
811
- : existing?.logicalName || null,
812
- originalFilename: String(asset.originalFilename || existing?.originalFilename || path.basename(normalizedPath) || 'asset').trim() || 'asset',
813
- mimeType: String(asset.mimeType || existing?.mimeType || 'application/octet-stream').trim() || 'application/octet-stream',
814
- storageProvider: objectStorageClient ? 'r2' : 'local',
815
- storageKey: normalizedPath,
816
- contentSha256: createHash('sha256').update(buffer).digest('hex'),
817
- sizeBytes: buffer.length,
818
- documentId: null,
819
- referenceNumber: Number.isFinite(Number(asset.referenceNumber))
820
- ? Math.max(1, Math.floor(Number(asset.referenceNumber)))
821
- : existing?.referenceNumber || null,
822
- version: null,
823
- isLatest: true,
824
- scanStatus: existing?.scanStatus || 'clean',
825
- scanEngine: existing?.scanEngine || null,
826
- scanDetails: existing?.scanDetails || null,
827
- createdByUserId: existing?.createdByUserId || null,
828
- createdAt: existing?.createdAt || effectiveUpdatedAt,
829
- updatedAt: effectiveUpdatedAt,
830
- deletedAt: null,
831
- purgeAfter: null,
832
- allowReferenceNumberReassignment: options?.allowReferenceNumberReassignment === true
833
- });
834
- const taskId = String(asset.taskId || '').trim();
835
- reconcileWorkspaceSyncLinks({
836
- workspaceId,
837
- assetId: assetRecord.assetId,
838
- taskId,
839
- displayName: typeof asset.caption === 'string' && asset.caption.trim().length > 0 ? asset.caption.trim() : null,
840
- role: asset.linkRole === 'reference' ? 'reference' : asset.kind === 'image' ? 'image' : 'attachment',
841
- updatedAt: effectiveUpdatedAt
842
- });
843
- }
844
- catch (error) {
845
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to persist binary asset metadata after write.', {
846
- source: 'assets.write.upsertAsset',
847
- path: normalizedPath,
848
- assetId: typeof asset.assetId === 'string' ? asset.assetId : null,
849
- kind: asset.kind,
850
- error: String(error?.message || error || '')
851
- });
852
- }
213
+ return true;
853
214
  };
854
- const deleteWorkspaceSyncBinaryAsset = (basePath, assetPath, workspaceId = 'default') => {
855
- const normalizedPath = normalizeTaskDataRelativePath(assetPath);
856
- if (!normalizedPath)
857
- return;
858
- const root = path.resolve(basePath);
859
- const targetPath = path.resolve(root, normalizedPath);
860
- const objectStorageClient = getObjectStorageClient();
861
- try {
862
- if (objectStorageClient) {
863
- void objectStorageClient.deleteObject(normalizedPath);
864
- }
865
- else if (targetPath === root || targetPath.startsWith(`${root}${path.sep}`)) {
866
- if (fs.existsSync(targetPath))
867
- fs.unlinkSync(targetPath);
868
- }
869
- }
870
- catch (error) {
871
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete workspace sync binary asset.', {
872
- source: 'assets.delete.file',
873
- path: normalizedPath,
874
- error: String(error?.message || error || '')
875
- });
876
- }
877
- if (workspaceAssetStore) {
878
- const existing = workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
879
- if (existing) {
880
- const deletedAt = new Date().toISOString();
881
- workspaceAssetStore.markDeleted(existing.assetId, workspaceId, deletedAt);
882
- if (existing.kind === 'image') {
883
- core.deleteAnnotatedAttachmentSessionsForImage({
884
- workspaceId,
885
- imageAssetId: existing.assetId,
886
- deletedAt,
887
- });
888
- }
889
- const links = workspaceAssetStore.listLinksForAsset(existing.assetId, workspaceId, { includeDeleted: true });
890
- for (const link of links) {
891
- workspaceAssetStore.deleteLink({
892
- workspaceId,
893
- assetId: existing.assetId,
894
- taskId: link.taskId,
895
- role: link.role
896
- });
897
- }
898
- }
899
- }
900
- };
901
- const deleteWorkspaceSyncDocument = (basePath, docPath, workspaceId = 'default') => {
902
- const normalizedPath = normalizeWorkspaceSyncDocumentPath(docPath);
903
- if (!normalizedPath)
904
- return;
905
- const root = path.resolve(basePath);
906
- const targetPath = path.resolve(root, normalizedPath);
907
- if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
908
- return;
909
- const objectStorageClient = getObjectStorageClient();
910
- try {
911
- if (objectStorageClient) {
912
- void objectStorageClient.deleteObject(normalizedPath);
913
- }
914
- else if (fs.existsSync(targetPath)) {
915
- fs.unlinkSync(targetPath);
916
- }
917
- }
918
- catch (error) {
919
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete workspace sync document.', {
920
- source: 'documents.delete.file',
921
- path: normalizedPath,
922
- error: String(error?.message || error || '')
923
- });
924
- }
925
- try {
926
- core.deleteDocumentWatermark(workspaceId, normalizedPath);
927
- if (workspaceAssetStore) {
928
- const existing = workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
929
- if (existing) {
930
- const deletedAt = new Date().toISOString();
931
- workspaceAssetStore.markDeleted(existing.assetId, workspaceId, deletedAt);
932
- if (existing.kind === 'image') {
933
- core.deleteAnnotatedAttachmentSessionsForImage({
934
- workspaceId,
935
- imageAssetId: existing.assetId,
936
- deletedAt,
937
- });
938
- }
939
- const links = workspaceAssetStore.listLinksForAsset(existing.assetId, workspaceId, { includeDeleted: true });
940
- for (const link of links) {
941
- workspaceAssetStore.deleteLink({
942
- workspaceId,
943
- assetId: existing.assetId,
944
- taskId: link.taskId,
945
- role: link.role
946
- });
947
- }
948
- }
949
- }
950
- }
951
- catch (error) {
952
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document watermark.', {
953
- source: 'documents.delete.watermark',
954
- path: normalizedPath,
955
- error: String(error?.message || error || '')
956
- });
957
- }
958
- workspaceDocumentSnapshotCache.get(workspaceId)?.delete(normalizedPath);
959
- };
960
- const resolveConflictOrdering = (incomingWatermark, existingWatermark, incomingTieBreak, existingTieBreak) => (compareWorkspaceMutationOrdering(incomingWatermark, existingWatermark, incomingTieBreak, existingTieBreak));
961
215
  const dispatchWorkspaceSyncChanges = async (workspaceId, incomingChanges, options) => {
962
216
  const tasks = [];
963
217
  const archived = [];
@@ -992,72 +246,32 @@ export function registerSyncRoutes(deps) {
992
246
  const raw = change;
993
247
  const op = String(raw.op || '').trim().toLowerCase();
994
248
  if (op === 'taxonomy-upsert') {
995
- if (raw.taxonomyState && typeof raw.taxonomyState === 'object' && !Array.isArray(raw.taxonomyState)) {
996
- incomingTaxonomyState = raw.taxonomyState;
997
- incomingTaxonomiesUpdatedAt = String(raw.updatedAt || raw.watermark || '').trim();
998
- }
999
- else if (Array.isArray(raw.taxonomies)) {
1000
- incomingTaxonomies = raw.taxonomies;
1001
- incomingTaxonomiesUpdatedAt = String(raw.updatedAt || raw.watermark || '').trim();
1002
- }
249
+ const taxonomy = normalizeTaxonomySyncPayload(raw);
250
+ if (taxonomy?.taxonomyState)
251
+ incomingTaxonomyState = taxonomy.taxonomyState;
252
+ if (Array.isArray(taxonomy?.taxonomies))
253
+ incomingTaxonomies = taxonomy.taxonomies;
254
+ if (taxonomy?.updatedAt)
255
+ incomingTaxonomiesUpdatedAt = taxonomy.updatedAt;
1003
256
  continue;
1004
257
  }
1005
258
  if (op === 'initiative-upsert') {
1006
- const initiative = raw.initiative && typeof raw.initiative === 'object'
1007
- ? raw.initiative
1008
- : null;
1009
- const initiativeId = String(initiative?.id || '').trim();
1010
- if (initiativeId) {
1011
- incomingInitiatives.set(initiativeId, {
1012
- id: initiativeId,
1013
- referenceNumber: Number.isFinite(Number(initiative?.referenceNumber))
1014
- ? Math.max(1, Math.floor(Number(initiative?.referenceNumber)))
1015
- : null,
1016
- referenceLabel: typeof initiative?.referenceLabel === 'string' ? initiative.referenceLabel.trim() : undefined,
1017
- title: String(initiative?.title || '').trim(),
1018
- description: typeof initiative?.description === 'string' ? initiative.description : null,
1019
- ownerId: typeof initiative?.ownerId === 'string' ? initiative.ownerId.trim() : null,
1020
- createdAt: String(initiative?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1021
- updatedAt: String(initiative?.updatedAt || raw.updatedAt || raw.watermark || initiative?.createdAt || '').trim(),
1022
- order: Number.isFinite(Number(initiative?.order)) ? Math.floor(Number(initiative?.order)) : null,
1023
- isArchived: Boolean(initiative?.isArchived)
1024
- });
1025
- }
259
+ const initiative = normalizeInitiativeSyncPayload(raw.initiative, String(raw.updatedAt || raw.watermark || '').trim());
260
+ if (initiative)
261
+ incomingInitiatives.set(initiative.id, initiative);
1026
262
  continue;
1027
263
  }
1028
264
  if (op === 'workstream-upsert') {
1029
- const workstream = raw.workstream && typeof raw.workstream === 'object'
1030
- ? raw.workstream
1031
- : null;
1032
- const workstreamId = String(workstream?.id || '').trim();
1033
- if (workstreamId) {
1034
- incomingWorkstreams.set(workstreamId, {
1035
- id: workstreamId,
1036
- referenceNumber: Number.isFinite(Number(workstream?.referenceNumber))
1037
- ? Math.max(1, Math.floor(Number(workstream?.referenceNumber)))
1038
- : null,
1039
- referenceLabel: typeof workstream?.referenceLabel === 'string' ? workstream.referenceLabel.trim() : undefined,
1040
- initiativeId: typeof workstream?.initiativeId === 'string' && workstream.initiativeId.trim().length > 0
1041
- ? workstream.initiativeId.trim()
1042
- : null,
1043
- title: String(workstream?.title || '').trim(),
1044
- description: typeof workstream?.description === 'string' ? workstream.description : null,
1045
- ownerId: typeof workstream?.ownerId === 'string' ? workstream.ownerId.trim() : null,
1046
- createdAt: String(workstream?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1047
- updatedAt: String(workstream?.updatedAt || raw.updatedAt || raw.watermark || workstream?.createdAt || '').trim(),
1048
- order: Number.isFinite(Number(workstream?.order)) ? Math.floor(Number(workstream?.order)) : null,
1049
- isArchived: Boolean(workstream?.isArchived)
1050
- });
1051
- }
265
+ const workstream = normalizeWorkstreamSyncPayload(raw.workstream, String(raw.updatedAt || raw.watermark || '').trim());
266
+ if (workstream)
267
+ incomingWorkstreams.set(workstream.id, workstream);
1052
268
  continue;
1053
269
  }
1054
270
  if (op === 'ai-profile-upsert') {
1055
- const profile = raw.profile && typeof raw.profile === 'object'
1056
- ? raw.profile
1057
- : null;
271
+ const profile = normalizeAiProfileSyncPayload(raw.profile, workspaceId, normalizeIncomingAiProfileSurfaceType, 'apply.aiProfileUpsert');
1058
272
  const profileId = String(profile?.id || '').trim();
1059
- if (profileId) {
1060
- const profileWorkspaceId = String(profile?.workspaceId || '').trim();
273
+ if (profileId && profile) {
274
+ const profileWorkspaceId = String(profile.workspaceId || '').trim();
1061
275
  if (profileWorkspaceId && profileWorkspaceId !== workspaceId) {
1062
276
  recordSyncDiagnostic(workspaceId, 'apply', `Rejected ai-profile-upsert for profile ${profileId}: workspace mismatch (expected ${workspaceId}, got ${profileWorkspaceId}).`, {
1063
277
  source: 'apply.aiProfileWorkspaceMismatch',
@@ -1069,185 +283,77 @@ export function registerSyncRoutes(deps) {
1069
283
  continue;
1070
284
  }
1071
285
  incomingAiProfiles.set(profileId, {
1072
- id: profileId,
286
+ ...profile,
1073
287
  workspaceId,
1074
- profileToken: String(profile?.profileToken || '').trim(),
1075
- name: String(profile?.name || '').trim(),
1076
- username: String(profile?.username || '').trim(),
1077
- icon: String(profile?.icon || 'Bot').trim() || 'Bot',
1078
- color: String(profile?.color || '#8b5cf6').trim() || '#8b5cf6',
1079
- surfaceType: normalizeIncomingAiProfileSurfaceType(profile?.surfaceType, {
1080
- source: 'apply.aiProfileUpsert',
1081
- profileId,
1082
- workspaceId
1083
- }),
1084
- providerMetadata: profile?.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
1085
- ? profile.providerMetadata
1086
- : null,
1087
- createdAt: String(profile?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1088
- updatedAt: String(profile?.updatedAt || raw.updatedAt || raw.watermark || '').trim(),
1089
- lastActiveAt: typeof profile?.lastActiveAt === 'string' && profile.lastActiveAt.trim().length > 0
1090
- ? profile.lastActiveAt.trim()
1091
- : null
288
+ createdAt: profile.createdAt || String(raw.updatedAt || raw.watermark || '').trim(),
289
+ updatedAt: profile.updatedAt || String(raw.updatedAt || raw.watermark || '').trim(),
1092
290
  });
1093
291
  }
1094
292
  continue;
1095
293
  }
1096
294
  if (op === 'document-upsert') {
1097
- const normalizedPath = normalizeWorkspaceSyncDocumentPath(raw.path);
1098
- if (normalizedPath) {
1099
- incomingDocuments.push({
1100
- path: normalizedPath,
1101
- updatedAt: String(raw.updatedAt || raw.watermark || '').trim(),
1102
- content: typeof raw.content === 'string' ? raw.content : '',
1103
- assetId: typeof raw.assetId === 'string' ? raw.assetId.trim() : undefined,
1104
- documentId: typeof raw.documentId === 'string' ? raw.documentId.trim() : null,
1105
- referenceNumber: Number.isFinite(Number(raw.referenceNumber))
1106
- ? Math.max(1, Math.floor(Number(raw.referenceNumber)))
1107
- : null,
1108
- version: Number.isFinite(Number(raw.version)) ? Math.max(1, Math.floor(Number(raw.version))) : null,
1109
- taskId: typeof raw.taskId === 'string' ? raw.taskId.trim() : null,
1110
- logicalName: typeof raw.logicalName === 'string' ? raw.logicalName.trim() : null,
1111
- caption: typeof raw.caption === 'string' ? raw.caption.trim() : null,
1112
- originalFilename: typeof raw.originalFilename === 'string' ? raw.originalFilename.trim() : null,
1113
- linkRole: raw.linkRole === 'reference' ? 'reference' : 'attachment'
1114
- });
1115
- }
295
+ const document = normalizeDocumentSyncPayload(raw, normalizeWorkspaceSyncDocumentPath);
296
+ if (document)
297
+ incomingDocuments.push(document);
1116
298
  continue;
1117
299
  }
1118
300
  if (op === 'document-delete') {
1119
- const normalizedPath = normalizeWorkspaceSyncDocumentPath(raw.path);
1120
- if (normalizedPath) {
1121
- incomingDocumentDeletes.set(normalizedPath, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
1122
- }
301
+ const deletedDocument = normalizeDocumentDeleteSyncPayload(raw, normalizeWorkspaceSyncDocumentPath);
302
+ if (deletedDocument)
303
+ incomingDocumentDeletes.set(deletedDocument.path, deletedDocument.deletedAt);
1123
304
  continue;
1124
305
  }
1125
306
  if (op === 'asset-upsert') {
1126
- const normalizedPath = normalizeTaskDataRelativePath(String(raw.path || '').trim());
1127
- if (normalizedPath) {
1128
- incomingAssets.push({
1129
- path: normalizedPath,
1130
- updatedAt: String(raw.updatedAt || raw.watermark || '').trim(),
1131
- contentBase64: typeof raw.contentBase64 === 'string' ? raw.contentBase64 : '',
1132
- assetId: typeof raw.assetId === 'string' ? raw.assetId.trim() : undefined,
1133
- kind: raw.kind === 'image' ? 'image' : 'file',
1134
- mimeType: typeof raw.mimeType === 'string' ? raw.mimeType.trim() : 'application/octet-stream',
1135
- referenceNumber: Number.isFinite(Number(raw.referenceNumber))
1136
- ? Math.max(1, Math.floor(Number(raw.referenceNumber)))
1137
- : null,
1138
- taskId: typeof raw.taskId === 'string' ? raw.taskId.trim() : null,
1139
- logicalName: typeof raw.logicalName === 'string' ? raw.logicalName.trim() : null,
1140
- caption: typeof raw.caption === 'string' ? raw.caption.trim() : null,
1141
- originalFilename: typeof raw.originalFilename === 'string' ? raw.originalFilename.trim() : null,
1142
- linkRole: raw.linkRole === 'reference' ? 'reference' : raw.linkRole === 'image' ? 'image' : 'attachment'
1143
- });
1144
- }
307
+ const asset = normalizeBinaryAssetSyncPayload(raw, (rawPath) => normalizeTaskDataRelativePath(String(rawPath || '').trim()));
308
+ if (asset)
309
+ incomingAssets.push(asset);
1145
310
  continue;
1146
311
  }
1147
312
  if (op === 'asset-delete') {
1148
- const normalizedPath = normalizeTaskDataRelativePath(String(raw.path || '').trim());
1149
- if (normalizedPath) {
1150
- incomingAssetDeletes.set(normalizedPath, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
1151
- }
313
+ const deletedAsset = normalizeBinaryAssetDeleteSyncPayload(raw, (rawPath) => normalizeTaskDataRelativePath(String(rawPath || '').trim()));
314
+ if (deletedAsset)
315
+ incomingAssetDeletes.set(deletedAsset.path, deletedAsset.deletedAt);
1152
316
  continue;
1153
317
  }
1154
318
  if (op === 'document-review-session-upsert') {
1155
- const session = raw.session && typeof raw.session === 'object'
1156
- ? raw.session
1157
- : null;
1158
- const sessionId = String(session?.id || '').trim();
1159
- const assetId = String(session?.assetId || '').trim();
1160
- if (sessionId && assetId) {
1161
- incomingDocumentReviewSessions.set(sessionId, {
1162
- id: sessionId,
1163
- assetId,
1164
- documentId: typeof session?.documentId === 'string' ? session.documentId.trim() : null,
1165
- documentVersion: Number.isFinite(Number(session?.documentVersion)) ? Math.max(1, Math.floor(Number(session?.documentVersion))) : null,
1166
- title: typeof session?.title === 'string' ? session.title.trim() : null,
1167
- status: session?.status === 'resolved' ? 'resolved' : 'open',
1168
- comments: Array.isArray(session?.comments) ? session.comments : [],
1169
- createdByActorId: typeof session?.createdByActorId === 'string' ? session.createdByActorId.trim() : null,
1170
- updatedByActorId: typeof session?.updatedByActorId === 'string' ? session.updatedByActorId.trim() : null,
1171
- createdAt: String(session?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1172
- updatedAt: String(session?.updatedAt || raw.updatedAt || raw.watermark || session?.createdAt || '').trim(),
1173
- deletedAt: typeof session?.deletedAt === 'string' && session.deletedAt.trim().length > 0 ? session.deletedAt.trim() : null,
1174
- });
1175
- }
319
+ const session = normalizeDocumentReviewSessionSyncPayload(raw.session, String(raw.updatedAt || raw.watermark || '').trim());
320
+ if (session)
321
+ incomingDocumentReviewSessions.set(session.id, session);
1176
322
  continue;
1177
323
  }
1178
324
  if (op === 'document-review-session-delete') {
1179
- const sessionId = String(raw.sessionId || raw.id || '').trim();
1180
- if (sessionId) {
1181
- incomingDocumentReviewSessionDeletes.set(sessionId, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
1182
- }
325
+ const deletedSession = normalizeDeleteSyncPayload(raw, 'sessionId');
326
+ if (deletedSession)
327
+ incomingDocumentReviewSessionDeletes.set(deletedSession.id, deletedSession.deletedAt);
1183
328
  continue;
1184
329
  }
1185
330
  if (op === 'document-review-comment-upsert') {
1186
- const comment = raw.comment && typeof raw.comment === 'object'
1187
- ? raw.comment
1188
- : null;
1189
- const commentId = String(comment?.id || '').trim();
1190
- const sessionId = String(comment?.sessionId || '').trim();
1191
- if (commentId && sessionId) {
1192
- incomingDocumentReviewComments.set(commentId, {
1193
- id: commentId,
1194
- sessionId,
1195
- body: typeof comment?.body === 'string' ? comment.body : '',
1196
- anchor: comment?.anchor ?? null,
1197
- order: Number.isFinite(Number(comment?.order)) ? Math.max(0, Math.floor(Number(comment?.order))) : 0,
1198
- authorActorId: typeof comment?.authorActorId === 'string' ? comment.authorActorId.trim() : null,
1199
- createdAt: String(comment?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1200
- updatedAt: String(comment?.updatedAt || raw.updatedAt || raw.watermark || comment?.createdAt || '').trim(),
1201
- deletedAt: typeof comment?.deletedAt === 'string' && comment.deletedAt.trim().length > 0 ? comment.deletedAt.trim() : null,
1202
- });
1203
- }
331
+ const comment = normalizeDocumentReviewCommentSyncPayload(raw.comment, { sourceWatermark: String(raw.updatedAt || raw.watermark || '').trim() });
332
+ if (comment)
333
+ incomingDocumentReviewComments.set(comment.id, comment);
1204
334
  continue;
1205
335
  }
1206
336
  if (op === 'document-review-comment-delete') {
1207
- const commentId = String(raw.commentId || raw.id || '').trim();
1208
- if (commentId) {
1209
- incomingDocumentReviewCommentDeletes.set(commentId, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
1210
- }
337
+ const deletedComment = normalizeDeleteSyncPayload(raw, 'commentId');
338
+ if (deletedComment)
339
+ incomingDocumentReviewCommentDeletes.set(deletedComment.id, deletedComment.deletedAt);
1211
340
  continue;
1212
341
  }
1213
342
  if (op === 'annotated-attachment-session-upsert') {
1214
- const session = raw.session && typeof raw.session === 'object'
1215
- ? raw.session
1216
- : null;
1217
- const sessionId = String(session?.id || '').trim();
1218
- const sessionWorkspaceId = String(session?.workspaceId || '').trim();
1219
- const taskId = String(session?.taskId || '').trim();
1220
- const baseImageAssetId = String(session?.baseImageAssetId || '').trim();
1221
- if (sessionId
1222
- && sessionWorkspaceId === workspaceId
1223
- && baseImageAssetId) {
1224
- incomingAnnotatedAttachmentSessions.set(sessionId, {
1225
- id: sessionId,
1226
- workspaceId: sessionWorkspaceId,
1227
- taskId,
1228
- baseImageAssetId,
1229
- title: typeof session?.title === 'string' ? session.title.trim() : null,
1230
- globalInstruction: typeof session?.globalInstruction === 'string' ? session.globalInstruction.trim() : null,
1231
- annotations: Array.isArray(session?.annotations) ? session.annotations : [],
1232
- createdByActorId: typeof session?.createdByActorId === 'string' ? session.createdByActorId.trim() : null,
1233
- updatedByActorId: typeof session?.updatedByActorId === 'string' ? session.updatedByActorId.trim() : null,
1234
- createdAt: String(session?.createdAt || raw.updatedAt || raw.watermark || '').trim(),
1235
- updatedAt: String(session?.updatedAt || raw.updatedAt || raw.watermark || session?.createdAt || '').trim(),
1236
- deletedAt: typeof session?.deletedAt === 'string' && session.deletedAt.trim().length > 0 ? session.deletedAt.trim() : null,
1237
- });
1238
- }
343
+ const session = normalizeAnnotatedAttachmentSessionSyncPayload(raw.session, workspaceId, String(raw.updatedAt || raw.watermark || '').trim());
344
+ if (session)
345
+ incomingAnnotatedAttachmentSessions.set(session.id, session);
1239
346
  continue;
1240
347
  }
1241
348
  if (op === 'annotated-attachment-session-delete') {
1242
- const sessionId = String(raw.sessionId || raw.id || '').trim();
1243
- if (sessionId) {
1244
- incomingAnnotatedAttachmentSessionDeletes.set(sessionId, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
1245
- }
349
+ const deletedSession = normalizeDeleteSyncPayload(raw, 'sessionId');
350
+ if (deletedSession)
351
+ incomingAnnotatedAttachmentSessionDeletes.set(deletedSession.id, deletedSession.deletedAt);
1246
352
  continue;
1247
353
  }
1248
354
  if (op !== 'delete')
1249
355
  continue;
1250
- const task = raw.task;
356
+ const task = normalizeTaskSyncPayload(raw.task);
1251
357
  const deleteTaskId = String(raw.taskId || raw.id || task?.id || '').trim();
1252
358
  if (deleteTaskId) {
1253
359
  incomingTaskDeletes.set(deleteTaskId, String(raw.deletedAt || raw.watermark || new Date().toISOString()).trim());
@@ -1270,13 +376,14 @@ export function registerSyncRoutes(deps) {
1270
376
  || op === 'annotated-attachment-session-upsert'
1271
377
  || op === 'annotated-attachment-session-delete')
1272
378
  continue;
1273
- const task = raw.task && typeof raw.task === 'object' ? raw.task : null;
379
+ const task = normalizeTaskSyncPayload(raw.task);
1274
380
  if (!task)
1275
381
  continue;
1276
382
  const taskId = String(task.id || '').trim();
383
+ hasTaskReferenceNumberDrift(workspaceId, task);
1277
384
  const deleteWatermark = taskId ? String(incomingTaskDeletes.get(taskId) || '').trim() : '';
1278
385
  if (taskId && deleteWatermark) {
1279
- const compareAgainstDelete = resolveConflictOrdering(deleteWatermark, String(task.updatedAt || task.createdAt || '').trim(), `delete:${taskId}`, getTaskMutationTieBreak(task, raw.archived === true || task?.isArchived === true));
386
+ const compareAgainstDelete = resolveSyncConflictOrdering(deleteWatermark, String(task.updatedAt || task.createdAt || '').trim(), `delete:${taskId}`, getTaskMutationTieBreak(task, raw.archived === true || task?.isArchived === true));
1280
387
  if (compareAgainstDelete >= 0)
1281
388
  continue;
1282
389
  }
@@ -1290,7 +397,7 @@ export function registerSyncRoutes(deps) {
1290
397
  try {
1291
398
  const existingTask = core.getTask(taskId, workspaceId);
1292
399
  if (existingTask) {
1293
- const compareAgainstExisting = resolveConflictOrdering(deleteWatermark, getTaskMutationWatermark(existingTask), `delete:${taskId}`, getTaskMutationTieBreak(existingTask, Boolean(existingTask.isArchived)));
400
+ const compareAgainstExisting = resolveSyncConflictOrdering(deleteWatermark, getTaskMutationWatermark(existingTask), `delete:${taskId}`, getTaskMutationTieBreak(existingTask, Boolean(existingTask.isArchived)));
1294
401
  if (compareAgainstExisting < 0)
1295
402
  continue;
1296
403
  }
@@ -1304,435 +411,62 @@ export function registerSyncRoutes(deps) {
1304
411
  });
1305
412
  }
1306
413
  }
1307
- if (incomingInitiatives.size > 0) {
1308
- for (const initiative of incomingInitiatives.values()) {
1309
- try {
1310
- core.upsertInitiativeForSync({
1311
- id: initiative.id,
1312
- referenceNumber: initiative.referenceNumber,
1313
- referenceLabel: initiative.referenceLabel,
1314
- title: initiative.title,
1315
- description: initiative.description,
1316
- ownerId: initiative.ownerId,
1317
- createdAt: initiative.createdAt,
1318
- updatedAt: initiative.updatedAt,
1319
- order: initiative.order,
1320
- isArchived: initiative.isArchived
1321
- }, workspaceId);
1322
- }
1323
- catch (error) {
1324
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert initiative during sync apply.', {
1325
- source: 'apply.initiativeUpsert',
1326
- initiativeId: initiative.id,
1327
- error: String(error?.message || error || '')
1328
- });
1329
- throw error;
1330
- }
1331
- }
1332
- }
1333
- if (incomingWorkstreams.size > 0) {
1334
- for (const workstream of incomingWorkstreams.values()) {
1335
- try {
1336
- core.upsertWorkstreamForSync({
1337
- id: workstream.id,
1338
- referenceNumber: workstream.referenceNumber,
1339
- referenceLabel: workstream.referenceLabel,
1340
- initiativeId: workstream.initiativeId,
1341
- title: workstream.title,
1342
- description: workstream.description,
1343
- ownerId: workstream.ownerId,
1344
- createdAt: workstream.createdAt,
1345
- updatedAt: workstream.updatedAt,
1346
- order: workstream.order,
1347
- isArchived: workstream.isArchived
1348
- }, workspaceId);
1349
- }
1350
- catch (error) {
1351
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert workstream during sync apply.', {
1352
- source: 'apply.workstreamUpsert',
1353
- workstreamId: workstream.id,
1354
- error: String(error?.message || error || '')
1355
- });
1356
- throw error;
1357
- }
1358
- }
1359
- }
1360
- if (incomingTaxonomyState || Array.isArray(incomingTaxonomies)) {
1361
- const nextTaxonomyState = incomingTaxonomyState || { taxonomies: incomingTaxonomies || [] };
1362
- if (!incomingTaxonomiesUpdatedAt) {
1363
- if (typeof core.updateTaxonomyState === 'function') {
1364
- core.updateTaxonomyState(nextTaxonomyState, workspaceId);
1365
- }
1366
- else {
1367
- core.updateTaxonomies(Array.isArray(incomingTaxonomies) ? incomingTaxonomies : []);
1368
- }
1369
- const fallbackHash = createHash('sha256').update(JSON.stringify(nextTaxonomyState)).digest('hex');
1370
- workspaceTaxonomySyncMeta.set(workspaceId, {
1371
- updatedAt: new Date().toISOString(),
1372
- hash: fallbackHash
1373
- });
1374
- }
1375
- else {
1376
- const incomingTaxonomyHash = createHash('sha256').update(JSON.stringify(nextTaxonomyState)).digest('hex');
1377
- const currentMeta = workspaceTaxonomySyncMeta.get(workspaceId) || { updatedAt: '', hash: '' };
1378
- const compare = resolveConflictOrdering(incomingTaxonomiesUpdatedAt, currentMeta.updatedAt, incomingTaxonomyHash, currentMeta.hash);
1379
- if (compare >= 0) {
1380
- if (typeof core.updateTaxonomyState === 'function') {
1381
- core.updateTaxonomyState(nextTaxonomyState, workspaceId);
1382
- }
1383
- else {
1384
- core.updateTaxonomies(Array.isArray(incomingTaxonomies) ? incomingTaxonomies : []);
1385
- }
1386
- workspaceTaxonomySyncMeta.set(workspaceId, {
1387
- updatedAt: incomingTaxonomiesUpdatedAt,
1388
- hash: incomingTaxonomyHash
1389
- });
1390
- }
1391
- }
1392
- }
1393
- if (incomingAiProfiles.size > 0) {
1394
- for (const profile of incomingAiProfiles.values()) {
1395
- try {
1396
- core.upsertAiProfileForSync({
1397
- id: profile.id,
1398
- workspaceId: workspaceId,
1399
- profileToken: profile.profileToken,
1400
- name: profile.name,
1401
- username: profile.username,
1402
- icon: profile.icon,
1403
- color: profile.color,
1404
- surfaceType: profile.surfaceType,
1405
- providerMetadata: profile.providerMetadata,
1406
- createdAt: profile.createdAt,
1407
- updatedAt: profile.updatedAt,
1408
- lastActiveAt: profile.lastActiveAt
1409
- });
1410
- }
1411
- catch (error) {
1412
- const details = {
1413
- source: 'apply.aiProfileUpsert',
1414
- workspaceId,
1415
- profileId: profile.id,
1416
- error: String(error?.message || error || ''),
1417
- errorCode: String(error?.code || '').trim() || null
1418
- };
1419
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert AI profile during sync apply.', details);
1420
- throw createSyncRouteError(`Failed to persist AI profile ${profile.id} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'AI_PROFILE_SYNC_APPLY_FAILED', statusCode: 500 });
1421
- }
1422
- }
1423
- assertSyncedAiProfilesPersisted(workspaceId, incomingAiProfiles);
1424
- }
1425
- if (Array.isArray(options?.workspaceMembers)) {
1426
- core.replaceWorkspaceUsersForSync({
1427
- workspaceId,
1428
- users: options.workspaceMembers
1429
- .filter((member) => String(member?.workspaceId || workspaceId).trim() === workspaceId)
1430
- .map((member) => ({
1431
- userId: member.userId,
1432
- email: member.email,
1433
- displayName: member.displayName,
1434
- role: member.role,
1435
- permissionMode: member.permissionMode,
1436
- betaAccess: member.betaAccess,
1437
- status: member.status,
1438
- disabled: member.disabled,
1439
- createdAt: member.createdAt,
1440
- updatedAt: member.updatedAt,
1441
- emailVerifiedAt: member.emailVerifiedAt
1442
- }))
1443
- });
1444
- }
414
+ applyPlanningSyncChanges({
415
+ core,
416
+ workspaceId,
417
+ incomingInitiatives,
418
+ incomingWorkstreams,
419
+ incomingTaxonomies,
420
+ incomingTaxonomyState,
421
+ incomingTaxonomiesUpdatedAt,
422
+ workspaceTaxonomySyncMeta,
423
+ recordSyncDiagnostic,
424
+ });
425
+ applyAiProfileAndMemberSyncChanges({
426
+ core,
427
+ workspaceId,
428
+ incomingAiProfiles,
429
+ workspaceMembers: options?.workspaceMembers,
430
+ recordSyncDiagnostic,
431
+ assertSyncedAiProfilesPersisted,
432
+ createSyncRouteError,
433
+ });
1445
434
  validateTaskActorAssignments(workspaceId, tasks, archived);
1446
- if (incomingDocuments.length > 0) {
1447
- for (const doc of incomingDocuments) {
1448
- try {
1449
- const existingDoc = core.getDocumentWatermark(workspaceId, doc.path);
1450
- const incomingHash = createHash('sha256').update(String(doc.content || '')).digest('hex');
1451
- const existingUpdatedAt = String(existingDoc?.updatedAt || '').trim();
1452
- const existingHash = String(existingDoc?.contentHash || '').trim();
1453
- const compare = resolveConflictOrdering(doc.updatedAt, existingUpdatedAt, incomingHash, existingHash);
1454
- const metadataDrift = hasDocumentSyncMetadataDrift(workspaceId, doc, options);
1455
- if (compare < 0)
1456
- continue;
1457
- if (compare === 0 && existingHash === incomingHash && !metadataDrift)
1458
- continue;
1459
- await writeWorkspaceSyncDocument(core.getPaths().basePath, doc, workspaceId, options);
1460
- }
1461
- catch (error) {
1462
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to apply synced document change.', {
1463
- source: 'documents.apply.write',
1464
- path: doc.path,
1465
- assetId: typeof doc.assetId === 'string' ? doc.assetId : null,
1466
- error: String(error?.message || error || '')
1467
- });
1468
- }
1469
- }
1470
- }
1471
- if (incomingDocumentDeletes.size > 0) {
1472
- for (const [docPath, deletedAt] of incomingDocumentDeletes.entries()) {
1473
- const existingDoc = core.getDocumentWatermark(workspaceId, docPath);
1474
- const existingUpdatedAt = String(existingDoc?.updatedAt || '').trim();
1475
- const existingHash = String(existingDoc?.contentHash || '').trim();
1476
- const compare = resolveConflictOrdering(deletedAt, existingUpdatedAt, `delete:${docPath}`, existingHash);
1477
- if (existingDoc && compare < 0)
1478
- continue;
1479
- deleteWorkspaceSyncDocument(core.getPaths().basePath, docPath, workspaceId);
1480
- }
1481
- }
1482
- if (incomingAssets.length > 0) {
1483
- for (const asset of incomingAssets) {
1484
- try {
1485
- const existing = workspaceAssetStore?.getByStorageKey(asset.path, workspaceId);
1486
- const incomingHash = createHash('sha256').update(Buffer.from(String(asset.contentBase64 || ''), 'base64')).digest('hex');
1487
- const existingUpdatedAt = String(existing?.updatedAt || '').trim();
1488
- const existingHash = String(existing?.contentSha256 || '').trim();
1489
- const assetPath = path.resolve(core.getPaths().basePath, asset.path);
1490
- const localFileMissing = !getObjectStorageClient() && !fs.existsSync(assetPath);
1491
- const compare = resolveConflictOrdering(asset.updatedAt, existingUpdatedAt, incomingHash, existingHash);
1492
- const metadataDrift = hasBinaryAssetSyncMetadataDrift(workspaceId, asset, options);
1493
- if (compare < 0)
1494
- continue;
1495
- if (compare === 0 && existingHash === incomingHash && !localFileMissing && !metadataDrift)
1496
- continue;
1497
- await writeWorkspaceSyncBinaryAsset(core.getPaths().basePath, asset, workspaceId, options);
1498
- }
1499
- catch (error) {
1500
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to apply synced asset change.', {
1501
- source: 'assets.apply.write',
1502
- path: asset.path,
1503
- assetId: typeof asset.assetId === 'string' ? asset.assetId : null,
1504
- kind: asset.kind,
1505
- error: String(error?.message || error || '')
1506
- });
1507
- }
1508
- }
1509
- }
1510
- if (incomingAssetDeletes.size > 0) {
1511
- for (const [assetPath, deletedAt] of incomingAssetDeletes.entries()) {
1512
- const existing = workspaceAssetStore?.getByStorageKey(assetPath, workspaceId);
1513
- const existingUpdatedAt = String(existing?.updatedAt || '').trim();
1514
- const existingHash = String(existing?.contentSha256 || '').trim();
1515
- const compare = resolveConflictOrdering(deletedAt, existingUpdatedAt, `delete:${assetPath}`, existingHash);
1516
- if (existing && compare < 0)
1517
- continue;
1518
- deleteWorkspaceSyncBinaryAsset(core.getPaths().basePath, assetPath, workspaceId);
1519
- }
1520
- }
1521
- if (incomingDocumentReviewSessions.size > 0) {
1522
- for (const session of incomingDocumentReviewSessions.values()) {
1523
- try {
1524
- const existing = core.getDocumentReviewSession({
1525
- workspaceId,
1526
- sessionId: session.id,
1527
- includeDeleted: true
1528
- });
1529
- const incomingTieBreak = createHash('sha256').update(JSON.stringify(session.comments || [])).digest('hex');
1530
- const existingTieBreak = existing
1531
- ? createHash('sha256').update(JSON.stringify(existing.comments || [])).digest('hex')
1532
- : '';
1533
- const compare = resolveConflictOrdering(session.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
1534
- if (compare < 0)
1535
- continue;
1536
- core.upsertDocumentReviewSessionForSync({
1537
- id: session.id,
1538
- tenantId: core.getTenantId(),
1539
- workspaceId,
1540
- assetId: session.assetId,
1541
- documentId: session.documentId,
1542
- documentVersion: session.documentVersion,
1543
- title: session.title,
1544
- status: session.status,
1545
- comments: [],
1546
- createdByActorId: session.createdByActorId,
1547
- updatedByActorId: session.updatedByActorId,
1548
- createdAt: session.createdAt,
1549
- updatedAt: session.updatedAt,
1550
- deletedAt: session.deletedAt,
1551
- });
1552
- }
1553
- catch (error) {
1554
- const details = {
1555
- source: 'apply.documentReviewUpsert',
1556
- sessionId: session.id,
1557
- error: String(error?.message || error || ''),
1558
- errorCode: String(error?.code || '').trim() || null
1559
- };
1560
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert document review session during sync apply.', details);
1561
- throw createSyncRouteError(`Failed to persist document review session ${session.id} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'DOCUMENT_REVIEW_SESSION_SYNC_APPLY_FAILED', statusCode: 500 });
1562
- }
1563
- }
1564
- }
1565
- if (incomingDocumentReviewComments.size > 0) {
1566
- for (const comment of incomingDocumentReviewComments.values()) {
1567
- try {
1568
- const existingComments = core.listDocumentReviewCommentsForSync({
1569
- workspaceId,
1570
- includeDeleted: true
1571
- });
1572
- const existing = existingComments.find((entry) => entry.id === comment.id) || null;
1573
- const incomingTieBreak = createHash('sha256')
1574
- .update(JSON.stringify({ body: comment.body, anchor: comment.anchor, deletedAt: comment.deletedAt }))
1575
- .digest('hex');
1576
- const existingTieBreak = existing
1577
- ? createHash('sha256')
1578
- .update(JSON.stringify({ body: existing.body, anchor: existing.anchor, deletedAt: existing.deletedAt }))
1579
- .digest('hex')
1580
- : '';
1581
- const compare = resolveConflictOrdering(comment.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
1582
- if (compare < 0)
1583
- continue;
1584
- core.upsertDocumentReviewCommentForSync({
1585
- id: comment.id,
1586
- tenantId: core.getTenantId(),
1587
- workspaceId,
1588
- sessionId: comment.sessionId,
1589
- body: comment.body,
1590
- anchor: comment.anchor,
1591
- order: comment.order,
1592
- authorActorId: comment.authorActorId,
1593
- createdAt: comment.createdAt,
1594
- updatedAt: comment.updatedAt,
1595
- deletedAt: comment.deletedAt,
1596
- });
1597
- }
1598
- catch (error) {
1599
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert document review comment during sync apply.', {
1600
- source: 'apply.documentReviewCommentUpsert',
1601
- commentId: comment.id,
1602
- error: String(error?.message || error || '')
1603
- });
1604
- throw error;
1605
- }
1606
- }
1607
- }
1608
- if (incomingDocumentReviewSessionDeletes.size > 0) {
1609
- for (const [sessionId, deletedAt] of incomingDocumentReviewSessionDeletes.entries()) {
1610
- try {
1611
- const existing = core.getDocumentReviewSession({
1612
- workspaceId,
1613
- sessionId,
1614
- includeDeleted: true
1615
- });
1616
- const compare = resolveConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${sessionId}`, String(existing?.id || '').trim());
1617
- if (existing && compare < 0)
1618
- continue;
1619
- core.deleteDocumentReviewSessionForSync({ workspaceId, sessionId, deletedAt });
1620
- }
1621
- catch (error) {
1622
- const details = {
1623
- source: 'apply.documentReviewDelete',
1624
- sessionId,
1625
- error: String(error?.message || error || ''),
1626
- errorCode: String(error?.code || '').trim() || null
1627
- };
1628
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document review session during sync apply.', details);
1629
- throw createSyncRouteError(`Failed to delete document review session ${sessionId} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'DOCUMENT_REVIEW_SESSION_SYNC_DELETE_FAILED', statusCode: 500 });
1630
- }
1631
- }
1632
- }
1633
- if (incomingDocumentReviewCommentDeletes.size > 0) {
1634
- for (const [commentId, deletedAt] of incomingDocumentReviewCommentDeletes.entries()) {
1635
- try {
1636
- const existing = core.listDocumentReviewCommentsForSync({
1637
- workspaceId,
1638
- includeDeleted: true
1639
- }).find((entry) => entry.id === commentId) || null;
1640
- const compare = resolveConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${commentId}`, String(existing?.id || '').trim());
1641
- if (existing && compare < 0)
1642
- continue;
1643
- core.deleteDocumentReviewCommentForSync({ workspaceId, commentId, deletedAt });
1644
- }
1645
- catch (error) {
1646
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document review comment during sync apply.', {
1647
- source: 'apply.documentReviewCommentDelete',
1648
- commentId,
1649
- error: String(error?.message || error || '')
1650
- });
1651
- throw error;
1652
- }
1653
- }
1654
- }
1655
- if (incomingAnnotatedAttachmentSessions.size > 0) {
1656
- for (const session of incomingAnnotatedAttachmentSessions.values()) {
1657
- try {
1658
- const existing = core.getAnnotatedAttachmentSession({
1659
- workspaceId,
1660
- sessionId: session.id,
1661
- includeDeleted: true
1662
- });
1663
- const incomingTieBreak = createHash('sha256').update(JSON.stringify(session.annotations || [])).digest('hex');
1664
- const existingTieBreak = existing
1665
- ? createHash('sha256').update(JSON.stringify(existing.annotations || [])).digest('hex')
1666
- : '';
1667
- const compare = resolveConflictOrdering(session.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
1668
- if (compare < 0)
1669
- continue;
1670
- core.upsertAnnotatedAttachmentSessionForSync({
1671
- id: session.id,
1672
- tenantId: core.getTenantId(),
1673
- workspaceId,
1674
- taskId: session.taskId,
1675
- baseImageAssetId: session.baseImageAssetId,
1676
- title: session.title,
1677
- globalInstruction: session.globalInstruction,
1678
- annotations: session.annotations,
1679
- createdByActorId: session.createdByActorId,
1680
- updatedByActorId: session.updatedByActorId,
1681
- createdAt: session.createdAt,
1682
- updatedAt: session.updatedAt,
1683
- deletedAt: session.deletedAt,
1684
- });
1685
- }
1686
- catch (error) {
1687
- const details = {
1688
- source: 'apply.annotatedAttachmentUpsert',
1689
- sessionId: session.id,
1690
- error: String(error?.message || error || ''),
1691
- errorCode: String(error?.code || '').trim() || null
1692
- };
1693
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert annotated attachment session during sync apply.', details);
1694
- throw createSyncRouteError(`Failed to persist annotated attachment session ${session.id} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'ANNOTATED_ATTACHMENT_SESSION_SYNC_APPLY_FAILED', statusCode: 500 });
1695
- }
1696
- }
1697
- }
1698
- if (incomingAnnotatedAttachmentSessionDeletes.size > 0) {
1699
- for (const [sessionId, deletedAt] of incomingAnnotatedAttachmentSessionDeletes.entries()) {
1700
- try {
1701
- const existing = core.getAnnotatedAttachmentSession({
1702
- workspaceId,
1703
- sessionId,
1704
- includeDeleted: true
1705
- });
1706
- const compare = resolveConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${sessionId}`, String(existing?.id || '').trim());
1707
- if (existing && compare < 0)
1708
- continue;
1709
- core.deleteAnnotatedAttachmentSessionForSync({ workspaceId, sessionId, deletedAt });
1710
- }
1711
- catch (error) {
1712
- const details = {
1713
- source: 'apply.annotatedAttachmentDelete',
1714
- sessionId,
1715
- error: String(error?.message || error || ''),
1716
- errorCode: String(error?.code || '').trim() || null
1717
- };
1718
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete annotated attachment session during sync apply.', details);
1719
- throw createSyncRouteError(`Failed to delete annotated attachment session ${sessionId} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'ANNOTATED_ATTACHMENT_SESSION_SYNC_DELETE_FAILED', statusCode: 500 });
1720
- }
1721
- }
1722
- }
1723
- if (incomingDocuments.length > 0 || incomingAssets.length > 0) {
1724
- core.notifyDocumentMetadataMutation(workspaceId, [
1725
- ...incomingDocuments.map((entry) => entry.path),
1726
- ...incomingAssets.map((entry) => entry.path)
1727
- ], 'upsert');
1728
- }
1729
- if (incomingDocumentDeletes.size > 0 || incomingAssetDeletes.size > 0) {
1730
- core.notifyDocumentMetadataMutation(workspaceId, [
1731
- ...Array.from(incomingDocumentDeletes.keys()),
1732
- ...Array.from(incomingAssetDeletes.keys())
1733
- ], 'delete');
1734
- }
1735
- const result = core.applyWorkspaceSyncSnapshot({ tasks, archived }, workspaceId);
435
+ await applyContentSyncChanges({
436
+ core,
437
+ workspaceId,
438
+ incomingDocuments,
439
+ incomingDocumentDeletes,
440
+ incomingAssets,
441
+ incomingAssetDeletes,
442
+ basePath: core.getPaths().basePath,
443
+ hasObjectStorageClient: () => Boolean(getObjectStorageClient()),
444
+ hasDocumentSyncMetadataDrift: contentSyncState.hasDocumentSyncMetadataDrift,
445
+ hasBinaryAssetSyncMetadataDrift: contentSyncState.hasBinaryAssetSyncMetadataDrift,
446
+ writeWorkspaceSyncDocument: contentSyncState.writeWorkspaceSyncDocument,
447
+ deleteWorkspaceSyncDocument: contentSyncState.deleteWorkspaceSyncDocument,
448
+ writeWorkspaceSyncBinaryAsset: contentSyncState.writeWorkspaceSyncBinaryAsset,
449
+ deleteWorkspaceSyncBinaryAsset: contentSyncState.deleteWorkspaceSyncBinaryAsset,
450
+ getExistingBinaryAsset: (assetPath, targetWorkspaceId) => workspaceAssetStore?.getByStorageKey(assetPath, targetWorkspaceId) || null,
451
+ recordSyncDiagnostic,
452
+ options,
453
+ });
454
+ applyCollaborationSyncChanges({
455
+ core,
456
+ workspaceId,
457
+ incomingDocumentReviewSessions,
458
+ incomingDocumentReviewSessionDeletes,
459
+ incomingDocumentReviewComments,
460
+ incomingDocumentReviewCommentDeletes,
461
+ incomingAnnotatedAttachmentSessions,
462
+ incomingAnnotatedAttachmentSessionDeletes,
463
+ recordSyncDiagnostic,
464
+ createSyncRouteError,
465
+ });
466
+ const result = core.applyWorkspaceSyncSnapshot({
467
+ tasks: tasks.map(taskSyncPayloadToCoreTask),
468
+ archived: archived.map(taskSyncPayloadToCoreTask)
469
+ }, workspaceId);
1736
470
  return { result, deleteCount: incomingTaskDeletes.size, emittedEventIds: Array.from(emittedEventIds) };
1737
471
  }
1738
472
  finally {
@@ -2000,135 +734,54 @@ export function registerSyncRoutes(deps) {
2000
734
  const incomingAssets = Array.isArray(body?.assets) ? body.assets : [];
2001
735
  try {
2002
736
  const bootstrapChanges = [
2003
- ...((incomingTaxonomyState || incomingTaxonomies) ? [{
737
+ ...((incomingTaxonomyState || incomingTaxonomies)
738
+ ? [normalizeTaxonomySyncPayload({
739
+ taxonomies: incomingTaxonomies,
740
+ taxonomyState: incomingTaxonomyState || undefined,
741
+ updatedAt: new Date().toISOString()
742
+ })]
743
+ .filter((entry) => Boolean(entry))
744
+ .map((entry) => ({
2004
745
  op: 'taxonomy-upsert',
2005
- taxonomies: incomingTaxonomies,
2006
- taxonomyState: incomingTaxonomyState || undefined,
2007
- updatedAt: new Date().toISOString()
2008
- }] : []),
746
+ taxonomies: entry.taxonomies,
747
+ taxonomyState: entry.taxonomyState,
748
+ updatedAt: entry.updatedAt
749
+ }))
750
+ : []),
2009
751
  ...incomingInitiatives
752
+ .map((initiative) => normalizeInitiativeSyncPayload(initiative))
753
+ .filter((initiative) => Boolean(initiative && initiative.id.length > 0))
2010
754
  .map((initiative) => ({
2011
755
  op: 'initiative-upsert',
2012
- initiative: {
2013
- id: String(initiative?.id || '').trim(),
2014
- referenceNumber: Number.isFinite(Number(initiative?.referenceNumber))
2015
- ? Math.max(1, Math.floor(Number(initiative.referenceNumber)))
2016
- : null,
2017
- referenceLabel: typeof initiative?.referenceLabel === 'string' ? initiative.referenceLabel.trim() : undefined,
2018
- title: String(initiative?.title || '').trim(),
2019
- description: typeof initiative?.description === 'string' ? initiative.description : null,
2020
- ownerId: typeof initiative?.ownerId === 'string' ? initiative.ownerId.trim() : null,
2021
- createdAt: String(initiative?.createdAt || '').trim(),
2022
- updatedAt: String(initiative?.updatedAt || initiative?.createdAt || '').trim(),
2023
- order: Number.isFinite(Number(initiative?.order)) ? Math.floor(Number(initiative.order)) : null,
2024
- isArchived: Boolean(initiative?.isArchived)
2025
- }
2026
- }))
2027
- .filter((change) => change.initiative.id.length > 0),
756
+ initiative
757
+ })),
2028
758
  ...incomingWorkstreams
759
+ .map((workstream) => normalizeWorkstreamSyncPayload(workstream))
760
+ .filter((workstream) => Boolean(workstream && workstream.id.length > 0))
2029
761
  .map((workstream) => ({
2030
762
  op: 'workstream-upsert',
2031
- workstream: {
2032
- id: String(workstream?.id || '').trim(),
2033
- referenceNumber: Number.isFinite(Number(workstream?.referenceNumber))
2034
- ? Math.max(1, Math.floor(Number(workstream.referenceNumber)))
2035
- : null,
2036
- referenceLabel: typeof workstream?.referenceLabel === 'string' ? workstream.referenceLabel.trim() : undefined,
2037
- initiativeId: typeof workstream?.initiativeId === 'string' ? workstream.initiativeId.trim() : null,
2038
- title: String(workstream?.title || '').trim(),
2039
- description: typeof workstream?.description === 'string' ? workstream.description : null,
2040
- ownerId: typeof workstream?.ownerId === 'string' ? workstream.ownerId.trim() : null,
2041
- createdAt: String(workstream?.createdAt || '').trim(),
2042
- updatedAt: String(workstream?.updatedAt || workstream?.createdAt || '').trim(),
2043
- order: Number.isFinite(Number(workstream?.order)) ? Math.floor(Number(workstream.order)) : null,
2044
- isArchived: Boolean(workstream?.isArchived)
2045
- }
2046
- }))
2047
- .filter((change) => change.workstream.id.length > 0),
763
+ workstream
764
+ })),
2048
765
  ...incomingAiProfiles
766
+ .map((profile) => normalizeAiProfileSyncPayload(profile, cloudWorkspaceId, normalizeIncomingAiProfileSurfaceType, 'push.aiProfileUpsert'))
767
+ .filter((profile) => Boolean(profile && profile.id.length > 0))
2049
768
  .map((profile) => ({
2050
769
  op: 'ai-profile-upsert',
2051
- profile: {
2052
- id: String(profile?.id || '').trim(),
2053
- workspaceId: cloudWorkspaceId,
2054
- profileToken: String(profile?.profileToken || '').trim(),
2055
- name: String(profile?.name || '').trim(),
2056
- username: String(profile?.username || '').trim(),
2057
- icon: String(profile?.icon || 'Bot').trim() || 'Bot',
2058
- color: String(profile?.color || '#8b5cf6').trim() || '#8b5cf6',
2059
- surfaceType: normalizeIncomingAiProfileSurfaceType(profile?.surfaceType, {
2060
- source: 'push.aiProfileUpsert',
2061
- profileId: String(profile?.id || '').trim(),
2062
- workspaceId: cloudWorkspaceId
2063
- }),
2064
- providerMetadata: profile?.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
2065
- ? profile.providerMetadata
2066
- : null,
2067
- createdAt: String(profile?.createdAt || '').trim(),
2068
- updatedAt: String(profile?.updatedAt || profile?.createdAt || '').trim(),
2069
- lastActiveAt: typeof profile?.lastActiveAt === 'string' && profile.lastActiveAt.trim().length > 0
2070
- ? profile.lastActiveAt.trim()
2071
- : null
2072
- }
2073
- }))
2074
- .filter((change) => change.profile.id.length > 0),
770
+ profile: { ...profile, workspaceId: cloudWorkspaceId }
771
+ })),
2075
772
  ...incomingDocuments
2076
- .map((doc) => ({
2077
- op: 'document-upsert',
2078
- path: String(doc?.path || '').trim(),
2079
- updatedAt: String(doc?.updatedAt || '').trim(),
2080
- content: typeof doc?.content === 'string' ? doc.content : '',
2081
- assetId: typeof doc?.assetId === 'string' ? doc.assetId.trim() : undefined,
2082
- documentId: typeof doc?.documentId === 'string' ? doc.documentId.trim() : null,
2083
- referenceNumber: Number.isFinite(Number(doc?.referenceNumber)) ? Math.max(1, Math.floor(Number(doc.referenceNumber))) : null,
2084
- version: Number.isFinite(Number(doc?.version)) ? Math.max(1, Math.floor(Number(doc.version))) : null,
2085
- taskId: typeof doc?.taskId === 'string' ? doc.taskId.trim() : null,
2086
- logicalName: typeof doc?.logicalName === 'string' ? doc.logicalName.trim() : null,
2087
- caption: typeof doc?.caption === 'string' ? doc.caption.trim() : null,
2088
- originalFilename: typeof doc?.originalFilename === 'string' ? doc.originalFilename.trim() : null,
2089
- linkRole: (doc?.linkRole === 'reference' ? 'reference' : 'attachment')
2090
- }))
2091
- .filter((doc) => doc.path.length > 0),
773
+ .map((doc) => serializeDocumentSyncPayload(doc))
774
+ .filter((doc) => Boolean(doc && doc.path.length > 0)),
2092
775
  ...incomingAssets
2093
- .map((asset) => ({
2094
- op: 'asset-upsert',
2095
- path: String(asset?.path || '').trim(),
2096
- updatedAt: String(asset?.updatedAt || '').trim(),
2097
- contentBase64: typeof asset?.contentBase64 === 'string' ? asset.contentBase64 : '',
2098
- assetId: typeof asset?.assetId === 'string' ? asset.assetId.trim() : undefined,
2099
- kind: asset?.kind === 'image' ? 'image' : 'file',
2100
- mimeType: typeof asset?.mimeType === 'string' ? asset.mimeType.trim() : 'application/octet-stream',
2101
- referenceNumber: Number.isFinite(Number(asset?.referenceNumber)) ? Math.max(1, Math.floor(Number(asset.referenceNumber))) : null,
2102
- taskId: typeof asset?.taskId === 'string' ? asset.taskId.trim() : null,
2103
- logicalName: typeof asset?.logicalName === 'string' ? asset.logicalName.trim() : null,
2104
- caption: typeof asset?.caption === 'string' ? asset.caption.trim() : null,
2105
- originalFilename: typeof asset?.originalFilename === 'string' ? asset.originalFilename.trim() : null,
2106
- linkRole: asset?.linkRole === 'reference'
2107
- ? 'reference'
2108
- : asset?.kind === 'image'
2109
- ? 'image'
2110
- : 'attachment'
2111
- }))
2112
- .filter((asset) => asset.path.length > 0 && asset.contentBase64.length > 0),
776
+ .map((asset) => serializeBinaryAssetSyncPayload(asset))
777
+ .filter((asset) => Boolean(asset && asset.path.length > 0 && asset.contentBase64.length > 0)),
2113
778
  ...incomingDocumentReviewSessions
779
+ .map((session) => normalizeDocumentReviewSessionSyncPayload(session))
780
+ .filter((session) => Boolean(session && session.id.length > 0 && session.assetId.length > 0))
2114
781
  .map((session) => ({
2115
782
  op: 'document-review-session-upsert',
2116
- session: {
2117
- id: String(session?.id || '').trim(),
2118
- assetId: String(session?.assetId || '').trim(),
2119
- documentId: typeof session?.documentId === 'string' ? session.documentId.trim() : null,
2120
- documentVersion: Number.isFinite(Number(session?.documentVersion)) ? Math.max(1, Math.floor(Number(session.documentVersion))) : null,
2121
- title: typeof session?.title === 'string' ? session.title.trim() : null,
2122
- status: session?.status === 'resolved' ? 'resolved' : 'open',
2123
- comments: [],
2124
- createdByActorId: typeof session?.createdByActorId === 'string' ? session.createdByActorId.trim() : null,
2125
- updatedByActorId: typeof session?.updatedByActorId === 'string' ? session.updatedByActorId.trim() : null,
2126
- createdAt: String(session?.createdAt || '').trim(),
2127
- updatedAt: String(session?.updatedAt || session?.createdAt || '').trim(),
2128
- deletedAt: typeof session?.deletedAt === 'string' && session.deletedAt.trim().length > 0 ? session.deletedAt.trim() : null
2129
- }
2130
- }))
2131
- .filter((change) => change.session.id.length > 0 && change.session.assetId.length > 0),
783
+ session: { ...session, comments: [] }
784
+ })),
2132
785
  ...(incomingDocumentReviewComments.length > 0
2133
786
  ? incomingDocumentReviewComments
2134
787
  : incomingDocumentReviewSessions.flatMap((session) => {
@@ -2144,43 +797,22 @@ export function registerSyncRoutes(deps) {
2144
797
  const sessionId = isLegacyNested
2145
798
  ? String(entry.sessionId || '').trim()
2146
799
  : String(comment?.sessionId || '').trim();
2147
- return {
2148
- op: 'document-review-comment-upsert',
2149
- comment: {
2150
- id: String(comment?.id || '').trim(),
2151
- sessionId,
2152
- body: typeof comment?.body === 'string' ? comment.body : '',
2153
- anchor: comment?.anchor ?? null,
2154
- order: Number.isFinite(Number(comment?.order)) ? Math.max(0, Math.floor(Number(comment?.order))) : 0,
2155
- authorActorId: typeof comment?.authorActorId === 'string' ? comment.authorActorId.trim() : null,
2156
- createdAt: String(comment?.createdAt || '').trim(),
2157
- updatedAt: String(comment?.updatedAt || comment?.createdAt || '').trim(),
2158
- deletedAt: typeof comment?.deletedAt === 'string' && comment.deletedAt.trim().length > 0 ? comment.deletedAt.trim() : null,
800
+ const normalized = normalizeDocumentReviewCommentSyncPayload(comment, { sessionId });
801
+ return normalized
802
+ ? {
803
+ op: 'document-review-comment-upsert',
804
+ comment: normalized
2159
805
  }
2160
- };
806
+ : null;
2161
807
  })
2162
- .filter((change) => change.comment.id.length > 0 && change.comment.sessionId.length > 0),
808
+ .filter((change) => Boolean(change && change.comment.id.length > 0 && change.comment.sessionId.length > 0)),
2163
809
  ...incomingAnnotatedAttachmentSessions
810
+ .map((session) => normalizeAnnotatedAttachmentSessionSyncPayload(session, cloudWorkspaceId))
811
+ .filter((session) => Boolean(session && session.id.length > 0 && session.workspaceId.length > 0 && session.baseImageAssetId.length > 0))
2164
812
  .map((session) => ({
2165
813
  op: 'annotated-attachment-session-upsert',
2166
- session: {
2167
- id: String(session?.id || '').trim(),
2168
- workspaceId: cloudWorkspaceId,
2169
- taskId: typeof session?.taskId === 'string' ? session.taskId.trim() : '',
2170
- baseImageAssetId: typeof session?.baseImageAssetId === 'string' ? session.baseImageAssetId.trim() : '',
2171
- title: typeof session?.title === 'string' ? session.title.trim() : null,
2172
- globalInstruction: typeof session?.globalInstruction === 'string' ? session.globalInstruction.trim() : null,
2173
- annotations: Array.isArray(session?.annotations) ? session.annotations : [],
2174
- createdByActorId: typeof session?.createdByActorId === 'string' ? session.createdByActorId.trim() : null,
2175
- updatedByActorId: typeof session?.updatedByActorId === 'string' ? session.updatedByActorId.trim() : null,
2176
- createdAt: String(session?.createdAt || '').trim(),
2177
- updatedAt: String(session?.updatedAt || session?.createdAt || '').trim(),
2178
- deletedAt: typeof session?.deletedAt === 'string' && session.deletedAt.trim().length > 0 ? session.deletedAt.trim() : null
2179
- }
2180
- }))
2181
- .filter((change) => (change.session.id.length > 0
2182
- && change.session.workspaceId.length > 0
2183
- && change.session.baseImageAssetId.length > 0)),
814
+ session
815
+ })),
2184
816
  ...tasks.map((task) => ({ op: 'upsert', archived: false, task })),
2185
817
  ...archived.map((task) => ({ op: 'upsert', archived: true, task }))
2186
818
  ];
@@ -2235,7 +867,7 @@ export function registerSyncRoutes(deps) {
2235
867
  }
2236
868
  const workspaceId = requestWorkspaceId(req) || 'default';
2237
869
  const basePath = core.getPaths().basePath;
2238
- const documents = await listWorkspaceSyncDocumentsSnapshot(basePath, workspaceId);
870
+ const documents = await contentSyncState.listWorkspaceSyncDocumentsSnapshot(basePath, workspaceId);
2239
871
  const fingerprint = JSON.stringify(documents.map((doc) => [
2240
872
  doc.path,
2241
873
  doc.updatedAt,
@@ -2293,8 +925,8 @@ export function registerSyncRoutes(deps) {
2293
925
  }
2294
926
  }
2295
927
  const workspaceId = requestWorkspaceId(req) || 'default';
2296
- const sessions = listWorkspaceSyncDocumentReviewSessionsSnapshot(workspaceId);
2297
- const comments = listWorkspaceSyncDocumentReviewCommentsSnapshot(workspaceId);
928
+ const sessions = collaborationSyncState.listWorkspaceSyncDocumentReviewSessionsSnapshot(workspaceId);
929
+ const comments = collaborationSyncState.listWorkspaceSyncDocumentReviewCommentsSnapshot(workspaceId);
2298
930
  const fingerprint = JSON.stringify([
2299
931
  ...sessions.map((session) => [
2300
932
  'session',
@@ -2331,7 +963,7 @@ export function registerSyncRoutes(deps) {
2331
963
  }
2332
964
  }
2333
965
  const workspaceId = requestWorkspaceId(req) || 'default';
2334
- const sessions = listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(workspaceId);
966
+ const sessions = collaborationSyncState.listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(workspaceId);
2335
967
  const fingerprint = JSON.stringify(sessions.map((session) => [
2336
968
  session.id,
2337
969
  session.updatedAt,
@@ -2361,7 +993,7 @@ export function registerSyncRoutes(deps) {
2361
993
  }
2362
994
  const workspaceId = requestWorkspaceId(req) || 'default';
2363
995
  const basePath = core.getPaths().basePath;
2364
- const assets = await listWorkspaceSyncBinaryAssetsSnapshot(basePath, workspaceId);
996
+ const assets = await contentSyncState.listWorkspaceSyncBinaryAssetsSnapshot(basePath, workspaceId);
2365
997
  const fingerprint = JSON.stringify(assets.map((asset) => [
2366
998
  asset.path,
2367
999
  asset.updatedAt,
@@ -2428,378 +1060,27 @@ export function registerSyncRoutes(deps) {
2428
1060
  const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0
2429
1061
  ? Math.max(1, Math.min(500, Math.floor(requestedLimit)))
2430
1062
  : 200;
2431
- // Use watermark-indexed queries to avoid full table scans (Finding 12).
2432
- // Fetch up to pageSize * 2 tasks/deletes from DB since the cursor, then merge with
2433
- // small in-memory collections (taxonomy, documents) before slicing to pageSize.
2434
- const syncCursor = cursor ? { watermark: cursor.u, id: cursor.id } : null;
2435
- const dbFetchLimit = pageSize * 2 + 10;
2436
- const taskRows = core.listTasksForSync(targetWorkspaceId, syncCursor, dbFetchLimit);
2437
- const upsertEntries = taskRows
2438
- .map((entry) => {
2439
- const watermark = String(entry.task?.updatedAt || entry.task?.createdAt || '').trim();
2440
- const taskId = String(entry.task?.id || '').trim();
2441
- return {
2442
- kind: 'upsert',
2443
- watermark,
2444
- sortId: taskId,
2445
- task: entry.task,
2446
- archived: Boolean(entry.archived)
2447
- };
2448
- })
2449
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.sortId.length > 0);
2450
- const rawDeleteEntries = core.listWorkspaceSyncDeletesSince(targetWorkspaceId, syncCursor, dbFetchLimit);
2451
- const deleteEntries = rawDeleteEntries
2452
- .map((entry) => ({
2453
- kind: 'delete',
2454
- watermark: String(entry.deletedAt || '').trim(),
2455
- sortId: String(entry.taskId || '').trim(),
2456
- taskId: String(entry.taskId || '').trim()
2457
- }))
2458
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.sortId.length > 0);
2459
- const taxonomySnapshot = core.getTaxonomiesSyncSnapshot(targetWorkspaceId);
2460
- const taxonomyEntries = (taxonomySnapshot
2461
- && Array.isArray(taxonomySnapshot.taxonomies)
2462
- && Number.isFinite(Date.parse(String(taxonomySnapshot.updatedAt || '').trim())))
2463
- ? [{
2464
- kind: 'taxonomy',
2465
- watermark: String(taxonomySnapshot.updatedAt || '').trim(),
2466
- sortId: '__taxonomy_config__',
2467
- taxonomies: taxonomySnapshot.taxonomies,
2468
- taxonomyState: taxonomySnapshot.taxonomyState || null
2469
- }]
2470
- : [];
2471
- const initiativeEntries = core.listInitiatives(targetWorkspaceId)
2472
- .map((initiative) => ({
2473
- kind: 'initiative',
2474
- watermark: String(initiative.updatedAt || initiative.createdAt || '').trim(),
2475
- sortId: `initiative:${String(initiative.id || '').trim()}`,
2476
- initiative
2477
- }))
2478
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.initiative.id.length > 0);
2479
- const workstreamEntries = core.listWorkstreams(targetWorkspaceId)
2480
- .map((workstream) => ({
2481
- kind: 'workstream',
2482
- watermark: String(workstream.updatedAt || workstream.createdAt || '').trim(),
2483
- sortId: `workstream:${String(workstream.id || '').trim()}`,
2484
- workstream
2485
- }))
2486
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.workstream.id.length > 0);
2487
- const aiProfileEntries = core.listAiProfiles(targetWorkspaceId)
2488
- .map((profile) => ({
2489
- kind: 'ai-profile',
2490
- watermark: String(profile.updatedAt || profile.createdAt || '').trim(),
2491
- sortId: `ai-profile:${String(profile.id || '').trim()}`,
2492
- profile
2493
- }))
2494
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.sortId.length > 0);
2495
- const documentEntries = (await listWorkspaceSyncDocumentsSnapshot(core.getPaths().basePath, targetWorkspaceId))
2496
- .map((doc) => ({
2497
- kind: 'document',
2498
- watermark: String(doc.updatedAt || '').trim(),
2499
- sortId: `doc:${String(doc.path || '').trim()}`,
2500
- path: String(doc.path || '').trim(),
2501
- content: String(doc.content || ''),
2502
- assetId: doc.assetId,
2503
- documentId: doc.documentId,
2504
- referenceNumber: doc.referenceNumber,
2505
- version: doc.version,
2506
- taskId: doc.taskId,
2507
- logicalName: doc.logicalName,
2508
- caption: doc.caption,
2509
- originalFilename: doc.originalFilename,
2510
- linkRole: doc.linkRole
2511
- }))
2512
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.path.length > 0);
2513
- const documentDeleteEntries = listWorkspaceSyncDocumentDeletesSnapshot(targetWorkspaceId)
2514
- .map((doc) => ({
2515
- kind: 'document-delete',
2516
- watermark: String(doc.deletedAt || '').trim(),
2517
- sortId: `doc-delete:${String(doc.path || '').trim()}:${String(doc.assetId || '').trim()}`,
2518
- path: String(doc.path || '').trim(),
2519
- assetId: typeof doc.assetId === 'string' && doc.assetId.trim().length > 0 ? doc.assetId.trim() : undefined
2520
- }))
2521
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.path.length > 0);
2522
- const documentReviewSessionEntries = listWorkspaceSyncDocumentReviewSessionsSnapshot(targetWorkspaceId)
2523
- .filter((session) => !session.deletedAt)
2524
- .map((session) => ({
2525
- kind: 'document-review-session',
2526
- watermark: String(session.updatedAt || session.createdAt || '').trim(),
2527
- sortId: `document-review-session:${String(session.id || '').trim()}`,
2528
- session
2529
- }))
2530
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.session.id.length > 0 && entry.session.assetId.length > 0);
2531
- const documentReviewSessionDeleteEntries = listWorkspaceSyncDocumentReviewSessionsSnapshot(targetWorkspaceId)
2532
- .filter((session) => typeof session.deletedAt === 'string' && session.deletedAt.trim().length > 0)
2533
- .map((session) => ({
2534
- kind: 'document-review-session-delete',
2535
- watermark: String(session.deletedAt || session.updatedAt || '').trim(),
2536
- sortId: `document-review-session-delete:${String(session.id || '').trim()}`,
2537
- sessionId: String(session.id || '').trim()
2538
- }))
2539
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.sessionId.length > 0);
2540
- const documentReviewCommentEntries = listWorkspaceSyncDocumentReviewCommentsSnapshot(targetWorkspaceId)
2541
- .filter((comment) => !comment.deletedAt)
2542
- .map((comment) => ({
2543
- kind: 'document-review-comment',
2544
- watermark: String(comment.updatedAt || comment.createdAt || '').trim(),
2545
- sortId: `document-review-comment:${String(comment.id || '').trim()}`,
2546
- comment
2547
- }))
2548
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.comment.id.length > 0 && entry.comment.sessionId.length > 0);
2549
- const documentReviewCommentDeleteEntries = listWorkspaceSyncDocumentReviewCommentsSnapshot(targetWorkspaceId)
2550
- .filter((comment) => typeof comment.deletedAt === 'string' && comment.deletedAt.trim().length > 0)
2551
- .map((comment) => ({
2552
- kind: 'document-review-comment-delete',
2553
- watermark: String(comment.deletedAt || comment.updatedAt || '').trim(),
2554
- sortId: `document-review-comment-delete:${String(comment.id || '').trim()}`,
2555
- commentId: String(comment.id || '').trim()
2556
- }))
2557
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.commentId.length > 0);
2558
- const annotatedAttachmentSessionEntries = listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(targetWorkspaceId)
2559
- .filter((session) => !session.deletedAt)
2560
- .map((session) => ({
2561
- kind: 'annotated-attachment-session',
2562
- watermark: String(session.updatedAt || session.createdAt || '').trim(),
2563
- sortId: `annotated-attachment-session:${String(session.id || '').trim()}`,
2564
- session
2565
- }))
2566
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.session.id.length > 0 && entry.session.baseImageAssetId.length > 0);
2567
- const annotatedAttachmentSessionDeleteEntries = listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(targetWorkspaceId)
2568
- .filter((session) => typeof session.deletedAt === 'string' && session.deletedAt.trim().length > 0)
2569
- .map((session) => ({
2570
- kind: 'annotated-attachment-session-delete',
2571
- watermark: String(session.deletedAt || session.updatedAt || '').trim(),
2572
- sortId: `annotated-attachment-session-delete:${String(session.id || '').trim()}`,
2573
- sessionId: String(session.id || '').trim()
2574
- }))
2575
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.sessionId.length > 0);
2576
- const assetEntries = (await listWorkspaceSyncBinaryAssetsSnapshot(core.getPaths().basePath, targetWorkspaceId))
2577
- .map((asset) => ({
2578
- kind: 'asset',
2579
- watermark: String(asset.updatedAt || '').trim(),
2580
- sortId: `asset:${String(asset.path || '').trim()}`,
2581
- path: String(asset.path || '').trim(),
2582
- contentBase64: String(asset.contentBase64 || ''),
2583
- assetId: asset.assetId,
2584
- kindValue: asset.kind,
2585
- mimeType: asset.mimeType,
2586
- referenceNumber: asset.referenceNumber,
2587
- taskId: asset.taskId,
2588
- logicalName: asset.logicalName,
2589
- caption: asset.caption,
2590
- originalFilename: asset.originalFilename,
2591
- linkRole: asset.linkRole
2592
- }))
2593
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.path.length > 0 && entry.contentBase64.length > 0);
2594
- const assetDeleteEntries = listWorkspaceSyncBinaryAssetDeletesSnapshot(targetWorkspaceId)
2595
- .map((asset) => ({
2596
- kind: 'asset-delete',
2597
- watermark: String(asset.deletedAt || '').trim(),
2598
- sortId: `asset-delete:${String(asset.path || '').trim()}:${String(asset.assetId || '').trim()}`,
2599
- path: String(asset.path || '').trim(),
2600
- assetId: typeof asset.assetId === 'string' && asset.assetId.trim().length > 0 ? asset.assetId.trim() : undefined
2601
- }))
2602
- .filter((entry) => Number.isFinite(Date.parse(entry.watermark)) && entry.path.length > 0);
2603
- const all = [
2604
- ...upsertEntries,
2605
- ...deleteEntries,
2606
- ...taxonomyEntries,
2607
- ...initiativeEntries,
2608
- ...workstreamEntries,
2609
- ...aiProfileEntries,
2610
- ...documentEntries,
2611
- ...documentDeleteEntries,
2612
- ...documentReviewSessionEntries,
2613
- ...documentReviewSessionDeleteEntries,
2614
- ...documentReviewCommentEntries,
2615
- ...documentReviewCommentDeleteEntries,
2616
- ...annotatedAttachmentSessionEntries,
2617
- ...annotatedAttachmentSessionDeleteEntries,
2618
- ...assetEntries,
2619
- ...assetDeleteEntries
2620
- ];
2621
- const afterCursor = all.filter((entry) => {
2622
- if (!cursor)
2623
- return true;
2624
- const watermark = entry.watermark;
2625
- const sortId = entry.sortId;
2626
- if (watermark > cursor.u)
2627
- return true;
2628
- if (watermark < cursor.u)
2629
- return false;
2630
- return sortId > cursor.id;
2631
- });
2632
- afterCursor.sort((a, b) => {
2633
- if (a.watermark !== b.watermark)
2634
- return a.watermark.localeCompare(b.watermark);
2635
- return String(a.sortId || '').localeCompare(String(b.sortId || ''));
2636
- });
2637
- const page = afterCursor.slice(0, pageSize);
2638
- const hasMore = afterCursor.length > pageSize;
2639
- const last = page.length > 0 ? page[page.length - 1] : null;
2640
- const nextCursor = encodeWorkspaceSyncCursor(last
2641
- ? { u: last.watermark, id: String(last.sortId || '') }
2642
- : cursor);
2643
- const workspaceMembers = core.listWorkspaceUsers(targetWorkspaceId).map((member) => ({
2644
- userId: member.userId,
1063
+ const { workspaceMembers, changes, nextCursor, hasMore } = await buildWorkspacePullFeed({
1064
+ core,
2645
1065
  workspaceId: targetWorkspaceId,
2646
- email: member.email,
2647
- displayName: member.displayName || null,
2648
- role: member.role,
2649
- permissionMode: member.permissionMode,
2650
- betaAccess: member.betaAccess,
2651
- status: member.status,
2652
- disabled: member.disabled,
2653
- createdAt: member.createdAt || null,
2654
- updatedAt: member.updatedAt || null,
2655
- emailVerifiedAt: member.emailVerifiedAt || null
2656
- }));
1066
+ cursor,
1067
+ pageSize,
1068
+ encodeWorkspaceSyncCursor,
1069
+ contentSyncState,
1070
+ collaborationSyncState,
1071
+ });
2657
1072
  res.writeHead(200, { 'Content-Type': 'application/json' });
2658
1073
  res.end(JSON.stringify({
2659
1074
  success: true,
2660
1075
  workspaceId: targetWorkspaceId,
2661
1076
  workspaceMembers,
2662
- changes: page.map((entry) => (entry.kind === 'delete'
2663
- ? {
2664
- op: 'delete',
2665
- taskId: entry.taskId,
2666
- deletedAt: entry.watermark,
2667
- watermark: entry.watermark
2668
- }
2669
- : entry.kind === 'taxonomy'
2670
- ? {
2671
- op: 'taxonomy-upsert',
2672
- taxonomies: entry.taxonomies,
2673
- taxonomyState: entry.taxonomyState,
2674
- watermark: entry.watermark
2675
- }
2676
- : entry.kind === 'initiative'
2677
- ? {
2678
- op: 'initiative-upsert',
2679
- initiative: entry.initiative,
2680
- updatedAt: entry.watermark,
2681
- watermark: entry.watermark
2682
- }
2683
- : entry.kind === 'workstream'
2684
- ? {
2685
- op: 'workstream-upsert',
2686
- workstream: entry.workstream,
2687
- updatedAt: entry.watermark,
2688
- watermark: entry.watermark
2689
- }
2690
- : entry.kind === 'ai-profile'
2691
- ? {
2692
- op: 'ai-profile-upsert',
2693
- profile: entry.profile,
2694
- updatedAt: entry.watermark,
2695
- watermark: entry.watermark
2696
- }
2697
- : entry.kind === 'document-review-session'
2698
- ? {
2699
- op: 'document-review-session-upsert',
2700
- session: entry.session,
2701
- updatedAt: entry.watermark,
2702
- watermark: entry.watermark
2703
- }
2704
- : entry.kind === 'document-review-session-delete'
2705
- ? {
2706
- op: 'document-review-session-delete',
2707
- sessionId: entry.sessionId,
2708
- deletedAt: entry.watermark,
2709
- watermark: entry.watermark
2710
- }
2711
- : entry.kind === 'document-review-comment'
2712
- ? {
2713
- op: 'document-review-comment-upsert',
2714
- comment: entry.comment,
2715
- updatedAt: entry.watermark,
2716
- watermark: entry.watermark
2717
- }
2718
- : entry.kind === 'document-review-comment-delete'
2719
- ? {
2720
- op: 'document-review-comment-delete',
2721
- commentId: entry.commentId,
2722
- deletedAt: entry.watermark,
2723
- watermark: entry.watermark
2724
- }
2725
- : entry.kind === 'annotated-attachment-session'
2726
- ? {
2727
- op: 'annotated-attachment-session-upsert',
2728
- session: entry.session,
2729
- updatedAt: entry.watermark,
2730
- watermark: entry.watermark
2731
- }
2732
- : entry.kind === 'annotated-attachment-session-delete'
2733
- ? {
2734
- op: 'annotated-attachment-session-delete',
2735
- sessionId: entry.sessionId,
2736
- deletedAt: entry.watermark,
2737
- watermark: entry.watermark
2738
- }
2739
- // Keep markdown and binary assets on separate ops so the protocol stays explicit:
2740
- // document-* carries markdown content + document identity, asset-* carries image/file bytes.
2741
- : entry.kind === 'document'
2742
- ? {
2743
- op: 'document-upsert',
2744
- path: entry.path,
2745
- updatedAt: entry.watermark,
2746
- content: entry.content,
2747
- assetId: entry.assetId,
2748
- documentId: entry.documentId,
2749
- referenceNumber: entry.referenceNumber,
2750
- version: entry.version,
2751
- taskId: entry.taskId,
2752
- logicalName: entry.logicalName,
2753
- caption: entry.caption,
2754
- originalFilename: entry.originalFilename,
2755
- linkRole: entry.linkRole,
2756
- watermark: entry.watermark
2757
- }
2758
- : entry.kind === 'document-delete'
2759
- ? {
2760
- op: 'document-delete',
2761
- path: entry.path,
2762
- assetId: entry.assetId,
2763
- deletedAt: entry.watermark,
2764
- watermark: entry.watermark
2765
- }
2766
- : entry.kind === 'asset'
2767
- ? {
2768
- op: 'asset-upsert',
2769
- path: entry.path,
2770
- updatedAt: entry.watermark,
2771
- contentBase64: entry.contentBase64,
2772
- assetId: entry.assetId,
2773
- kind: entry.kindValue,
2774
- mimeType: entry.mimeType,
2775
- referenceNumber: entry.referenceNumber,
2776
- taskId: entry.taskId,
2777
- logicalName: entry.logicalName,
2778
- caption: entry.caption,
2779
- originalFilename: entry.originalFilename,
2780
- linkRole: entry.linkRole,
2781
- watermark: entry.watermark
2782
- }
2783
- : entry.kind === 'asset-delete'
2784
- ? {
2785
- op: 'asset-delete',
2786
- path: entry.path,
2787
- assetId: entry.assetId,
2788
- deletedAt: entry.watermark,
2789
- watermark: entry.watermark
2790
- }
2791
- : {
2792
- op: 'upsert',
2793
- task: entry.task,
2794
- archived: Boolean(entry.archived),
2795
- watermark: entry.watermark
2796
- })),
1077
+ changes,
2797
1078
  nextCursor,
2798
1079
  hasMore
2799
1080
  }));
2800
1081
  auditAuthAction(req, 'sync-workspace-pull-success', {
2801
1082
  workspaceId: targetWorkspaceId,
2802
- returnedChanges: page.length,
1083
+ returnedChanges: changes.length,
2803
1084
  hasMore
2804
1085
  });
2805
1086
  });
@@ -2995,6 +1276,27 @@ export function registerSyncRoutes(deps) {
2995
1276
  }
2996
1277
  incomingChanges = decryptedChanges;
2997
1278
  }
1279
+ const receivedAtBaseMs = Date.now();
1280
+ incomingChanges = incomingChanges.map((change, index) => {
1281
+ if (change.op !== 'upsert')
1282
+ return change;
1283
+ const task = change.task;
1284
+ const rawReferenceNumber = task.referenceNumber;
1285
+ const incomingReferenceNumber = rawReferenceNumber === undefined || rawReferenceNumber === null
1286
+ ? null
1287
+ : (Number.isFinite(Number(rawReferenceNumber))
1288
+ ? Math.max(1, Math.floor(Number(rawReferenceNumber)))
1289
+ : null);
1290
+ if (incomingReferenceNumber)
1291
+ return change;
1292
+ return {
1293
+ ...change,
1294
+ task: {
1295
+ ...task,
1296
+ updatedAt: new Date(receivedAtBaseMs + index).toISOString()
1297
+ }
1298
+ };
1299
+ });
2998
1300
  try {
2999
1301
  const { result, deleteCount, emittedEventIds } = await dispatchWorkspaceSyncChanges(targetWorkspaceId, incomingChanges, {
3000
1302
  allowReferenceNumberReassignment