@taskforcehq/taskforce 0.3.301 → 0.3.303

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 (171) hide show
  1. package/dist/Taskforce.module.css +37 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +72 -33
  4. package/dist/TaskforceCore.test.js +848 -130
  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/task/TaskKanban.test.js +17 -0
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.d.ts +3 -1
  16. package/dist/components/views/PlansPage.js +109 -49
  17. package/dist/components/views/PlansPage.test.d.ts +1 -0
  18. package/dist/components/views/PlansPage.test.js +100 -0
  19. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  20. package/dist/components/views/StandaloneLayout.js +473 -256
  21. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  22. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
  23. package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
  24. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  25. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  26. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  27. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  28. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  29. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  30. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  31. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  32. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  33. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  34. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  35. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  36. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  37. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  38. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  39. package/dist/components/views/standalone/modals/types.js +1 -0
  40. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  41. package/dist/components/views/teamManagementAccess.js +2 -2
  42. package/dist/components/views/teamManagementAccess.test.js +3 -3
  43. package/dist/config/deployment.js +2 -2
  44. package/dist/config/deployment.test.js +5 -3
  45. package/dist/core/AiProfileService.js +7 -2
  46. package/dist/core/AiProfiles.test.js +19 -0
  47. package/dist/core/EntitlementsPolicy.test.js +2 -2
  48. package/dist/core/GlobalSettingsService.js +78 -18
  49. package/dist/core/PlanEntitlementService.d.ts +29 -5
  50. package/dist/core/PlanEntitlementService.js +297 -219
  51. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  52. package/dist/core/SyncReconciliationService.d.ts +2 -0
  53. package/dist/core/SyncReconciliationService.js +3 -0
  54. package/dist/core/SystemAdmin.test.js +186 -96
  55. package/dist/core/TaskAuditTimeline.test.js +40 -0
  56. package/dist/core/Taskforce.d.ts +100 -10
  57. package/dist/core/Taskforce.ids.test.js +133 -0
  58. package/dist/core/Taskforce.js +459 -127
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  61. package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
  62. package/dist/core/taskReferencePolicy.d.ts +28 -0
  63. package/dist/core/taskReferencePolicy.js +189 -0
  64. package/dist/core/types.d.ts +13 -12
  65. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  66. package/dist/hooks/useSyncOrchestrator.js +17 -3
  67. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  68. package/dist/hooks/useTaskData.js +3 -2
  69. package/dist/hooks/useTaskData.test.js +66 -0
  70. package/dist/hooks/useTaskforce.d.ts +8 -0
  71. package/dist/hooks/useTaskforce.js +72 -19
  72. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  73. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  74. package/dist/mcp/aiProfileBootstrap.js +80 -0
  75. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  76. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  77. package/dist/mcp/clientIntegrations.d.ts +2 -8
  78. package/dist/mcp/clientIntegrations.js +21 -40
  79. package/dist/mcp/clientIntegrations.test.js +28 -2
  80. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  81. package/dist/mcp/clientProfileDefaults.js +37 -0
  82. package/dist/mcp/runtime.d.ts +10 -7
  83. package/dist/mcp/runtime.js +131 -48
  84. package/dist/mcp/runtime.test.js +321 -83
  85. package/dist/migrations/billingSchemaParity.test.js +3 -0
  86. package/dist/migrations/taskSchemaMigrations.js +68 -1
  87. package/dist/qaOpen.test.d.ts +1 -0
  88. package/dist/qaOpen.test.js +56 -0
  89. package/dist/server/index.cookieProxy.test.js +2 -0
  90. package/dist/server/index.d.ts +1 -0
  91. package/dist/server/index.js +17 -1
  92. package/dist/server/index.test.js +15 -1
  93. package/dist/server/mockStripe.d.ts +1 -0
  94. package/dist/server/mockStripe.js +2 -0
  95. package/dist/server/routes/admin.js +105 -45
  96. package/dist/server/routes/auth.d.ts +6 -0
  97. package/dist/server/routes/auth.js +505 -30
  98. package/dist/server/routes/billing.js +517 -24
  99. package/dist/server/routes/billing.test.js +967 -45
  100. package/dist/server/routes/documents.js +6 -1
  101. package/dist/server/routes/shared.d.ts +1 -1
  102. package/dist/server/routes/shared.js +16 -6
  103. package/dist/server/routes/sync.integration.test.js +290 -0
  104. package/dist/server/routes/sync.js +234 -1922
  105. package/dist/server/routes/tasks.js +18 -4
  106. package/dist/server/routes.js +29 -22
  107. package/dist/server/routes.test.js +639 -82
  108. package/dist/shared/runtimeContract.d.ts +1 -1
  109. package/dist/sync/cloudSyncApi.d.ts +1 -0
  110. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  111. package/dist/sync/collaborationSyncPayload.js +131 -0
  112. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  113. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  114. package/dist/sync/collaborationSyncState.d.ts +15 -0
  115. package/dist/sync/collaborationSyncState.js +60 -0
  116. package/dist/sync/contentSyncPayload.d.ts +45 -0
  117. package/dist/sync/contentSyncPayload.js +104 -0
  118. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  119. package/dist/sync/contentSyncPayload.test.js +104 -0
  120. package/dist/sync/contentSyncState.d.ts +98 -0
  121. package/dist/sync/contentSyncState.js +1004 -0
  122. package/dist/sync/planningSyncPayload.d.ts +9 -0
  123. package/dist/sync/planningSyncPayload.js +82 -0
  124. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  125. package/dist/sync/planningSyncPayload.test.js +68 -0
  126. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  127. package/dist/sync/syncApplyHandlers.js +445 -0
  128. package/dist/sync/syncService.d.ts +1 -0
  129. package/dist/sync/syncService.js +21 -8
  130. package/dist/sync/taskSyncPayload.d.ts +33 -0
  131. package/dist/sync/taskSyncPayload.js +126 -0
  132. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  133. package/dist/sync/taskSyncPayload.test.js +54 -0
  134. package/dist/sync/workspacePullFeed.d.ts +38 -0
  135. package/dist/sync/workspacePullFeed.js +349 -0
  136. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  137. package/dist/sync/workspacePullFeed.test.js +278 -0
  138. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  139. package/dist/sync/workspaceSyncModel.js +9 -2
  140. package/dist/sync/workspaceSyncModel.test.js +29 -0
  141. package/dist/test/setup.js +1 -1
  142. package/dist/types.d.ts +1 -0
  143. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
  144. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  145. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
  146. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  147. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  148. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
  149. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  150. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  151. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  152. package/dist/ui/assets/index-DneETxcu.css +1 -0
  153. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
  154. package/dist/ui/index.html +3 -3
  155. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  156. package/dist/utils/accountProfileSummaryCache.js +97 -0
  157. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  158. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  159. package/dist/utils/taskActivity.js +17 -4
  160. package/dist/utils/taskActivity.test.js +27 -0
  161. package/dist/utils/taskNormalization.js +10 -2
  162. package/dist/utils/taskReferences.d.ts +11 -1
  163. package/dist/utils/taskReferences.js +33 -1
  164. package/package.json +4 -3
  165. package/scripts/qa-open-lib.mjs +175 -0
  166. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  167. package/scripts/qa-open.mjs +352 -0
  168. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  169. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  170. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  171. 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,62 @@ 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
