@taskforcehq/taskforce 0.3.306 → 0.3.307

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 (215) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.js +9 -1
  38. package/dist/components/views/PlanComparisonPage.test.js +19 -0
  39. package/dist/components/views/PlansPage.js +1 -1
  40. package/dist/components/views/StandaloneLayout.js +256 -69
  41. package/dist/components/views/WidgetView.js +10 -8
  42. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  43. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  44. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  45. package/dist/config/envSchema.js +1 -0
  46. package/dist/core/AiProfileService.d.ts +7 -0
  47. package/dist/core/AiProfileService.js +460 -35
  48. package/dist/core/AiProfiles.test.js +648 -2
  49. package/dist/core/AttachmentLinkService.d.ts +3 -0
  50. package/dist/core/AttachmentLinkService.js +58 -13
  51. package/dist/core/GlobalSettingsService.js +12 -0
  52. package/dist/core/McpTokenService.d.ts +12 -0
  53. package/dist/core/McpTokenService.js +44 -1
  54. package/dist/core/PlanningEntities.test.js +366 -4
  55. package/dist/core/TaskActivityService.d.ts +28 -1
  56. package/dist/core/TaskActivityService.js +227 -46
  57. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  58. package/dist/core/TaskActivitySync.test.js +90 -0
  59. package/dist/core/TaskAuditTimeline.test.js +96 -0
  60. package/dist/core/Taskforce.d.ts +190 -26
  61. package/dist/core/Taskforce.ids.test.js +58 -0
  62. package/dist/core/Taskforce.js +775 -71
  63. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  64. package/dist/core/types.d.ts +7 -0
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  66. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  67. package/dist/hooks/bootstrapTimeouts.js +8 -0
  68. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  69. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  70. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  71. package/dist/hooks/sync/orchestratorShared.js +61 -1
  72. package/dist/hooks/sync/recovery.js +8 -0
  73. package/dist/hooks/sync/recovery.test.js +5 -5
  74. package/dist/hooks/sync/transfers.d.ts +2 -1
  75. package/dist/hooks/sync/transfers.js +36 -9
  76. package/dist/hooks/sync/transfers.test.js +534 -2
  77. package/dist/hooks/useFilterSort.js +13 -13
  78. package/dist/hooks/useFilterSort.test.js +87 -1
  79. package/dist/hooks/useSyncOrchestrator.js +50 -5
  80. package/dist/hooks/useTaskforce.d.ts +85 -50
  81. package/dist/hooks/useTaskforce.js +44 -14
  82. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  83. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  84. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  85. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  86. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  87. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  88. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  89. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  90. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  91. package/dist/mcp/clientIntegrations.d.ts +1 -0
  92. package/dist/mcp/clientIntegrations.js +25 -0
  93. package/dist/mcp/clientIntegrations.test.js +14 -3
  94. package/dist/mcp/clientProfileDefaults.js +3 -3
  95. package/dist/mcp/collaborationRegistrar.js +7 -7
  96. package/dist/mcp/documentAssetRegistrar.js +113 -7
  97. package/dist/mcp/runtime.d.ts +1 -0
  98. package/dist/mcp/runtime.js +969 -122
  99. package/dist/mcp/runtime.test.js +1083 -11
  100. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  101. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  102. package/dist/mcp/toolRegistry.d.ts +56 -0
  103. package/dist/mcp/toolRegistry.js +8 -0
  104. package/dist/mcp/toolRegistry.test.js +4 -0
  105. package/dist/migrations/taskSchemaMigrations.js +21 -0
  106. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  107. package/dist/server/aiProfileAvatarAssets.js +319 -0
  108. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  109. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  110. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/index.test.js +6 -1
  113. package/dist/server/routes/admin.js +187 -9
  114. package/dist/server/routes/annotatedAttachments.js +51 -0
  115. package/dist/server/routes/auth.d.ts +11 -0
  116. package/dist/server/routes/auth.js +189 -27
  117. package/dist/server/routes/authSupport.d.ts +4 -0
  118. package/dist/server/routes/authSupport.js +12 -1
  119. package/dist/server/routes/billing.js +11 -0
  120. package/dist/server/routes/billing.test.js +3 -0
  121. package/dist/server/routes/documents.js +10 -0
  122. package/dist/server/routes/shared.js +0 -10
  123. package/dist/server/routes/sync.integration.test.js +221 -0
  124. package/dist/server/routes/sync.js +13 -3
  125. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  126. package/dist/server/routes/tasks.js +12 -9
  127. package/dist/server/routes.js +87 -8
  128. package/dist/server/routes.test.js +890 -22
  129. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  130. package/dist/shared/aiProfileSurfaceType.js +10 -8
  131. package/dist/storage/objectStorage.d.ts +1 -0
  132. package/dist/storage/objectStorage.js +2 -1
  133. package/dist/storage/objectStorageClient.d.ts +1 -0
  134. package/dist/storage/objectStorageClient.js +41 -8
  135. package/dist/storage/objectStorageClient.test.js +26 -1
  136. package/dist/storage/objectStorageConfig.js +3 -1
  137. package/dist/storage/objectStorageConfig.test.js +13 -1
  138. package/dist/sync/collaborationSyncPayload.js +2 -0
  139. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  140. package/dist/sync/collaborationSyncState.d.ts +3 -3
  141. package/dist/sync/collaborationSyncState.js +19 -12
  142. package/dist/sync/contentSyncPayload.d.ts +5 -1
  143. package/dist/sync/contentSyncPayload.js +9 -0
  144. package/dist/sync/contentSyncPayload.test.js +39 -0
  145. package/dist/sync/contentSyncState.d.ts +5 -1
  146. package/dist/sync/contentSyncState.js +231 -37
  147. package/dist/sync/planningSyncPayload.js +5 -0
  148. package/dist/sync/planningSyncPayload.test.js +24 -0
  149. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  150. package/dist/sync/syncApplyHandlers.js +89 -31
  151. package/dist/sync/syncApplyHandlers.test.js +184 -4
  152. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  153. package/dist/sync/syncResourceAdapters.js +2 -0
  154. package/dist/sync/syncService.d.ts +1 -0
  155. package/dist/sync/syncService.js +13 -1
  156. package/dist/sync/syncService.test.js +39 -0
  157. package/dist/sync/workspacePullFeed.d.ts +4 -0
  158. package/dist/sync/workspacePullFeed.js +129 -70
  159. package/dist/sync/workspacePullFeed.test.js +49 -0
  160. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  161. package/dist/sync/workspaceSyncModel.js +159 -45
  162. package/dist/sync/workspaceSyncModel.test.js +269 -3
  163. package/dist/types.d.ts +33 -2
  164. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  165. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  166. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  167. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  168. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  169. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  170. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  171. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  172. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
  173. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  174. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  175. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  176. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  177. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  178. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  179. package/dist/ui/assets/index-607WPo_X.js +4 -0
  180. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  181. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  182. package/dist/ui/index.html +3 -3
  183. package/dist/ui/product/agent manager.png +0 -0
  184. package/dist/ui/product/agent_manager_02.png +0 -0
  185. package/dist/utils/aiProfileEvents.d.ts +7 -0
  186. package/dist/utils/aiProfileEvents.js +6 -0
  187. package/dist/utils/assignees.d.ts +6 -0
  188. package/dist/utils/assignees.js +8 -1
  189. package/dist/utils/assignees.test.d.ts +1 -0
  190. package/dist/utils/assignees.test.js +25 -0
  191. package/dist/utils/avatarDisplay.d.ts +1 -0
  192. package/dist/utils/avatarDisplay.js +15 -0
  193. package/dist/utils/contextFiles.d.ts +3 -1
  194. package/dist/utils/contextFiles.js +2 -1
  195. package/dist/utils/taskActivity.d.ts +7 -1
  196. package/dist/utils/taskActivity.js +63 -1
  197. package/dist/utils/taskActivity.test.js +23 -1
  198. package/dist/utils/taskSearch.d.ts +22 -0
  199. package/dist/utils/taskSearch.js +87 -0
  200. package/dist/utils/taskSearch.test.d.ts +1 -0
  201. package/dist/utils/taskSearch.test.js +69 -0
  202. package/dist/utils/taskUtils.js +10 -3
  203. package/dist/utils/taskUtils.test.d.ts +1 -0
  204. package/dist/utils/taskUtils.test.js +19 -0
  205. package/package.json +1 -1
  206. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  207. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  208. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  209. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  210. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  211. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  212. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  213. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  214. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  215. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -194,7 +194,7 @@ export function classifyWorkspaceSyncChanges(input) {
194
194
  model.collaboration.incomingAnnotatedAttachmentSessionDeletes.set(deletedSession.id, deletedSession.deletedAt);
195
195
  continue;
196
196
  }
