@taskforcehq/taskforce 0.3.300 → 0.3.302

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +19 -0
  2. package/dist/Taskforce.module.css +26 -2
  3. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  4. package/dist/TaskforceCore.js +13 -24
  5. package/dist/TaskforceCore.test.js +167 -152
  6. package/dist/components/features/TaskSettings.js +14 -82
  7. package/dist/components/features/TaskSettings.test.js +33 -70
  8. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  9. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  10. package/dist/components/task/TaskActionHeader.js +6 -3
  11. package/dist/components/task/TaskCard.js +6 -3
  12. package/dist/components/task/TaskForm.js +2 -2
  13. package/dist/components/task/TaskForm.test.js +41 -2
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.js +9 -8
  16. package/dist/components/views/StandaloneLayout.js +490 -207
  17. package/dist/components/views/panels/FilterToolbar.d.ts +2 -0
  18. package/dist/components/views/panels/FilterToolbarControls.d.ts +1 -0
  19. package/dist/components/views/panels/FilterToolbarControls.js +1 -1
  20. package/dist/components/views/panels/PlanningDrawer.js +19 -2
  21. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +43 -0
  22. package/dist/components/views/standalone/modals/AccountHubModal.js +22 -0
  23. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  24. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  25. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  26. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  27. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  28. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  29. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  30. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  31. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  32. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  33. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  34. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  35. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  36. package/dist/components/views/standalone/modals/types.js +1 -0
  37. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  38. package/dist/components/views/teamManagementAccess.js +2 -2
  39. package/dist/components/views/teamManagementAccess.test.js +3 -3
  40. package/dist/config/deployment.js +2 -2
  41. package/dist/config/deployment.test.js +5 -3
  42. package/dist/core/AiProfileService.js +7 -2
  43. package/dist/core/AiProfiles.test.js +19 -0
  44. package/dist/core/SyncReconciliationService.d.ts +2 -0
  45. package/dist/core/SyncReconciliationService.js +3 -0
  46. package/dist/core/TaskAuditTimeline.test.js +40 -0
  47. package/dist/core/Taskforce.d.ts +49 -0
  48. package/dist/core/Taskforce.ids.test.js +133 -0
  49. package/dist/core/Taskforce.js +241 -78
  50. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  51. package/dist/core/UserProfileAvatarDrafts.test.js +102 -0
  52. package/dist/core/taskReferencePolicy.d.ts +28 -0
  53. package/dist/core/taskReferencePolicy.js +189 -0
  54. package/dist/core/types.d.ts +2 -0
  55. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  56. package/dist/hooks/useTaskData.js +16 -11
  57. package/dist/hooks/useTaskData.test.js +127 -0
  58. package/dist/hooks/useTaskforce.d.ts +5 -0
  59. package/dist/hooks/useTaskforce.js +63 -15
  60. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  61. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  62. package/dist/mcp/aiProfileBootstrap.js +80 -0
  63. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  64. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  65. package/dist/mcp/clientIntegrations.d.ts +2 -8
  66. package/dist/mcp/clientIntegrations.js +21 -40
  67. package/dist/mcp/clientIntegrations.test.js +28 -2
  68. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  69. package/dist/mcp/clientProfileDefaults.js +37 -0
  70. package/dist/mcp/runtime.d.ts +10 -7
  71. package/dist/mcp/runtime.js +45 -23
  72. package/dist/mcp/runtime.test.js +56 -3
  73. package/dist/migrations/taskSchemaMigrations.js +53 -1
  74. package/dist/qaOpen.test.d.ts +1 -0
  75. package/dist/qaOpen.test.js +56 -0
  76. package/dist/server/index.cookieProxy.test.js +1 -0
  77. package/dist/server/index.d.ts +1 -0
  78. package/dist/server/index.js +15 -1
  79. package/dist/server/index.test.js +14 -1
  80. package/dist/server/routes/admin.js +25 -22
  81. package/dist/server/routes/auth.d.ts +5 -0
  82. package/dist/server/routes/auth.js +350 -16
  83. package/dist/server/routes/billing.js +11 -1
  84. package/dist/server/routes/billing.test.js +32 -2
  85. package/dist/server/routes/documents.js +6 -1
  86. package/dist/server/routes/shared.d.ts +1 -1
  87. package/dist/server/routes/shared.js +11 -3
  88. package/dist/server/routes/sync.integration.test.js +130 -0
  89. package/dist/server/routes/sync.js +226 -1924
  90. package/dist/server/routes/tasks.js +18 -4
  91. package/dist/server/routes.js +26 -19
  92. package/dist/server/routes.test.js +352 -6
  93. package/dist/shared/runtimeContract.d.ts +1 -1
  94. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  95. package/dist/sync/collaborationSyncPayload.js +131 -0
  96. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  97. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  98. package/dist/sync/collaborationSyncState.d.ts +15 -0
  99. package/dist/sync/collaborationSyncState.js +60 -0
  100. package/dist/sync/contentSyncPayload.d.ts +45 -0
  101. package/dist/sync/contentSyncPayload.js +104 -0
  102. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  103. package/dist/sync/contentSyncPayload.test.js +104 -0
  104. package/dist/sync/contentSyncState.d.ts +88 -0
  105. package/dist/sync/contentSyncState.js +743 -0
  106. package/dist/sync/planningSyncPayload.d.ts +9 -0
  107. package/dist/sync/planningSyncPayload.js +82 -0
  108. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  109. package/dist/sync/planningSyncPayload.test.js +68 -0
  110. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  111. package/dist/sync/syncApplyHandlers.js +445 -0
  112. package/dist/sync/taskSyncPayload.d.ts +33 -0
  113. package/dist/sync/taskSyncPayload.js +126 -0
  114. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  115. package/dist/sync/taskSyncPayload.test.js +54 -0
  116. package/dist/sync/workspacePullFeed.d.ts +28 -0
  117. package/dist/sync/workspacePullFeed.js +301 -0
  118. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  119. package/dist/sync/workspacePullFeed.test.js +166 -0
  120. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  121. package/dist/sync/workspaceSyncModel.js +9 -2
  122. package/dist/sync/workspaceSyncModel.test.js +29 -0
  123. package/dist/test/setup.js +1 -1
  124. package/dist/types.d.ts +1 -0
  125. package/dist/ui/assets/{AgentsModule-TVmCgizS.js → AgentsModule-JG5jc3he.js} +1 -1
  126. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-CAyhnPeR.js → AnnotatedAttachmentWorkspace-Dvi02Y7q.js} +1 -1
  127. package/dist/ui/assets/{DocumentWorkspace-BOCw5e4D.js → DocumentWorkspace-iys3zfca.js} +2 -2
  128. package/dist/ui/assets/{InitiativesModule-CN0r02Oi.js → InitiativesModule-DJvLJI3A.js} +1 -1
  129. package/dist/ui/assets/PlansPage-BMPlpFpw.js +1 -0
  130. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +8 -0
  131. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  132. package/dist/ui/assets/{WorkflowsModule-CY9R2zPB.js → WorkflowsModule-BguHeQaV.js} +1 -1
  133. package/dist/ui/assets/index-MXeWoebC.css +1 -0
  134. package/dist/ui/assets/index-YJ4k5QNa.js +6 -0
  135. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-C8EED3Oh.js} +1 -1
  136. package/dist/ui/index.html +3 -3
  137. package/dist/utils/billingStatusCache.d.ts +21 -0
  138. package/dist/utils/billingStatusCache.js +70 -0
  139. package/dist/utils/taskActivity.js +17 -4
  140. package/dist/utils/taskActivity.test.js +27 -0
  141. package/dist/utils/taskNormalization.js +10 -2
  142. package/dist/utils/taskReferences.d.ts +11 -1
  143. package/dist/utils/taskReferences.js +33 -1
  144. package/package.json +4 -1
  145. package/scripts/deploy-prod.sh +41 -0
  146. package/scripts/deploy-staging.sh +23 -0
  147. package/scripts/qa-open-lib.mjs +175 -0
  148. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  149. package/scripts/qa-open.mjs +352 -0
  150. package/dist/ui/assets/PlansPage-FcQSNLZd.js +0 -1
  151. package/dist/ui/assets/TaskSettings-Cw5zYD1L.js +0 -8
  152. package/dist/ui/assets/index-Bzqy5QGm.js +0 -6
  153. package/dist/ui/assets/index-DxRiI3tY.css +0 -1