773
+ .map((doc) => serializeDocumentSyncPayload(doc))
774
+ .filter((doc) => Boolean(doc && doc.path.length > 0))
2076
775
  .map((doc) => ({
2077
776
  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),
777
+ ...doc
778
+ })),
2092
779
  ...incomingAssets
780
+ .map((asset) => serializeBinaryAssetSyncPayload(asset))
781
+ .filter((asset) => Boolean(asset && asset.path.length > 0 && asset.contentBase64.length > 0))
2093
782
  .map((asset) => ({
2094
783
  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),
784
+ ...asset
785
+ })),
2113
786
  ...incomingDocumentReviewSessions
787
+ .map((session) => normalizeDocumentReviewSessionSyncPayload(session))
788
+ .filter((session) => Boolean(session && session.id.length > 0 && session.assetId.length > 0))
2114
789
  .map((session) => ({
2115
790
  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),
791
+ session: { ...session, comments: [] }
792
+ })),
2132
793
  ...(incomingDocumentReviewComments.length > 0
2133
794
  ? incomingDocumentReviewComments
2134
795
  : incomingDocumentReviewSessions.flatMap((session) => {
@@ -2144,43 +805,22 @@ export function registerSyncRoutes(deps) {
2144
805
  const sessionId = isLegacyNested
2145
806
  ? String(entry.sessionId || '').trim()
2146
807
  : 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,
808
+ const normalized = normalizeDocumentReviewCommentSyncPayload(comment, { sessionId });
809
+ return normalized
810
+ ? {
811
+ op: 'document-review-comment-upsert',
812
+ comment: normalized
2159
813
  }
2160
- };
814
+ : null;
2161
815
  })
2162
- .filter((change) => change.comment.id.length > 0 && change.comment.sessionId.length > 0),
816
+ .filter((change) => Boolean(change && change.comment.id.length > 0 && change.comment.sessionId.length > 0)),
2163
817
  ...incomingAnnotatedAttachmentSessions
818
+ .map((session) => normalizeAnnotatedAttachmentSessionSyncPayload(session, cloudWorkspaceId))
819
+ .filter((session) => Boolean(session && session.id.length > 0 && session.workspaceId.length > 0 && session.baseImageAssetId.length > 0))
2164
820
  .map((session) => ({
2165
821
  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)),
822
+ session
823
+ })),
2184
824
  ...tasks.map((task) => ({ op: 'upsert', archived: false, task })),