197
- if (op === 'task-event-upsert') {
197
+ if (op === 'task-event-upsert' || op === 'entity-event-upsert') {
198
198
  const event = normalizeTaskEventSyncPayload(raw.event, workspaceId, String(raw.updatedAt || raw.watermark || '').trim());
199
199
  if (event)
200
200
  model.taskActivity.incomingTaskEvents.set(event.id, event);
@@ -295,6 +295,7 @@ export function applyPlanningSyncChanges(input) {
295
295
  createdAt: initiative.createdAt,
296
296
  updatedAt: initiative.updatedAt,
297
297
  order: initiative.order,
298
+ attachments: initiative.attachments,
298
299
  isArchived: initiative.isArchived
299
300
  }, workspaceId);
300
301
  }
@@ -322,6 +323,7 @@ export function applyPlanningSyncChanges(input) {
322
323
  createdAt: workstream.createdAt,
323
324
  updatedAt: workstream.updatedAt,
324
325
  order: workstream.order,
326
+ attachments: workstream.attachments,
325
327
  isArchived: workstream.isArchived
326
328
  }, workspaceId);
327
329
  }
@@ -384,6 +386,8 @@ export function applyAiProfileAndMemberSyncChanges(input) {
384
386
  color: profile.color,
385
387
  ...(Object.prototype.hasOwnProperty.call(profile, 'avatarUrl') ? { avatarUrl: profile.avatarUrl ?? null } : {}),
386
388
  ...(Object.prototype.hasOwnProperty.call(profile, 'avatarSourceUrl') ? { avatarSourceUrl: profile.avatarSourceUrl ?? null } : {}),
389
+ ...(Object.prototype.hasOwnProperty.call(profile, 'avatarRevision') ? { avatarRevision: profile.avatarRevision ?? 0 } : {}),
390
+ avatarUpdatedAt: profile.avatarUpdatedAt ?? null,
387
391
  description: profile.description ?? null,
388
392
  role: profile.role ?? null,
389
393
  provider: profile.provider ?? null,
@@ -438,6 +442,20 @@ export function applyAiProfileAndMemberSyncChanges(input) {
438
442
  }
439
443
  export async function applyContentSyncChanges(input) {
440
444
  const { core, workspaceId, incomingDocuments, incomingDocumentDeletes, incomingAssets, incomingAssetDeletes, basePath, hasObjectStorageClient, hasDocumentSyncMetadataDrift, hasBinaryAssetSyncMetadataDrift, getExistingDocument, writeWorkspaceSyncDocument, deleteWorkspaceSyncDocument, writeWorkspaceSyncBinaryAsset, deleteWorkspaceSyncBinaryAsset, getExistingBinaryAsset, recordSyncDiagnostic, options, } = input;
445
+ const contentApplyFailures = [];
446
+ const recordContentApplyFailure = (failure) => {
447
+ contentApplyFailures.push(failure);
448
+ };
449
+ const throwIfContentApplyFailed = () => {
450
+ if (contentApplyFailures.length === 0)
451
+ return;
452
+ const first = contentApplyFailures[0];
453
+ const error = new Error(`Failed to apply ${contentApplyFailures.length} synced content change${contentApplyFailures.length === 1 ? '' : 's'}; first failure: ${first.kind} ${first.path}: ${first.error}`);
454
+ error.statusCode = 500;
455
+ error.code = 'SYNC_CONTENT_APPLY_FAILED';
456
+ error.details = { failures: contentApplyFailures.slice(0, 10) };
457
+ throw error;
458
+ };
441
459
  if (incomingDocuments.length > 0) {
442
460
  for (const doc of incomingDocuments) {
443
461
  try {
@@ -448,17 +466,17 @@ export async function applyContentSyncChanges(input) {
448
466
  const incomingHash = createHash('sha256').update(String(doc.content || '')).digest('hex');
449
467
  const existingUpdatedAt = String(existingDoc?.updatedAt || existingDocFallback?.updatedAt || '').trim();
450
468
  let existingHash = String(existingDoc?.contentHash || existingDocFallback?.contentHash || '').trim();
451
- if (!existingHash && !hasObjectStorageClient()) {
469
+ const metadataDrift = hasDocumentSyncMetadataDrift(workspaceId, doc, options);
470
+ if (!hasObjectStorageClient() && (!existingHash || metadataDrift)) {
452
471
  const localDocumentPath = path.resolve(basePath, doc.path);
453
472
  if (fs.existsSync(localDocumentPath)) {
454
473
  existingHash = createHash('sha256').update(fs.readFileSync(localDocumentPath, 'utf8')).digest('hex');
455
474
  }
456
475
  }
457
476
  const compare = resolveSyncConflictOrdering(doc.updatedAt, existingUpdatedAt, incomingHash, existingHash);
458
- const metadataDrift = hasDocumentSyncMetadataDrift(workspaceId, doc, options);
459
477
  if (compare < 0)
460
478
  continue;
461
- if (compare === 0 && existingHash === incomingHash && !metadataDrift)
479
+ if (compare === 0 && existingHash === incomingHash && !metadataDrift && !hasObjectStorageClient())
462
480
  continue;
463
481
  await writeWorkspaceSyncDocument(basePath, doc, workspaceId, {
464
482
  ...options,
@@ -474,6 +492,11 @@ export async function applyContentSyncChanges(input) {
474
492
  assetId: typeof doc.assetId === 'string' ? doc.assetId : null,
475
493
  error: String(error?.message || error || '')
476
494
  });
495
+ recordContentApplyFailure({
496
+ kind: 'document',
497
+ path: doc.path,
498
+ error: String(error?.message || error || '')
499
+ });
477
500
  }
478
501
  }
479
502
  }
@@ -497,14 +520,14 @@ export async function applyContentSyncChanges(input) {
497
520
  const localFileMissing = !hasObjectStorageClient() && !fs.existsSync(localAssetPath);
498
521
  const existingUpdatedAt = String(existing?.updatedAt || '').trim();
499
522
  let existingHash = String(existing?.contentSha256 || '').trim();
500
- if (!existingHash && !hasObjectStorageClient() && !localFileMissing) {
523
+ const metadataDrift = hasBinaryAssetSyncMetadataDrift(workspaceId, asset, options);
524
+ if (!hasObjectStorageClient() && !localFileMissing && (!existingHash || metadataDrift)) {
501
525
  existingHash = createHash('sha256').update(fs.readFileSync(localAssetPath)).digest('hex');
502
526
  }
503
527
  const compare = resolveSyncConflictOrdering(asset.updatedAt, existingUpdatedAt, incomingHash, existingHash);
504
- const metadataDrift = hasBinaryAssetSyncMetadataDrift(workspaceId, asset, options);
505
528
  if (compare < 0)
506
529
  continue;
507
- if (compare === 0 && existingHash === incomingHash && !localFileMissing && !metadataDrift)
530
+ if (compare === 0 && existingHash === incomingHash && !localFileMissing && !metadataDrift && !hasObjectStorageClient())
508
531
  continue;
509
532
  await writeWorkspaceSyncBinaryAsset(basePath, asset, workspaceId, {
510
533
  ...options,
@@ -522,9 +545,15 @@ export async function applyContentSyncChanges(input) {
522
545
  kind: asset.kind,
523
546
  error: String(error?.message || error || '')
524
547
  });
548
+ recordContentApplyFailure({
549
+ kind: 'asset',
550
+ path: asset.path,
551
+ error: String(error?.message || error || '')
552
+ });
525
553
  }
526
554
  }
527
555
  }
556
+ throwIfContentApplyFailed();
528
557
  if (incomingAssetDeletes.size > 0) {
529
558
  for (const [assetPath, deletedAt] of incomingAssetDeletes.entries()) {
530
559
  const existing = getExistingBinaryAsset(assetPath, workspaceId);
@@ -760,22 +789,32 @@ export function applyTaskActivitySyncChanges(input) {
760
789
  return;
761
790
  for (const event of incomingTaskEvents.values()) {
762
791
  try {
763
- const taskId = String(event.taskId || '').trim();
764
- if (!taskId)
792
+ const entityType = event.entityType === 'initiative' || event.entityType === 'workstream' ? event.entityType : 'task';
793
+ const entityId = String(event.entityId || event.taskId || '').trim();
794
+ if (!entityId)
765
795
  continue;
766
- const existingTask = core.getTask(taskId, workspaceId);
767
- if (!existingTask) {
768
- recordSyncDiagnostic(workspaceId, 'apply', 'Skipped task event during sync apply because the task does not exist locally.', {
769
- source: 'apply.taskEventMissingTask',
796
+ const entityExists = entityType === 'task'
797
+ ? core.taskExistsForSync(entityId, workspaceId)
798
+ : entityType === 'initiative'
799
+ ? core.initiativeExistsForSync(entityId, workspaceId)
800
+ : core.workstreamExistsForSync(entityId, workspaceId);
801
+ if (!entityExists) {
802
+ recordSyncDiagnostic(workspaceId, 'apply', 'Skipped entity event during sync apply because the entity does not exist locally.', {
803
+ source: 'apply.entityEventMissingEntity',
770
804
  eventId: event.id,
771
- taskId,
805
+ entityType,
806
+ entityId,
772
807
  });
773
808
  continue;
774
809
  }
775
- const existing = core.listTaskEvents(taskId, workspaceId).find((entry) => entry.id === event.id) || null;
810
+ const existing = core.getEntityEventForSync({
811
+ workspaceId,
812
+ eventId: event.id,
813
+ });
776
814
  const incomingTieBreak = createHash('sha256')
777
815
  .update(JSON.stringify({
778
- taskId,
816
+ entityType,
817
+ entityId,
779
818
  action: event.action,
780
819
  actor: event.actor,
781
820
  actorType: event.actorType,
@@ -785,7 +824,8 @@ export function applyTaskActivitySyncChanges(input) {
785
824
  const existingTieBreak = existing
786
825
  ? createHash('sha256')
787
826
  .update(JSON.stringify({
788
- taskId: existing.taskId,
827
+ entityType: existing.entityType,
828
+ entityId: existing.entityId || ('taskId' in existing ? existing.taskId : ''),
789
829
  action: existing.action,
790
830
  actor: existing.actor,
791
831
  actorType: existing.actorType,
@@ -796,27 +836,45 @@ export function applyTaskActivitySyncChanges(input) {
796
836
  const compare = resolveSyncConflictOrdering(event.createdAt, String(existing?.createdAt || '').trim(), incomingTieBreak, existingTieBreak);
797
837
  if (compare < 0)
798
838
  continue;
799
- core.upsertTaskEventForSync({
800
- id: event.id,
801
- taskId,
802
- workspaceId,
803
- action: event.action,
804
- actor: event.actor,
805
- actorType: event.actorType,
806
- details: event.details,
807
- createdAt: event.createdAt,
808
- });
839
+ if (entityType === 'task') {
840
+ core.upsertEntityEventForSync({
841
+ id: event.id,
842
+ workspaceId,
843
+ entityType: 'task',
844
+ entityId,
845
+ taskId: String(event.taskId || entityId).trim(),
846
+ action: event.action,
847
+ actor: event.actor,
848
+ actorType: event.actorType,
849
+ details: event.details,
850
+ createdAt: event.createdAt,
851
+ });
852
+ }
853
+ else {
854
+ core.upsertEntityEventForSync({
855
+ id: event.id,
856
+ workspaceId,
857
+ entityType,
858
+ entityId,
859
+ action: event.action,
860
+ actor: event.actor,
861
+ actorType: event.actorType,
862
+ details: event.details,
863
+ createdAt: event.createdAt,
864
+ });
865
+ }
809
866
  }
810
867
  catch (error) {
811
868
  const details = {
812
- source: 'apply.taskEventUpsert',
869
+ source: 'apply.entityEventUpsert',
813
870
  eventId: event.id,
814
- taskId: event.taskId,
871
+ entityType: event.entityType,
872
+ entityId: event.entityId || event.taskId,
815
873
  error: String(error?.message || error || ''),
816
874
  errorCode: String(error?.code || '').trim() || null
817
875
  };
818
- recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert task event during sync apply.', details);
819
- throw createSyncRouteError(`Failed to persist task event ${event.id} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'TASK_EVENT_SYNC_APPLY_FAILED', statusCode: 500 });
876
+ recordSyncDiagnostic(workspaceId, 'apply', 'Failed to upsert entity event during sync apply.', details);
877
+ throw createSyncRouteError(`Failed to persist entity event ${event.id} during sync apply: ${String(error?.message || error || 'Unknown error')}`, details, { code: 'TASK_EVENT_SYNC_APPLY_FAILED', statusCode: 500 });
820
878
  }
821
879
  }
822
880
  }
@@ -1,11 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { describe, expect, it, vi, afterEach } from 'vitest';
4
- import { applyCollaborationSyncChanges, applyContentSyncChanges, applyPreparedTaskSyncSnapshot, applyTaskSyncDeletes, classifyWorkspaceSyncChanges, prepareTaskSyncSnapshotApply, } from './syncApplyHandlers.js';
4
+ import { applyCollaborationSyncChanges, applyContentSyncChanges, applyPlanningSyncChanges, applyPreparedTaskSyncSnapshot, applyTaskActivitySyncChanges, applyTaskSyncDeletes, classifyWorkspaceSyncChanges, prepareTaskSyncSnapshotApply, } from './syncApplyHandlers.js';
5
5
  function createCoreFixture(overrides = {}) {
6
6
  return {
7
7
  resolveTaskIdentifier: vi.fn().mockReturnValue(null),
8
8
  getTask: vi.fn().mockReturnValue(null),
9
+ taskExistsForSync: vi.fn().mockReturnValue(true),
10
+ initiativeExistsForSync: vi.fn().mockReturnValue(true),
11
+ workstreamExistsForSync: vi.fn().mockReturnValue(true),
12
+ getTaskEventForSync: vi.fn().mockReturnValue(null),
13
+ getEntityEventForSync: vi.fn().mockReturnValue(null),
9
14
  deleteTaskForSync: vi.fn(),
10
15
  applyWorkspaceSyncSnapshot: vi.fn().mockReturnValue({
11
16
  total: 1,
@@ -185,6 +190,84 @@ describe('syncApplyHandlers', () => {
185
190
  }, workspaceId);
186
191
  expect(result).toEqual(expect.objectContaining({ total: 1 }));
187
192
  });
193
+ it('preserves planning attachments when applying initiative and workstream sync changes', () => {
194
+ const core = createCoreFixture({
195
+ upsertInitiativeForSync: vi.fn(),
196
+ upsertWorkstreamForSync: vi.fn(),
197
+ updateTaxonomies: vi.fn(),
198
+ });
199
+ applyPlanningSyncChanges({
200
+ core,
201
+ workspaceId: 'workspace-1',
202
+ incomingInitiatives: new Map([
203
+ ['initiative-1', {
204
+ id: 'initiative-1',
205
+ referenceNumber: 12,
206
+ referenceLabel: 'I-12',
207
+ title: 'Initiative',
208
+ description: 'Initiative description',
209
+ ownerId: null,
210
+ createdAt: '2026-04-10T09:00:00.000Z',
211
+ updatedAt: '2026-04-10T10:00:00.000Z',
212
+ order: 1,
213
+ attachments: [{
214
+ path: '/api/taskforce/documents/asset-init-doc/content',
215
+ fsPath: 'workspaces/workspace-1/assets/documents/initiative.md',
216
+ assetId: 'asset-init-doc',
217
+ caption: 'Initiative context',
218
+ timestamp: '2026-04-10T10:00:00.000Z'
219
+ }],
220
+ isArchived: false,
221
+ }],
222
+ ]),
223
+ incomingWorkstreams: new Map([
224
+ ['workstream-1', {
225
+ id: 'workstream-1',
226
+ referenceNumber: 689,
227
+ referenceLabel: 'WS-689',
228
+ initiativeId: 'initiative-1',
229
+ title: 'Security Audit Remediation',
230
+ description: 'Workstream description',
231
+ ownerId: null,
232
+ createdAt: '2026-04-10T09:00:00.000Z',
233
+ updatedAt: '2026-04-10T10:00:00.000Z',
234
+ order: 2,
235
+ attachments: [{
236
+ path: '/api/taskforce/documents/asset-workstream-doc/content',
237
+ fsPath: 'workspaces/workspace-1/assets/documents/security-findings-register.md',
238
+ assetId: 'asset-workstream-doc',
239
+ caption: 'Living security findings register for WS-689',
240
+ timestamp: '2026-04-10T10:00:00.000Z'
241
+ }],
242
+ isArchived: false,
243
+ }],
244
+ ]),
245
+ incomingTaxonomies: null,
246
+ incomingTaxonomyState: null,
247
+ incomingTaxonomiesUpdatedAt: '',
248
+ workspaceTaxonomySyncMeta: new Map(),
249
+ recordSyncDiagnostic: vi.fn(),
250
+ });
251
+ expect(core.upsertInitiativeForSync).toHaveBeenCalledWith(expect.objectContaining({
252
+ id: 'initiative-1',
253
+ attachments: [
254
+ expect.objectContaining({
255
+ assetId: 'asset-init-doc',
256
+ caption: 'Initiative context'
257
+ })
258
+ ]
259
+ }), 'workspace-1');
260
+ expect(core.upsertWorkstreamForSync).toHaveBeenCalledWith(expect.objectContaining({
261
+ id: 'workstream-1',
262
+ referenceLabel: 'WS-689',
263
+ attachments: [
264
+ expect.objectContaining({
265
+ assetId: 'asset-workstream-doc',
266
+ caption: 'Living security findings register for WS-689'
267
+ })
268
+ ]
269
+ }), 'workspace-1');
270
+ });
188
271
  it('applies document and asset changes through the content handler helpers', async () => {
189
272
  const core = createCoreFixture();
190
273
  const writeWorkspaceSyncDocument = vi.fn().mockResolvedValue(undefined);
@@ -289,7 +372,7 @@ describe('syncApplyHandlers', () => {
289
372
  expect(writeWorkspaceSyncDocument).toHaveBeenCalledWith('/tmp/taskforce-sync-content', expect.objectContaining({ path: 'docs/spec.md' }), 'workspace-1', { allowReferenceNumberReassignment: true });
290
373
  expect(writeWorkspaceSyncBinaryAsset).toHaveBeenCalledWith('/tmp/taskforce-sync-content', expect.objectContaining({ path: 'assets/proof.png' }), 'workspace-1', { allowReferenceNumberReassignment: true });
291
374
  });
292
- it('reuses persisted content hashes instead of re-reading unchanged local files', async () => {
375
+ it('reuses persisted content hashes instead of re-reading unchanged local files while reasserting object storage payloads', async () => {
293
376
  const existingDocContent = '# Spec';
294
377
  const existingAssetBytes = Buffer.from('png-bytes');
295
378
  const core = createCoreFixture({
@@ -347,8 +430,50 @@ describe('syncApplyHandlers', () => {
347
430
  recordSyncDiagnostic: vi.fn(),
348
431
  });
349
432
  expect(readSpy).not.toHaveBeenCalled();
350
- expect(writeWorkspaceSyncDocument).not.toHaveBeenCalled();
351
- expect(writeWorkspaceSyncBinaryAsset).not.toHaveBeenCalled();
433
+ expect(writeWorkspaceSyncDocument).toHaveBeenCalledWith('/tmp/taskforce-sync-content', expect.objectContaining({ path: 'docs/spec.md', assetId: 'asset-doc-1' }), 'workspace-1', { allowReferenceNumberReassignment: false });
434
+ expect(writeWorkspaceSyncBinaryAsset).toHaveBeenCalledWith('/tmp/taskforce-sync-content', expect.objectContaining({ path: 'assets/proof.png', assetId: 'asset-image-1' }), 'workspace-1', { allowReferenceNumberReassignment: false });
435
+ });
436
+ it('rejects synced content applies when a binary asset write fails', async () => {
437
+ const core = createCoreFixture();
438
+ const recordSyncDiagnostic = vi.fn();
439
+ await expect(applyContentSyncChanges({
440
+ core,
441
+ workspaceId: 'workspace-1',
442
+ incomingDocuments: [],
443
+ incomingDocumentDeletes: new Map(),
444
+ incomingAssets: [
445
+ {
446
+ path: 'assets/proof.png',
447
+ updatedAt: '2026-04-10T10:05:00.000Z',
448
+ contentBase64: Buffer.from('png-bytes').toString('base64'),
449
+ assetId: 'asset-image-1',
450
+ kind: 'image',
451
+ mimeType: 'image/png',
452
+ },
453
+ ],
454
+ incomingAssetDeletes: new Map(),
455
+ basePath: '/tmp/taskforce-sync-content',
456
+ hasObjectStorageClient: () => true,
457
+ hasDocumentSyncMetadataDrift: () => false,
458
+ hasBinaryAssetSyncMetadataDrift: () => false,
459
+ getExistingDocument: () => null,
460
+ writeWorkspaceSyncDocument: vi.fn(),
461
+ deleteWorkspaceSyncDocument: vi.fn(),
462
+ writeWorkspaceSyncBinaryAsset: vi.fn().mockRejectedValue(new Error('R2 write failed')),
463
+ deleteWorkspaceSyncBinaryAsset: vi.fn(),
464
+ getExistingBinaryAsset: () => null,
465
+ recordSyncDiagnostic,
466
+ })).rejects.toMatchObject({
467
+ code: 'SYNC_CONTENT_APPLY_FAILED',
468
+ statusCode: 500,
469
+ });
470
+ expect(recordSyncDiagnostic).toHaveBeenCalledWith('workspace-1', 'apply', 'Failed to apply synced asset change.', expect.objectContaining({
471
+ source: 'assets.apply.write',
472
+ path: 'assets/proof.png',
473
+ assetId: 'asset-image-1',
474
+ kind: 'image',
475
+ error: 'R2 write failed'
476
+ }));
352
477
  });
353
478
  it('applies collaboration upserts and deletes through the collaboration handlers', () => {
354
479
  const core = createCoreFixture();
@@ -443,4 +568,59 @@ describe('syncApplyHandlers', () => {
443
568
  deletedAt: '2026-04-10T11:10:00.000Z',
444
569
  });
445
570
  });
571
+ it('applies entity events through lean sync lookups instead of hydrated entity activity reads', () => {
572
+ const core = createCoreFixture({
573
+ taskExistsForSync: vi.fn().mockReturnValue(true),
574
+ getEntityEventForSync: vi.fn().mockReturnValue(null),
575
+ listTaskEvents: vi.fn(() => {
576
+ throw new Error('hydrated task activity lookup should not run during sync apply');
577
+ }),
578
+ getTask: vi.fn(() => {
579
+ throw new Error('hydrated task lookup should not run during sync apply');
580
+ }),
581
+ getInitiative: vi.fn(() => {
582
+ throw new Error('hydrated initiative lookup should not run during sync apply');
583
+ }),
584
+ getWorkstream: vi.fn(() => {
585
+ throw new Error('hydrated workstream lookup should not run during sync apply');
586
+ }),
587
+ listEntityEventsForSync: vi.fn(() => {
588
+ throw new Error('hydrated entity event listing should not run during sync apply');
589
+ }),
590
+ upsertEntityEventForSync: vi.fn(),
591
+ });
592
+ applyTaskActivitySyncChanges({
593
+ core,
594
+ workspaceId: 'workspace-1',
595
+ incomingTaskEvents: new Map([
596
+ ['task-event-1', {
597
+ id: 'task-event-1',
598
+ taskId: 'task-1',
599
+ entityType: 'task',
600
+ entityId: 'task-1',
601
+ workspaceId: 'workspace-1',
602
+ action: 'task-updated',
603
+ actor: 'user-1',
604
+ actorType: 'human',
605
+ details: { changes: { status: { from: 'task', to: 'review' } } },
606
+ createdAt: '2026-04-10T10:00:00.000Z',
607
+ }],
608
+ ]),
609
+ recordSyncDiagnostic: vi.fn(),
610
+ createSyncRouteError: (message, details) => Object.assign(new Error(message), { details }),
611
+ });
612
+ expect(core.taskExistsForSync).toHaveBeenCalledWith('task-1', 'workspace-1');
613
+ expect(core.getEntityEventForSync).toHaveBeenCalledWith({
614
+ workspaceId: 'workspace-1',
615
+ eventId: 'task-event-1',
616
+ });
617
+ expect(core.upsertEntityEventForSync).toHaveBeenCalledWith(expect.objectContaining({
618
+ id: 'task-event-1',
619
+ taskId: 'task-1',
620
+ entityType: 'task',
621
+ entityId: 'task-1',
622
+ workspaceId: 'workspace-1',
623
+ action: 'task-updated',
624
+ }));
625
+ });
446
626
  });
@@ -9,6 +9,8 @@ export interface SyncAiProfileLike {
9
9
  color?: string | null;
10
10
  avatarUrl?: string | null;
11
11
  avatarSourceUrl?: string | null;
12
+ avatarRevision?: number | null;
13
+ avatarUpdatedAt?: string | null;
12
14
  description?: string | null;
13
15
  role?: string | null;
14
16
  provider?: string | null;
@@ -26,6 +26,8 @@ export function getAiProfileSyncChangeKey(profile) {
26
26
  color: String(source.color || '#8b5cf6').trim() || '#8b5cf6',
27
27
  avatarUrl: normalizeSyncSameOriginAssetUrl(source.avatarUrl) || '',
28
28
  avatarSourceUrl: normalizeSyncSameOriginAssetUrl(source.avatarSourceUrl) || '',
29
+ avatarRevision: Number.isFinite(Number(source.avatarRevision)) ? Math.max(0, Math.floor(Number(source.avatarRevision))) : 0,
30
+ avatarUpdatedAt: normalizeSyncNullableIsoString(source.avatarUpdatedAt) || '',
29
31
  description: normalizeSyncNullableString(source.description) || '',
30
32
  role: normalizeSyncNullableString(source.role) || '',
31
33
  provider: normalizeSyncNullableString(source.provider) || '',
@@ -126,6 +126,7 @@ export interface WorkspacePullSyncResult {
126
126
  pulledActiveUpserts: boolean;
127
127
  pulledArchivedUpserts: boolean;
128
128
  pulledTaskEventUpserts?: boolean;
129
+ pulledPlanningUpserts?: boolean;
129
130
  documentDecryptFailures?: string[];
130
131
  emittedEventIds?: string[];
131
132
  serverDiagnostics?: Record<string, unknown> | null;
@@ -319,6 +319,7 @@ export async function runWorkspacePullSyncService(input) {
319
319
  let pulledActiveUpserts = false;
320
320
  let pulledArchivedUpserts = false;
321
321
  let pulledTaskEventUpserts = false;
322
+ let pulledPlanningUpserts = false;
322
323
  const documentDecryptFailures = new Set();
323
324
  const emittedEventIds = new Set();
324
325
  const pulledAnnotatedAttachmentSessionIds = new Set();
@@ -363,6 +364,7 @@ export async function runWorkspacePullSyncService(input) {
363
364
  pulledActiveUpserts,
364
365
  pulledArchivedUpserts,
365
366
  pulledTaskEventUpserts,
367
+ pulledPlanningUpserts,
366
368
  documentDecryptFailures: Array.from(documentDecryptFailures),
367
369
  serverDiagnostics: null
368
370
  };
@@ -392,10 +394,14 @@ export async function runWorkspacePullSyncService(input) {
392
394
  pulledAnnotatedAttachmentSessionIds.add(sessionId);
393
395
  continue;
394
396
  }
395
- if (change.op === 'task-event-upsert') {
397
+ if (change.op === 'task-event-upsert' || change.op === 'entity-event-upsert') {
396
398
  pulledTaskEventUpserts = true;
397
399
  continue;
398
400
  }
401
+ if (change.op === 'initiative-upsert' || change.op === 'workstream-upsert') {
402
+ pulledPlanningUpserts = true;
403
+ continue;
404
+ }
399
405
  if (change.op === 'document-upsert') {
400
406
  if (typeof change.content === 'string' && typeof process !== 'undefined' && process.env?.TASKFORCE_SYNC_KEY) {
401
407
  try {
@@ -487,6 +493,7 @@ export async function runWorkspacePullSyncService(input) {
487
493
  pulledActiveUpserts,
488
494
  pulledArchivedUpserts,
489
495
  pulledTaskEventUpserts,
496
+ pulledPlanningUpserts,
490
497
  documentDecryptFailures: Array.from(documentDecryptFailures),
491
498
  emittedEventIds: Array.from(emittedEventIds),
492
499
  serverDiagnostics: lastServerDiagnostics
@@ -509,6 +516,7 @@ export async function runWorkspacePullSyncService(input) {
509
516
  pulledActiveUpserts,
510
517
  pulledArchivedUpserts,
511
518
  pulledTaskEventUpserts,
519
+ pulledPlanningUpserts,
512
520
  documentDecryptFailures: Array.from(documentDecryptFailures),
513
521
  emittedEventIds: Array.from(emittedEventIds),
514
522
  serverDiagnostics: lastServerDiagnostics
@@ -560,6 +568,7 @@ export async function runWorkspacePullSyncService(input) {
560
568
  pulledActiveUpserts,
561
569
  pulledArchivedUpserts,
562
570
  pulledTaskEventUpserts,
571
+ pulledPlanningUpserts,
563
572
  documentDecryptFailures: Array.from(documentDecryptFailures),
564
573
  emittedEventIds: Array.from(emittedEventIds),
565
574
  serverDiagnostics: lastServerDiagnostics
@@ -589,6 +598,7 @@ export async function runWorkspacePullSyncService(input) {
589
598
  pulledActiveUpserts,
590
599
  pulledArchivedUpserts,
591
600
  pulledTaskEventUpserts,
601
+ pulledPlanningUpserts,
592
602
  documentDecryptFailures: Array.from(documentDecryptFailures),
593
603
  emittedEventIds: Array.from(emittedEventIds),
594
604
  serverDiagnostics: lastServerDiagnostics
@@ -608,6 +618,7 @@ export async function runWorkspacePullSyncService(input) {
608
618
  pulledActiveUpserts,
609
619
  pulledArchivedUpserts,
610
620
  pulledTaskEventUpserts,
621
+ pulledPlanningUpserts,
611
622
  documentDecryptFailures: Array.from(documentDecryptFailures),
612
623
  emittedEventIds: Array.from(emittedEventIds),
613
624
  serverDiagnostics: lastServerDiagnostics
@@ -629,6 +640,7 @@ export async function runWorkspacePullSyncService(input) {
629
640
  pulledActiveUpserts,
630
641
  pulledArchivedUpserts,
631
642
  pulledTaskEventUpserts,
643
+ pulledPlanningUpserts,
632
644
  documentDecryptFailures: Array.from(documentDecryptFailures),
633
645
  emittedEventIds: Array.from(emittedEventIds),
634
646
  serverDiagnostics: null
@@ -548,6 +548,45 @@ describe('syncService', () => {
548
548
  expect(res.pulledActiveUpserts).toBe(false);
549
549
  expect(res.pulledArchivedUpserts).toBe(false);
550
550
  });
551
+ it('marks planning upserts when the pull contains initiative or workstream changes', async () => {
552
+ vi.mocked(pullWorkspaceChanges).mockResolvedValueOnce({
553
+ ok: true,
554
+ status: 200,
555
+ data: {
556
+ success: true,
557
+ changes: [{
558
+ op: 'workstream-upsert',
559
+ updatedAt: '2026-04-27T22:00:00.000Z',
560
+ workstream: {
561
+ id: 'ws-123',
562
+ title: 'Planning sync workstream',
563
+ description: 'Contains planning attachments',
564
+ createdAt: '2026-04-27T21:00:00.000Z',
565
+ updatedAt: '2026-04-27T22:00:00.000Z',
566
+ attachments: []
567
+ }
568
+ }],
569
+ nextCursor: null,
570
+ hasMore: false
571
+ }
572
+ });
573
+ vi.mocked(applyLocalWorkspaceChanges).mockResolvedValueOnce({
574
+ ok: true,
575
+ failures: [],
576
+ appliedCount: 1,
577
+ data: { success: true }
578
+ });
579
+ const res = await runWorkspacePullSyncService({
580
+ resolveCloudAuthUrl: mockResolveCloudAuthUrl,
581
+ workspaceId: 'ws-1',
582
+ cursor: null,
583
+ bootstrap: false
584
+ });
585
+ expect(res.success).toBe(true);
586
+ expect(res.pulledPlanningUpserts).toBe(true);
587
+ expect(res.pulledActiveUpserts).toBe(false);
588
+ expect(res.pulledArchivedUpserts).toBe(false);
589
+ });
551
590
  it('zero-change bootstrap pull (no changes, hasMore false) returns success true', async () => {
552
591
  vi.mocked(pullWorkspaceChanges).mockResolvedValueOnce({
553
592
  ok: true,
@@ -25,6 +25,10 @@ export interface WorkspacePullFeedResult {
25
25
  hydrationMs: number;
26
26
  encodeMs: number;
27
27
  buildMs: number;
28
+ snapshotErrors: Array<{
29
+ domain: string;
30
+ error: string;
31
+ }>;
28
32
  };
29
33
  }
30
34
  export declare function buildWorkspacePullFeed(input: {