@@ -0,0 +1,445 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { createHash } from 'crypto';
4
+ import { compareWorkspaceMutationOrdering } from './workspaceSyncSurface.js';
5
+ export function resolveSyncConflictOrdering(incomingWatermark, existingWatermark, incomingTieBreak, existingTieBreak) {
6
+ return compareWorkspaceMutationOrdering(String(incomingWatermark || '').trim(), String(existingWatermark || '').trim(), String(incomingTieBreak || '').trim(), String(existingTieBreak || '').trim());
7
+ }
8
+ export function applyPlanningSyncChanges(input) {
9
+ const { core, workspaceId, incomingInitiatives, incomingWorkstreams, incomingTaxonomies, incomingTaxonomyState, incomingTaxonomiesUpdatedAt, workspaceTaxonomySyncMeta, recordSyncDiagnostic, } = input;
10
+ if (incomingInitiatives.size > 0) {
11
+ for (const initiative of incomingInitiatives.values()) {
12
+ try {
13
+ core.upsertInitiativeForSync({
14
+ id: initiative.id,
15
+ referenceNumber: initiative.referenceNumber,
16
+ referenceLabel: initiative.referenceLabel,
17
+ title: initiative.title,
18
+ description: initiative.description,
19
+ ownerId: initiative.ownerId,
20
+ createdAt: initiative.createdAt,
21
+ updatedAt: initiative.updatedAt,
22
+ order: initiative.order,
23
+ isArchived: initiative.isArchived
24
+ }, workspaceId);
25
+ }
26
+ catch (error) {
27
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert initiative during sync apply.', {
28
+ source: 'apply.initiativeUpsert',
29
+ initiativeId: initiative.id,
30
+ error: String(error?.message || error || '')
31
+ });
32
+ throw error;
33
+ }
34
+ }
35
+ }
36
+ if (incomingWorkstreams.size > 0) {
37
+ for (const workstream of incomingWorkstreams.values()) {
38
+ try {
39
+ core.upsertWorkstreamForSync({
40
+ id: workstream.id,
41
+ referenceNumber: workstream.referenceNumber,
42
+ referenceLabel: workstream.referenceLabel,
43
+ initiativeId: workstream.initiativeId,
44
+ title: workstream.title,
45
+ description: workstream.description,
46
+ ownerId: workstream.ownerId,
47
+ createdAt: workstream.createdAt,
48
+ updatedAt: workstream.updatedAt,
49
+ order: workstream.order,
50
+ isArchived: workstream.isArchived
51
+ }, workspaceId);
52
+ }
53
+ catch (error) {
54
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert workstream during sync apply.', {
55
+ source: 'apply.workstreamUpsert',
56
+ workstreamId: workstream.id,
57
+ error: String(error?.message || error || '')
58
+ });
59
+ throw error;
60
+ }
61
+ }
62
+ }
63
+ if (incomingTaxonomyState || Array.isArray(incomingTaxonomies)) {
64
+ const nextTaxonomyState = incomingTaxonomyState || { taxonomies: incomingTaxonomies || [] };
65
+ if (!incomingTaxonomiesUpdatedAt) {
66
+ if (typeof core.updateTaxonomyState === 'function') {
67
+ core.updateTaxonomyState(nextTaxonomyState, workspaceId);
68
+ }
69
+ else {
70
+ core.updateTaxonomies(Array.isArray(incomingTaxonomies) ? incomingTaxonomies : []);
71
+ }
72
+ const fallbackHash = createHash('sha256').update(JSON.stringify(nextTaxonomyState)).digest('hex');
73
+ workspaceTaxonomySyncMeta.set(workspaceId, {
74
+ updatedAt: new Date().toISOString(),
75
+ hash: fallbackHash
76
+ });
77
+ }
78
+ else {
79
+ const incomingTaxonomyHash = createHash('sha256').update(JSON.stringify(nextTaxonomyState)).digest('hex');
80
+ const currentMeta = workspaceTaxonomySyncMeta.get(workspaceId) || { updatedAt: '', hash: '' };
81
+ const compare = resolveSyncConflictOrdering(incomingTaxonomiesUpdatedAt, currentMeta.updatedAt, incomingTaxonomyHash, currentMeta.hash);
82
+ if (compare >= 0) {
83
+ if (typeof core.updateTaxonomyState === 'function') {
84
+ core.updateTaxonomyState(nextTaxonomyState, workspaceId);
85
+ }
86
+ else {
87
+ core.updateTaxonomies(Array.isArray(incomingTaxonomies) ? incomingTaxonomies : []);
88
+ }
89
+ workspaceTaxonomySyncMeta.set(workspaceId, {
90
+ updatedAt: incomingTaxonomiesUpdatedAt,
91
+ hash: incomingTaxonomyHash
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ export function applyAiProfileAndMemberSyncChanges(input) {
98
+ const { core, workspaceId, incomingAiProfiles, workspaceMembers, recordSyncDiagnostic, assertSyncedAiProfilesPersisted, createSyncRouteError, } = input;
99
+ if (incomingAiProfiles.size > 0) {
100
+ for (const profile of incomingAiProfiles.values()) {
101
+ try {
102
+ core.upsertAiProfileForSync({
103
+ id: profile.id,
104
+ workspaceId,
105
+ profileToken: profile.profileToken,
106
+ name: profile.name,
107
+ username: profile.username,
108
+ icon: profile.icon,
109
+ color: profile.color,
110
+ surfaceType: profile.surfaceType,
111
+ providerMetadata: profile.providerMetadata,
112
+ createdAt: profile.createdAt,
113
+ updatedAt: profile.updatedAt,
114
+ lastActiveAt: profile.lastActiveAt
115
+ });
116
+ }
117
+ catch (error) {
118
+ const details = {
119
+ source: 'apply.aiProfileUpsert',
120
+ workspaceId,
121
+ profileId: profile.id,
122
+ error: String(error?.message || error || ''),
123
+ errorCode: String(error?.code || '').trim() || null
124
+ };
125
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert AI profile during sync apply.', details);
126
+ 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 });
127
+ }
128
+ }
129
+ assertSyncedAiProfilesPersisted(workspaceId, incomingAiProfiles);
130
+ }
131
+ if (Array.isArray(workspaceMembers)) {
132
+ core.replaceWorkspaceUsersForSync({
133
+ workspaceId,
134
+ users: workspaceMembers
135
+ .filter((member) => String(member?.workspaceId || workspaceId).trim() === workspaceId)
136
+ .map((member) => ({
137
+ userId: member.userId,
138
+ email: member.email,
139
+ displayName: member.displayName,
140
+ role: member.role,
141
+ permissionMode: member.permissionMode,
142
+ betaAccess: member.betaAccess,
143
+ status: member.status,
144
+ disabled: member.disabled,
145
+ createdAt: member.createdAt,
146
+ updatedAt: member.updatedAt,
147
+ emailVerifiedAt: member.emailVerifiedAt
148
+ }))
149
+ });
150
+ }
151
+ }
152
+ export async function applyContentSyncChanges(input) {
153
+ const { core, workspaceId, incomingDocuments, incomingDocumentDeletes, incomingAssets, incomingAssetDeletes, basePath, hasObjectStorageClient, hasDocumentSyncMetadataDrift, hasBinaryAssetSyncMetadataDrift, writeWorkspaceSyncDocument, deleteWorkspaceSyncDocument, writeWorkspaceSyncBinaryAsset, deleteWorkspaceSyncBinaryAsset, getExistingBinaryAsset, recordSyncDiagnostic, options, } = input;
154
+ if (incomingDocuments.length > 0) {
155
+ for (const doc of incomingDocuments) {
156
+ try {
157
+ const existingDoc = core.getDocumentWatermark(workspaceId, doc.path);
158
+ const incomingHash = createHash('sha256').update(String(doc.content || '')).digest('hex');
159
+ const existingUpdatedAt = String(existingDoc?.updatedAt || '').trim();
160
+ const existingHash = String(existingDoc?.contentHash || '').trim();
161
+ const compare = resolveSyncConflictOrdering(doc.updatedAt, existingUpdatedAt, incomingHash, existingHash);
162
+ const metadataDrift = hasDocumentSyncMetadataDrift(workspaceId, doc, options);
163
+ if (compare < 0)
164
+ continue;
165
+ if (compare === 0 && existingHash === incomingHash && !metadataDrift)
166
+ continue;
167
+ await writeWorkspaceSyncDocument(basePath, doc, workspaceId, options);
168
+ }
169
+ catch (error) {
170
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to apply synced document change.', {
171
+ source: 'documents.apply.write',
172
+ path: doc.path,
173
+ assetId: typeof doc.assetId === 'string' ? doc.assetId : null,
174
+ error: String(error?.message || error || '')
175
+ });
176
+ }
177
+ }
178
+ }
179
+ if (incomingDocumentDeletes.size > 0) {
180
+ for (const [docPath, deletedAt] of incomingDocumentDeletes.entries()) {
181
+ const existingDoc = core.getDocumentWatermark(workspaceId, docPath);
182
+ const existingUpdatedAt = String(existingDoc?.updatedAt || '').trim();
183
+ const existingHash = String(existingDoc?.contentHash || '').trim();
184
+ const compare = resolveSyncConflictOrdering(deletedAt, existingUpdatedAt, `delete:${docPath}`, existingHash);
185
+ if (existingDoc && compare < 0)
186
+ continue;
187
+ deleteWorkspaceSyncDocument(basePath, docPath, workspaceId);
188
+ }
189
+ }
190
+ if (incomingAssets.length > 0) {
191
+ for (const asset of incomingAssets) {
192
+ try {
193
+ const existing = getExistingBinaryAsset(asset.path, workspaceId);
194
+ const incomingHash = createHash('sha256').update(Buffer.from(String(asset.contentBase64 || ''), 'base64')).digest('hex');
195
+ const existingUpdatedAt = String(existing?.updatedAt || '').trim();
196
+ const existingHash = String(existing?.contentSha256 || '').trim();
197
+ const localFileMissing = !hasObjectStorageClient() && !fs.existsSync(path.resolve(basePath, asset.path));
198
+ const compare = resolveSyncConflictOrdering(asset.updatedAt, existingUpdatedAt, incomingHash, existingHash);
199
+ const metadataDrift = hasBinaryAssetSyncMetadataDrift(workspaceId, asset, options);
200
+ if (compare < 0)
201
+ continue;
202
+ if (compare === 0 && existingHash === incomingHash && !localFileMissing && !metadataDrift)
203
+ continue;
204
+ await writeWorkspaceSyncBinaryAsset(basePath, asset, workspaceId, options);
205
+ }
206
+ catch (error) {
207
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to apply synced asset change.', {
208
+ source: 'assets.apply.write',
209
+ path: asset.path,
210
+ assetId: typeof asset.assetId === 'string' ? asset.assetId : null,
211
+ kind: asset.kind,
212
+ error: String(error?.message || error || '')
213
+ });
214
+ }
215
+ }
216
+ }
217
+ if (incomingAssetDeletes.size > 0) {
218
+ for (const [assetPath, deletedAt] of incomingAssetDeletes.entries()) {
219
+ const existing = getExistingBinaryAsset(assetPath, workspaceId);
220
+ const existingUpdatedAt = String(existing?.updatedAt || '').trim();
221
+ const existingHash = String(existing?.contentSha256 || '').trim();
222
+ const compare = resolveSyncConflictOrdering(deletedAt, existingUpdatedAt, `delete:${assetPath}`, existingHash);
223
+ if (existing && compare < 0)
224
+ continue;
225
+ deleteWorkspaceSyncBinaryAsset(basePath, assetPath, workspaceId);
226
+ }
227
+ }
228
+ if (incomingDocuments.length > 0 || incomingAssets.length > 0) {
229
+ core.notifyDocumentMetadataMutation(workspaceId, [
230
+ ...incomingDocuments.map((entry) => entry.path),
231
+ ...incomingAssets.map((entry) => entry.path)
232
+ ], 'upsert');
233
+ }
234
+ if (incomingDocumentDeletes.size > 0 || incomingAssetDeletes.size > 0) {
235
+ core.notifyDocumentMetadataMutation(workspaceId, [
236
+ ...Array.from(incomingDocumentDeletes.keys()),
237
+ ...Array.from(incomingAssetDeletes.keys())
238
+ ], 'delete');
239
+ }
240
+ }
241
+ export function applyCollaborationSyncChanges(input) {
242
+ const { core, workspaceId, incomingDocumentReviewSessions, incomingDocumentReviewSessionDeletes, incomingDocumentReviewComments, incomingDocumentReviewCommentDeletes, incomingAnnotatedAttachmentSessions, incomingAnnotatedAttachmentSessionDeletes, recordSyncDiagnostic, createSyncRouteError, } = input;
243
+ if (incomingDocumentReviewSessions.size > 0) {
244
+ for (const session of incomingDocumentReviewSessions.values()) {
245
+ try {
246
+ const existing = core.getDocumentReviewSession({
247
+ workspaceId,
248
+ sessionId: session.id,
249
+ includeDeleted: true
250
+ });
251
+ const incomingTieBreak = createHash('sha256').update(JSON.stringify(session.comments || [])).digest('hex');
252
+ const existingTieBreak = existing
253
+ ? createHash('sha256').update(JSON.stringify(existing.comments || [])).digest('hex')
254
+ : '';
255
+ const compare = resolveSyncConflictOrdering(session.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
256
+ if (compare < 0)
257
+ continue;
258
+ core.upsertDocumentReviewSessionForSync({
259
+ id: session.id,
260
+ tenantId: core.getTenantId(),
261
+ workspaceId,
262
+ assetId: session.assetId,
263
+ documentId: session.documentId,
264
+ documentVersion: session.documentVersion,
265
+ title: session.title,
266
+ status: session.status,
267
+ comments: [],
268
+ createdByActorId: session.createdByActorId,
269
+ updatedByActorId: session.updatedByActorId,
270
+ createdAt: session.createdAt,
271
+ updatedAt: session.updatedAt,
272
+ deletedAt: session.deletedAt,
273
+ });
274
+ }
275
+ catch (error) {
276
+ const details = {
277
+ source: 'apply.documentReviewUpsert',
278
+ sessionId: session.id,
279
+ error: String(error?.message || error || ''),
280
+ errorCode: String(error?.code || '').trim() || null
281
+ };
282
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert document review session during sync apply.', details);
283
+ 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 });
284
+ }
285
+ }
286
+ }
287
+ if (incomingDocumentReviewComments.size > 0) {
288
+ for (const comment of incomingDocumentReviewComments.values()) {
289
+ try {
290
+ const existingComments = core.listDocumentReviewCommentsForSync({
291
+ workspaceId,
292
+ includeDeleted: true
293
+ });
294
+ const existing = existingComments.find((entry) => entry.id === comment.id) || null;
295
+ const incomingTieBreak = createHash('sha256')
296
+ .update(JSON.stringify({ body: comment.body, anchor: comment.anchor, deletedAt: comment.deletedAt }))
297
+ .digest('hex');
298
+ const existingTieBreak = existing
299
+ ? createHash('sha256')
300
+ .update(JSON.stringify({ body: existing.body, anchor: existing.anchor, deletedAt: existing.deletedAt }))
301
+ .digest('hex')
302
+ : '';
303
+ const compare = resolveSyncConflictOrdering(comment.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
304
+ if (compare < 0)
305
+ continue;
306
+ core.upsertDocumentReviewCommentForSync({
307
+ id: comment.id,
308
+ tenantId: core.getTenantId(),
309
+ workspaceId,
310
+ sessionId: comment.sessionId,
311
+ body: comment.body,
312
+ anchor: comment.anchor,
313
+ order: comment.order,
314
+ authorActorId: comment.authorActorId,
315
+ createdAt: comment.createdAt,
316
+ updatedAt: comment.updatedAt,
317
+ deletedAt: comment.deletedAt,
318
+ });
319
+ }
320
+ catch (error) {
321
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert document review comment during sync apply.', {
322
+ source: 'apply.documentReviewCommentUpsert',
323
+ commentId: comment.id,
324
+ error: String(error?.message || error || '')
325
+ });
326
+ throw error;
327
+ }
328
+ }
329
+ }
330
+ if (incomingDocumentReviewSessionDeletes.size > 0) {
331
+ for (const [sessionId, deletedAt] of incomingDocumentReviewSessionDeletes.entries()) {
332
+ try {
333
+ const existing = core.getDocumentReviewSession({
334
+ workspaceId,
335
+ sessionId,
336
+ includeDeleted: true
337
+ });
338
+ const compare = resolveSyncConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${sessionId}`, String(existing?.id || '').trim());
339
+ if (existing && compare < 0)
340
+ continue;
341
+ core.deleteDocumentReviewSessionForSync({ workspaceId, sessionId, deletedAt });
342
+ }
343
+ catch (error) {
344
+ const details = {
345
+ source: 'apply.documentReviewDelete',
346
+ sessionId,
347
+ error: String(error?.message || error || ''),
348
+ errorCode: String(error?.code || '').trim() || null
349
+ };
350
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document review session during sync apply.', details);
351
+ 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 });
352
+ }
353
+ }
354
+ }
355
+ if (incomingDocumentReviewCommentDeletes.size > 0) {
356
+ for (const [commentId, deletedAt] of incomingDocumentReviewCommentDeletes.entries()) {
357
+ try {
358
+ const existing = core.listDocumentReviewCommentsForSync({
359
+ workspaceId,
360
+ includeDeleted: true
361
+ }).find((entry) => entry.id === commentId) || null;
362
+ const compare = resolveSyncConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${commentId}`, String(existing?.id || '').trim());
363
+ if (existing && compare < 0)
364
+ continue;
365
+ core.deleteDocumentReviewCommentForSync({ workspaceId, commentId, deletedAt });
366
+ }
367
+ catch (error) {
368
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document review comment during sync apply.', {
369
+ source: 'apply.documentReviewCommentDelete',
370
+ commentId,
371
+ error: String(error?.message || error || '')
372
+ });
373
+ throw error;
374
+ }
375
+ }
376
+ }
377
+ if (incomingAnnotatedAttachmentSessions.size > 0) {
378
+ for (const session of incomingAnnotatedAttachmentSessions.values()) {
379
+ try {
380
+ const existing = core.getAnnotatedAttachmentSession({
381
+ workspaceId,
382
+ sessionId: session.id,
383
+ includeDeleted: true
384
+ });
385
+ const incomingTieBreak = createHash('sha256').update(JSON.stringify(session.annotations || [])).digest('hex');
386
+ const existingTieBreak = existing
387
+ ? createHash('sha256').update(JSON.stringify(existing.annotations || [])).digest('hex')
388
+ : '';
389
+ const compare = resolveSyncConflictOrdering(session.updatedAt, String(existing?.updatedAt || '').trim(), incomingTieBreak, existingTieBreak);
390
+ if (compare < 0)
391
+ continue;
392
+ core.upsertAnnotatedAttachmentSessionForSync({
393
+ id: session.id,
394
+ tenantId: core.getTenantId(),
395
+ workspaceId,
396
+ taskId: session.taskId,
397
+ baseImageAssetId: session.baseImageAssetId,
398
+ title: session.title,
399
+ globalInstruction: session.globalInstruction,
400
+ annotations: session.annotations,
401
+ createdByActorId: session.createdByActorId,
402
+ updatedByActorId: session.updatedByActorId,
403
+ createdAt: session.createdAt,
404
+ updatedAt: session.updatedAt,
405
+ deletedAt: session.deletedAt,
406
+ });
407
+ }
408
+ catch (error) {
409
+ const details = {
410
+ source: 'apply.annotatedAttachmentUpsert',
411
+ sessionId: session.id,
412
+ error: String(error?.message || error || ''),
413
+ errorCode: String(error?.code || '').trim() || null
414
+ };
415
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert annotated attachment session during sync apply.', details);
416
+ 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 });
417
+ }
418
+ }
419
+ }
420
+ if (incomingAnnotatedAttachmentSessionDeletes.size > 0) {
421
+ for (const [sessionId, deletedAt] of incomingAnnotatedAttachmentSessionDeletes.entries()) {
422
+ try {
423
+ const existing = core.getAnnotatedAttachmentSession({
424
+ workspaceId,
425
+ sessionId,
426
+ includeDeleted: true
427
+ });
428
+ const compare = resolveSyncConflictOrdering(deletedAt, String(existing?.updatedAt || '').trim(), `delete:${sessionId}`, String(existing?.id || '').trim());
429
+ if (existing && compare < 0)
430
+ continue;
431
+ core.deleteAnnotatedAttachmentSessionForSync({ workspaceId, sessionId, deletedAt });
432
+ }
433
+ catch (error) {
434
+ const details = {
435
+ source: 'apply.annotatedAttachmentDelete',
436
+ sessionId,
437
+ error: String(error?.message || error || ''),
438
+ errorCode: String(error?.code || '').trim() || null
439
+ };
440
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete annotated attachment session during sync apply.', details);
441
+ 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 });
442
+ }
443
+ }
444
+ }
445
+ }
@@ -0,0 +1,33 @@
1
+ import type { AttachmentItem, ChecklistItem, Comment, TaskItem, TaskStatus } from '../types.js';
2
+ import type { TaskItem as CoreTaskItem } from '../core/Taskforce.js';
3
+ export interface TaskSyncPayload {
4
+ id: string;
5
+ title: string;
6
+ description?: string | null;
7
+ status: TaskStatus;
8
+ priority?: number;
9
+ complexity?: number | null;
10
+ type: string;
11
+ category: string;
12
+ approach?: string;
13
+ canceledReason?: string | null;
14
+ createdAt: string;
15
+ updatedAt?: string;
16
+ completedAt?: string | null;
17
+ isArchived?: boolean;
18
+ taxonomies?: Record<string, string | string[]>;
19
+ createdBy?: string;
20
+ assignee?: string;
21
+ scheduledDate?: string | null;
22
+ dueDate?: string | null;
23
+ scheduledWeekKey?: string | null;
24
+ orderInDay?: number | null;
25
+ workstreamId?: string | null;
26
+ referenceNumber?: number | null;
27
+ comments?: Comment[];
28
+ attachments?: Array<string | AttachmentItem>;
29
+ checklistItems?: ChecklistItem[];
30
+ }
31
+ export declare function serializeTaskForSync(task: Partial<TaskItem> | null | undefined): TaskSyncPayload;
32
+ export declare function normalizeTaskSyncPayload(raw: unknown): TaskSyncPayload | null;
33
+ export declare function taskSyncPayloadToCoreTask(task: TaskSyncPayload): Partial<CoreTaskItem>;
@@ -0,0 +1,126 @@
1
+ function toOptionalString(value) {
2
+ if (value === undefined || value === null)
3
+ return undefined;
4
+ const normalized = String(value).trim();
5
+ return normalized.length > 0 ? normalized : undefined;
6
+ }
7
+ function toNullableString(value) {
8
+ if (value === undefined)
9
+ return undefined;
10
+ if (value === null)
11
+ return null;
12
+ const normalized = String(value).trim();
13
+ return normalized.length > 0 ? normalized : null;
14
+ }
15
+ function toOptionalBoolean(value) {
16
+ return typeof value === 'boolean' ? value : undefined;
17
+ }
18
+ function toOptionalInteger(value, options) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ if (value === null || value === '')
22
+ return options?.nullable ? null : undefined;
23
+ const normalized = typeof value === 'number' ? value : Number(value);
24
+ if (!Number.isFinite(normalized))
25
+ return options?.nullable ? null : undefined;
26
+ const rounded = Math.floor(normalized);
27
+ if (Number.isFinite(options?.min) && rounded < Number(options?.min)) {
28
+ return options?.nullable ? null : undefined;
29
+ }
30
+ return rounded;
31
+ }
32
+ function cloneTaxonomies(value) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ return undefined;
35
+ const entries = Object.entries(value);
36
+ const normalized = entries.reduce((acc, [key, raw]) => {
37
+ if (Array.isArray(raw)) {
38
+ acc[key] = raw.map((entry) => String(entry ?? ''));
39
+ return acc;
40
+ }
41
+ if (raw === undefined || raw === null)
42
+ return acc;
43
+ acc[key] = String(raw);
44
+ return acc;
45
+ }, {});
46
+ return Object.keys(normalized).length > 0 ? normalized : {};
47
+ }
48
+ function cloneComments(value) {
49
+ return Array.isArray(value) ? value : undefined;
50
+ }
51
+ function cloneAttachments(value) {
52
+ return Array.isArray(value) ? value : undefined;
53
+ }
54
+ function cloneChecklistItems(value) {
55
+ return Array.isArray(value) ? value : undefined;
56
+ }
57
+ export function serializeTaskForSync(task) {
58
+ const source = task && typeof task === 'object' ? task : {};
59
+ return {
60
+ id: String(source.id || '').trim(),
61
+ title: String(source.title || '').trim(),
62
+ description: source.description === undefined ? undefined : (source.description || null),
63
+ status: (String(source.status || 'task').trim() || 'task'),
64
+ priority: toOptionalInteger(source.priority, { min: 1 }) ?? undefined,
65
+ complexity: toOptionalInteger(source.complexity, { min: 1, nullable: true }),
66
+ type: String(source.type || '').trim(),
67
+ category: String(source.category || '').trim(),
68
+ approach: toOptionalString(source.approach),
69
+ canceledReason: source.canceledReason === undefined ? undefined : (source.canceledReason || null),
70
+ createdAt: String(source.createdAt || '').trim(),
71
+ updatedAt: toOptionalString(source.updatedAt),
72
+ completedAt: source.completedAt === undefined ? undefined : (source.completedAt || null),
73
+ isArchived: toOptionalBoolean(source.isArchived),
74
+ taxonomies: cloneTaxonomies(source.taxonomies),
75
+ createdBy: toOptionalString(source.createdBy),
76
+ assignee: toOptionalString(source.assignee),
77
+ scheduledDate: toNullableString(source.scheduledDate),
78
+ dueDate: toNullableString(source.dueDate),
79
+ scheduledWeekKey: toNullableString(source.scheduledWeekKey),
80
+ orderInDay: toOptionalInteger(source.orderInDay, { min: 0, nullable: true }),
81
+ workstreamId: toNullableString(source.workstreamId),
82
+ referenceNumber: toOptionalInteger(source.referenceNumber, { min: 1, nullable: true }),
83
+ comments: cloneComments(source.comments),
84
+ attachments: cloneAttachments(source.attachments),
85
+ checklistItems: cloneChecklistItems(source.checklistItems),
86
+ };
87
+ }
88
+ export function normalizeTaskSyncPayload(raw) {
89
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
90
+ return null;
91
+ const source = raw;
92
+ const id = String(source.id || '').trim();
93
+ if (!id)
94
+ return null;
95
+ return serializeTaskForSync(source);
96
+ }
97
+ export function taskSyncPayloadToCoreTask(task) {
98
+ return {
99
+ id: task.id,
100
+ title: task.title,
101
+ description: task.description,
102
+ status: task.status,
103
+ priority: task.priority,
104
+ complexity: task.complexity ?? undefined,
105
+ type: task.type,
106
+ category: task.category,
107
+ approach: task.approach,
108
+ canceledReason: task.canceledReason ?? undefined,
109
+ createdAt: task.createdAt,
110
+ updatedAt: task.updatedAt,
111
+ completedAt: task.completedAt,
112
+ isArchived: task.isArchived,
113
+ taxonomies: task.taxonomies,
114
+ createdBy: task.createdBy,
115
+ assignee: task.assignee,
116
+ scheduledDate: task.scheduledDate,
117
+ dueDate: task.dueDate,
118
+ scheduledWeekKey: task.scheduledWeekKey,
119
+ orderInDay: task.orderInDay ?? undefined,
120
+ workstreamId: task.workstreamId,
121
+ referenceNumber: task.referenceNumber ?? undefined,
122
+ comments: task.comments,
123
+ attachments: task.attachments,
124
+ checklistItems: task.checklistItems
125
+ };
126
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeTaskSyncPayload, serializeTaskForSync, taskSyncPayloadToCoreTask, } from './taskSyncPayload';
3
+ describe('taskSyncPayload', () => {
4
+ it('omits local-only task reference fields from serialized sync payloads', () => {
5
+ const payload = serializeTaskForSync({
6
+ id: 'task-1',
7
+ title: 'Serialized task',
8
+ status: 'task',
9
+ priority: 2,
10
+ category: 'general',
11
+ type: 'task',
12
+ referenceNumber: 7,
13
+ localReferenceNumber: 3,
14
+ referenceLabel: 'LT-3',
15
+ });
16
+ expect(payload).toEqual(expect.objectContaining({
17
+ id: 'task-1',
18
+ referenceNumber: 7,
19
+ }));
20
+ expect(payload).not.toHaveProperty('localReferenceNumber');
21
+ expect(payload).not.toHaveProperty('referenceLabel');
22
+ });
23
+ it('normalizes task sync payload numbers before converting back to core input', () => {
24
+ const payload = normalizeTaskSyncPayload({
25
+ id: 'task-2',
26
+ title: 'Incoming task',
27
+ status: 'review',
28
+ priority: '3',
29
+ complexity: '4',
30
+ category: 'general',
31
+ type: 'task',
32
+ referenceNumber: '9',
33
+ orderInDay: '2',
34
+ localReferenceNumber: 99,
35
+ referenceLabel: 'LT-99',
36
+ });
37
+ expect(payload).toEqual(expect.objectContaining({
38
+ id: 'task-2',
39
+ priority: 3,
40
+ complexity: 4,
41
+ referenceNumber: 9,
42
+ orderInDay: 2,
43
+ }));
44
+ expect(payload).not.toHaveProperty('localReferenceNumber');
45
+ expect(payload).not.toHaveProperty('referenceLabel');
46
+ expect(taskSyncPayloadToCoreTask(payload)).toEqual(expect.objectContaining({
47
+ id: 'task-2',
48
+ priority: 3,
49
+ complexity: 4,
50
+ referenceNumber: 9,
51
+ orderInDay: 2,
52
+ }));
53
+ });
54
+ });