2185
825
  ...archived.map((task) => ({ op: 'upsert', archived: true, task }))
2186
826
  ];
@@ -2235,7 +875,7 @@ export function registerSyncRoutes(deps) {
2235
875
  }
2236
876
  const workspaceId = requestWorkspaceId(req) || 'default';
2237
877
  const basePath = core.getPaths().basePath;
2238
- const documents = await listWorkspaceSyncDocumentsSnapshot(basePath, workspaceId);
878
+ const documents = await contentSyncState.listWorkspaceSyncDocumentsSnapshot(basePath, workspaceId);
2239
879
  const fingerprint = JSON.stringify(documents.map((doc) => [
2240
880
  doc.path,
2241
881
  doc.updatedAt,
@@ -2293,8 +933,8 @@ export function registerSyncRoutes(deps) {
2293
933
  }
2294
934
  }
2295
935
  const workspaceId = requestWorkspaceId(req) || 'default';
2296
- const sessions = listWorkspaceSyncDocumentReviewSessionsSnapshot(workspaceId);
2297
- const comments = listWorkspaceSyncDocumentReviewCommentsSnapshot(workspaceId);
936
+ const sessions = collaborationSyncState.listWorkspaceSyncDocumentReviewSessionsSnapshot(workspaceId);
937
+ const comments = collaborationSyncState.listWorkspaceSyncDocumentReviewCommentsSnapshot(workspaceId);
2298
938
  const fingerprint = JSON.stringify([
2299
939
  ...sessions.map((session) => [
2300
940
  'session',
@@ -2331,7 +971,7 @@ export function registerSyncRoutes(deps) {
2331
971
  }
2332
972
  }
2333
973
  const workspaceId = requestWorkspaceId(req) || 'default';
2334
- const sessions = listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(workspaceId);
974
+ const sessions = collaborationSyncState.listWorkspaceSyncAnnotatedAttachmentSessionsSnapshot(workspaceId);
2335
975
  const fingerprint = JSON.stringify(sessions.map((session) => [
2336
976
  session.id,
2337
977
  session.updatedAt,
@@ -2361,7 +1001,7 @@ export function registerSyncRoutes(deps) {
2361
1001
  }
2362
1002
  const workspaceId = requestWorkspaceId(req) || 'default';
2363
1003
  const basePath = core.getPaths().basePath;
2364
- const assets = await listWorkspaceSyncBinaryAssetsSnapshot(basePath, workspaceId);
1004
+ const assets = await contentSyncState.listWorkspaceSyncBinaryAssetsSnapshot(basePath, workspaceId);
2365
1005
  const fingerprint = JSON.stringify(assets.map((asset) => [
2366
1006
  asset.path,
2367
1007
  asset.updatedAt,
@@ -2428,379 +1068,30 @@ export function registerSyncRoutes(deps) {
2428
1068
  const pageSize = Number.isFinite(requestedLimit) && requestedLimit > 0
2429
1069
  ? Math.max(1, Math.min(500, Math.floor(requestedLimit)))
2430
1070
  : 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,
1071
+ const { workspaceMembers, changes, nextCursor, hasMore, diagnostics } = await buildWorkspacePullFeed({
1072
+ core,
2645
1073
  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
- }));
1074
+ cursor,
1075
+ pageSize,
1076
+ encodeWorkspaceSyncCursor,
1077
+ contentSyncState,
1078
+ collaborationSyncState,
1079
+ });
2657
1080
  res.writeHead(200, { 'Content-Type': 'application/json' });
2658
1081
  res.end(JSON.stringify({
2659
1082
  success: true,
2660
1083
  workspaceId: targetWorkspaceId,
2661
1084
  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
- })),
1085
+ changes,
2797
1086
  nextCursor,
2798
- hasMore
1087
+ hasMore,
1088
+ diagnostics
2799
1089
  }));
2800
1090
  auditAuthAction(req, 'sync-workspace-pull-success', {
2801
1091
  workspaceId: targetWorkspaceId,
2802
- returnedChanges: page.length,
2803
- hasMore
1092
+ returnedChanges: changes.length,
1093
+ hasMore,
1094
+ diagnostics
2804
1095
  });
2805
1096
  });
2806
1097
  // POST /api/taskforce/sync/workspace/apply-local - Apply pulled cloud deltas into local workspace store
@@ -2995,6 +1286,27 @@ export function registerSyncRoutes(deps) {
2995
1286
  }
2996
1287
  incomingChanges = decryptedChanges;
2997
1288
  }
1289
+ const receivedAtBaseMs = Date.now();
1290
+ incomingChanges = incomingChanges.map((change, index) => {
1291
+ if (change.op !== 'upsert')
1292
+ return change;
1293
+ const task = change.task;
1294
+ const rawReferenceNumber = task.referenceNumber;
1295
+ const incomingReferenceNumber = rawReferenceNumber === undefined || rawReferenceNumber === null
1296
+ ? null
1297
+ : (Number.isFinite(Number(rawReferenceNumber))
1298
+ ? Math.max(1, Math.floor(Number(rawReferenceNumber)))
1299
+ : null);
1300
+ if (incomingReferenceNumber)
1301
+ return change;
1302
+ return {
1303
+ ...change,
1304
+ task: {
1305
+ ...task,
1306
+ updatedAt: new Date(receivedAtBaseMs + index).toISOString()
1307
+ }
1308
+ };
1309
+ });
2998
1310
  try {
2999
1311
  const { result, deleteCount, emittedEventIds } = await dispatchWorkspaceSyncChanges(targetWorkspaceId, incomingChanges, {
3000
1312
  allowReferenceNumberReassignment