@taskforcehq/taskforce 0.3.327 → 0.3.328

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 (187) hide show
  1. package/dist/Taskforce.module.css +24 -53
  2. package/dist/components/activity/EntityActivityTimeline.js +15 -5
  3. package/dist/components/context/AttachmentPreviewHost.js +1 -1
  4. package/dist/components/context/ContextAttachmentManager.d.ts +3 -0
  5. package/dist/components/context/ContextAttachmentManager.js +116 -20
  6. package/dist/components/features/planning/PlanningEntitySummary.d.ts +3 -1
  7. package/dist/components/features/planning/PlanningEntitySummary.js +3 -2
  8. package/dist/components/features/planning/PlanningLinkPicker.d.ts +3 -1
  9. package/dist/components/features/planning/PlanningLinkPicker.js +5 -3
  10. package/dist/components/features/planning/PlanningModule.js +26 -9
  11. package/dist/components/features/planning/PlanningVisualIdentity.d.ts +14 -0
  12. package/dist/components/features/planning/PlanningVisualIdentity.js +106 -0
  13. package/dist/components/features/planning/planningWorkspaceModel.d.ts +11 -0
  14. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  15. package/dist/components/task/TaskActionHeader.js +2 -2
  16. package/dist/components/task/TaskCard.js +37 -4
  17. package/dist/components/task/TaskContextUpload.d.ts +3 -0
  18. package/dist/components/task/TaskContextUpload.js +2 -2
  19. package/dist/components/task/TaskForm.d.ts +4 -0
  20. package/dist/components/task/TaskForm.js +32 -7
  21. package/dist/components/task/TaskImageReviews.js +16 -16
  22. package/dist/components/task/TaskStatusActions.js +11 -2
  23. package/dist/components/task/TaskWorkstreamPicker.js +2 -1
  24. package/dist/components/ui/DocumentTypeIcon.d.ts +7 -0
  25. package/dist/components/ui/DocumentTypeIcon.js +8 -0
  26. package/dist/components/ui/TaxonomyDropdown.d.ts +9 -1
  27. package/dist/components/ui/TaxonomyDropdown.js +30 -3
  28. package/dist/components/views/StandaloneLayout.js +62 -9
  29. package/dist/components/views/WidgetView.js +8 -5
  30. package/dist/components/views/panels/PlanningDrawer.d.ts +7 -1
  31. package/dist/components/views/panels/PlanningDrawer.js +48 -44
  32. package/dist/config/envSchema.js +1 -0
  33. package/dist/config/localServiceCloudTarget.d.ts +10 -0
  34. package/dist/config/localServiceCloudTarget.js +76 -0
  35. package/dist/core/AiProfileService.d.ts +1 -0
  36. package/dist/core/AiProfileService.js +2 -1
  37. package/dist/core/AttachmentLinkService.js +7 -4
  38. package/dist/core/DeletedTaskLifecycleService.d.ts +4 -0
  39. package/dist/core/DeletedTaskLifecycleService.js +21 -0
  40. package/dist/core/McpTokenService.d.ts +4 -0
  41. package/dist/core/McpTokenService.js +52 -0
  42. package/dist/core/TaskAttachmentLinksDurableMutationService.d.ts +5 -0
  43. package/dist/core/TaskAttachmentLinksDurableMutationService.js +20 -11
  44. package/dist/core/Taskforce.d.ts +63 -1
  45. package/dist/core/Taskforce.js +558 -73
  46. package/dist/core/types.d.ts +2 -1
  47. package/dist/documentReviews/DocumentReviewCommentStore.d.ts +11 -0
  48. package/dist/documentReviews/DocumentReviewCommentStore.js +35 -2
  49. package/dist/documentReviews/service.d.ts +4 -0
  50. package/dist/documentReviews/service.js +141 -46
  51. package/dist/hooks/tasks/useTaskFormActions.d.ts +3 -0
  52. package/dist/hooks/tasks/useTaskFormActions.js +33 -7
  53. package/dist/hooks/useTaskforce.d.ts +59 -50
  54. package/dist/hooks/useTaskforce.js +103 -1
  55. package/dist/mcp/canonicalAssetHelpers.d.ts +13 -0
  56. package/dist/mcp/canonicalAssetHelpers.js +39 -5
  57. package/dist/mcp/collaborationRegistrar.js +15 -13
  58. package/dist/mcp/documentAssetRegistrar.js +16 -9
  59. package/dist/mcp/localServiceProxy.js +4 -1
  60. package/dist/mcp/runtime.d.ts +16 -0
  61. package/dist/mcp/runtime.js +566 -171
  62. package/dist/mcp/taskAttachmentCommandAdapter.d.ts +2 -0
  63. package/dist/mcp/taskAttachmentCommandAdapter.js +14 -8
  64. package/dist/mcp/taskPlanningRegistrar.js +42 -34
  65. package/dist/mcp/toolRegistry.js +2 -2
  66. package/dist/migrations/taskSchemaMigrations.js +35 -0
  67. package/dist/runtime/appGateDefinitions.d.ts +2 -2
  68. package/dist/runtime/appGateDefinitions.js +2 -2
  69. package/dist/server/appAccess.d.ts +12 -0
  70. package/dist/server/appAccess.js +21 -0
  71. package/dist/server/authorizedWorkspaceAccess.d.ts +12 -0
  72. package/dist/server/authorizedWorkspaceAccess.js +40 -0
  73. package/dist/server/cloudCollectionRead.d.ts +25 -0
  74. package/dist/server/cloudCollectionRead.js +120 -0
  75. package/dist/server/cloudCollectionReadPool.d.ts +52 -0
  76. package/dist/server/cloudCollectionReadPool.js +140 -0
  77. package/dist/server/cloudCollectionReadWorker.d.ts +1 -0
  78. package/dist/server/cloudCollectionReadWorker.js +36 -0
  79. package/dist/server/index.d.ts +3 -11
  80. package/dist/server/index.js +53 -40
  81. package/dist/server/localServiceLease.d.ts +3 -0
  82. package/dist/server/localServiceLease.js +9 -1
  83. package/dist/server/routes/documents.d.ts +5 -2
  84. package/dist/server/routes/documents.js +160 -13
  85. package/dist/server/routes/durableTaskHttpRouters.d.ts +2 -0
  86. package/dist/server/routes/durableTaskHttpRouters.js +2 -2
  87. package/dist/server/routes/localService.js +2 -0
  88. package/dist/server/routes/primitives.d.ts +2 -0
  89. package/dist/server/routes/shared.d.ts +24 -0
  90. package/dist/server/routes/shared.js +114 -18
  91. package/dist/server/routes/sync.js +22 -0
  92. package/dist/server/routes/syncV3FeedRoutes.js +4 -2
  93. package/dist/server/routes/syncV3LocalCaptureRoutes.js +20 -2
  94. package/dist/server/routes/syncV3OperationRoutes.js +17 -2
  95. package/dist/server/routes/tasks.js +199 -89
  96. package/dist/server/routes.js +35 -7
  97. package/dist/service/localServiceCli.js +36 -25
  98. package/dist/service/localServiceClientLifecycle.js +67 -6
  99. package/dist/shared/planningIdentity.d.ts +20 -0
  100. package/dist/shared/planningIdentity.js +79 -0
  101. package/dist/storage/postgresAdapter.js +4 -0
  102. package/dist/storage/postgresRequestDiagnostics.d.ts +8 -0
  103. package/dist/storage/postgresRequestDiagnostics.js +24 -0
  104. package/dist/storage/workspaceAssetStore.d.ts +32 -0
  105. package/dist/storage/workspaceAssetStore.js +70 -0
  106. package/dist/sync/coordinator/localSyncExecutionPermitStore.d.ts +16 -0
  107. package/dist/sync/coordinator/localSyncExecutionPermitStore.js +70 -22
  108. package/dist/sync/coordinator/workspaceSyncTransportGateway.js +4 -2
  109. package/dist/sync/engine/workspaceSyncEngineRemoteServices.d.ts +5 -0
  110. package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.d.ts +1 -1
  111. package/dist/sync/engine/workspaceSyncEngineServerRemoteServices.js +15 -0
  112. package/dist/sync/engine/workspaceSyncEngineServerRunnerOperations.js +26 -3
  113. package/dist/sync/engine/workspaceSyncV2ChangeDispatcher.js +2 -0
  114. package/dist/sync/syncApplyHandlers.d.ts +3 -1
  115. package/dist/sync/syncApplyHandlers.js +29 -0
  116. package/dist/sync/syncService.js +9 -0
  117. package/dist/sync/taskSyncPayload.d.ts +1 -0
  118. package/dist/sync/taskSyncPayload.js +6 -0
  119. package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.d.ts +1 -0
  120. package/dist/sync/v3/durableTaskAttachmentLinksMutationRouter.js +21 -3
  121. package/dist/sync/v3/durableTaskHttpBulkMutationRouter.js +17 -4
  122. package/dist/sync/v3/localInitiativeOutboxService.js +4 -2
  123. package/dist/sync/v3/localOutboxDispatcher.js +5 -1
  124. package/dist/sync/v3/localTaskAttachmentLinksOutboxHandler.js +12 -2
  125. package/dist/sync/v3/localTaskAttachmentLinksOutboxService.d.ts +1 -0
  126. package/dist/sync/v3/localTaskAttachmentLinksOutboxService.js +15 -3
  127. package/dist/sync/v3/localWorkstreamOutboxService.js +4 -2
  128. package/dist/sync/v3/syncDurabilityStore.d.ts +8 -0
  129. package/dist/sync/v3/syncDurabilityStore.js +171 -33
  130. package/dist/sync/v3/taskAttachmentLinksMutationService.d.ts +1 -0
  131. package/dist/sync/v3/taskAttachmentLinksMutationService.js +18 -2
  132. package/dist/sync/v3/taskMetadataCoexistence.js +4 -0
  133. package/dist/sync/v3/workspaceSyncV2ProjectionExclusions.js +1 -9
  134. package/dist/sync/v3/workspaceSyncV3CloudOperationHandler.js +1 -0
  135. package/dist/sync/v3/workspaceSyncV3CombinedFeedOrchestrator.js +7 -1
  136. package/dist/sync/v3/workspaceSyncV3CombinedLocalApply.js +15 -3
  137. package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.d.ts +4 -0
  138. package/dist/sync/v3/workspaceSyncV3CombinedRepairBaseline.js +29 -6
  139. package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.d.ts +1 -0
  140. package/dist/sync/v3/workspaceSyncV3CombinedRepairMaterialization.js +29 -7
  141. package/dist/sync/v3/workspaceSyncV3CombinedRepairSnapshot.js +62 -7
  142. package/dist/sync/v3/workspaceSyncV3OperationProtocol.d.ts +1 -0
  143. package/dist/sync/v3/workspaceSyncV3RepairSqlBatching.d.ts +2 -0
  144. package/dist/sync/v3/workspaceSyncV3RepairSqlBatching.js +10 -0
  145. package/dist/sync/v3/workspaceSyncV3TaskRootProjection.d.ts +1 -1
  146. package/dist/sync/v3/workspaceSyncV3TaskRootProjection.js +1 -1
  147. package/dist/sync/workspacePullFeed.d.ts +6 -1
  148. package/dist/sync/workspacePullFeed.js +172 -161
  149. package/dist/sync/workspaceSyncModel.d.ts +11 -0
  150. package/dist/sync/workspaceSyncModel.js +27 -2
  151. package/dist/types.d.ts +8 -0
  152. package/dist/ui/assets/{AiIdentityRosterCard-DB1BLO_j.js → AiIdentityRosterCard-MZs7lVED.js} +1 -1
  153. package/dist/ui/assets/{AiProfilesModule-DPxQeF9N.js → AiProfilesModule-C3i0LOxe.js} +1 -1
  154. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-DHNYaNg6.js → AnnotatedAttachmentWorkspace-DVZk5Tln.js} +1 -1
  155. package/dist/ui/assets/AssetTraySortControl-Kf1pelSy.js +1 -0
  156. package/dist/ui/assets/ContextAttachmentManager-B8boOs6Q.js +3 -0
  157. package/dist/ui/assets/{DocumentWorkspace-BC7sy5TG.js → DocumentWorkspace-DXH4XUsB.js} +4 -4
  158. package/dist/ui/assets/EntityActivityTimeline-BtSTq0ON.js +1 -0
  159. package/dist/ui/assets/PlanningModule-BPVyEa-f.js +1 -0
  160. package/dist/ui/assets/PlanningModule-CoIR9hbV.css +1 -0
  161. package/dist/ui/assets/{PlansPage-BVH_NfgT.js → PlansPage-ChqgxALc.js} +1 -1
  162. package/dist/ui/assets/TaskContextUpload-CB3ol0sN.js +1 -0
  163. package/dist/ui/assets/TaskContextUpload-qmBeUw3u.css +1 -0
  164. package/dist/ui/assets/{TaskSettings-xVsxSa0g.js → TaskSettings-g4ECKNOz.js} +1 -1
  165. package/dist/ui/assets/{TaskforceAgentsModule-CkAxW19K.js → TaskforceAgentsModule-BazdUwxZ.js} +1 -1
  166. package/dist/ui/assets/WorkflowManagerModule-DWFspC-o.js +1 -0
  167. package/dist/ui/assets/index-BUplSsv_.js +7 -0
  168. package/dist/ui/assets/index-gz2EXuoK.css +1 -0
  169. package/dist/ui/assets/{vendor-icons-B8ZNsH1g.js → vendor-icons-BkFLXavV.js} +1 -1
  170. package/dist/ui/index.html +3 -3
  171. package/dist/utils/constants.d.ts +2 -2
  172. package/dist/utils/constants.js +1 -1
  173. package/dist/utils/contextAttachmentUpload.d.ts +2 -0
  174. package/dist/utils/contextAttachmentUpload.js +7 -0
  175. package/dist/utils/contextFiles.d.ts +1 -1
  176. package/dist/utils/contextFiles.js +4 -4
  177. package/package.json +6 -1
  178. package/dist/ui/assets/AssetTraySortControl-DphbyHVc.js +0 -1
  179. package/dist/ui/assets/ContextAttachmentManager-CMxSzHqc.js +0 -3
  180. package/dist/ui/assets/EntityActivityTimeline-pJ2ZjZ-k.js +0 -1
  181. package/dist/ui/assets/PlanningModule-CeTqXMMz.css +0 -1
  182. package/dist/ui/assets/PlanningModule-t5GbvVR9.js +0 -1
  183. package/dist/ui/assets/TaskContextUpload-Ds4LnDzV.css +0 -1
  184. package/dist/ui/assets/TaskContextUpload-bl4fOOUz.js +0 -1
  185. package/dist/ui/assets/WorkflowManagerModule-BKqIGVnF.js +0 -1
  186. package/dist/ui/assets/index-BzJlVnNS.css +0 -1
  187. package/dist/ui/assets/index-Cx7vZJHL.js +0 -7
@@ -21,6 +21,8 @@ const NON_APPLYING_OPERATION_CLASSES = new Set([
21
21
  const MAX_DRAIN_INSPECTION_LIMIT = 500;
22
22
  export const LOCAL_SYNC_PERMIT_RETENTION_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
23
23
  export const LOCAL_SYNC_PERMIT_RETENTION_SOFT_LIMIT = 1_000;
24
+ export const LOCAL_SYNC_PERMIT_RETENTION_HARD_LIMIT = 10_000;
25
+ export const LOCAL_SYNC_OBLIGATION_RETENTION_HARD_LIMIT = 2_000;
24
26
  export const LOCAL_SYNC_PERMIT_RETENTION_DELETE_BATCH = 250;
25
27
  export const LOCAL_SYNC_PERMIT_RETENTION_SWEEP_LIMIT = 5_000;
26
28
  const LOCAL_SYNC_PERMIT_RETENTION_CHECK_INTERVAL_MS = 60 * 60 * 1000;
@@ -166,11 +168,29 @@ export class LocalSyncExecutionPermitStore {
166
168
  FROM local_sync_recovery_obligations
167
169
  WHERE workspace_id = ? AND resolved_at IS NULL
168
170
  `).get(workspaceId);
171
+ const terminalHistory = this.db.prepare(`
172
+ SELECT COUNT(*) AS count, MIN(completed_at) AS oldest_at
173
+ FROM local_sync_execution_permits
174
+ WHERE workspace_id = ?
175
+ AND state IN ('released', 'abandoned-clean', 'abandoned-reconcile-required')
176
+ AND completed_at IS NOT NULL
177
+ `).get(workspaceId);
178
+ const resolvedHistory = this.db.prepare(`
179
+ SELECT COUNT(*) AS count, MIN(resolved_at) AS oldest_at
180
+ FROM local_sync_recovery_obligations
181
+ WHERE workspace_id = ? AND resolved_at IS NOT NULL
182
+ `).get(workspaceId);
169
183
  return {
170
184
  active,
171
185
  byState,
172
186
  recoveryObligations: Number(obligation?.count || 0),
173
187
  localCommitInvariantViolation: this.invariantBlockedWorkspaces.has(workspaceId),
188
+ history: {
189
+ terminalPermits: Number(terminalHistory?.count || 0),
190
+ oldestTerminalPermitAt: terminalHistory?.oldest_at || null,
191
+ resolvedRecoveryObligations: Number(resolvedHistory?.count || 0),
192
+ oldestResolvedRecoveryObligationAt: resolvedHistory?.oldest_at || null,
193
+ },
174
194
  };
175
195
  }
176
196
  inspectGenerationDrain(input) {
@@ -417,6 +437,42 @@ export class LocalSyncExecutionPermitStore {
417
437
  });
418
438
  })();
419
439
  }
440
+ renewAcceptedApplyCompletion(input) {
441
+ return this.db.transaction(() => {
442
+ const current = this.requirePermit(input.permitId);
443
+ this.assertFence(current, input);
444
+ if (current.ownerMode !== input.ownerMode) {
445
+ throw new LocalSyncExecutionPermitError(`Execution permit ${input.permitId} belongs to ${current.ownerMode}, not ${input.ownerMode}.`, 'SYNC_COORDINATOR_PERMIT_IDENTITY_CONFLICT');
446
+ }
447
+ if (current.state !== 'gateway-complete-awaiting-apply'
448
+ || current.upstreamDisposition !== 'accepted') {
449
+ throw new LocalSyncExecutionPermitError(`Execution permit ${input.permitId} has no accepted apply completion to renew.`, 'SYNC_COORDINATOR_PERMIT_STATE_CONFLICT');
450
+ }
451
+ const now = this.now();
452
+ const deadlineMs = Date.parse(input.deadlineAt);
453
+ if (!Number.isFinite(deadlineMs)
454
+ || deadlineMs <= Date.parse(now)
455
+ || deadlineMs <= Date.parse(current.deadlineAt)) {
456
+ throw new LocalSyncExecutionPermitError(`Execution permit ${input.permitId} renewal deadline is invalid.`, 'SYNC_COORDINATOR_PERMIT_EXPIRED');
457
+ }
458
+ const result = this.db.prepare(`
459
+ UPDATE local_sync_execution_permits
460
+ SET deadline_at = ?,
461
+ last_transition_at = ?
462
+ WHERE permit_id = ?
463
+ AND workspace_id = ?
464
+ AND coordinator_generation = ?
465
+ AND server_instance_id = ?
466
+ AND owner_mode = ?
467
+ AND state = 'gateway-complete-awaiting-apply'
468
+ AND upstream_disposition = 'accepted'
469
+ `).run(input.deadlineAt, now, input.permitId, input.workspaceId, input.coordinatorGeneration, input.serverInstanceId, input.ownerMode);
470
+ if (Number(result?.changes || 0) !== 1) {
471
+ throw new LocalSyncExecutionPermitError(`Execution permit ${input.permitId} changed before its renewal committed.`, 'SYNC_COORDINATOR_PERMIT_STATE_CONFLICT');
472
+ }
473
+ return this.read(input.permitId);
474
+ })();
475
+ }
420
476
  completeAcceptedV3Dispatch(input, applyAuthoritativeAndAck) {
421
477
  return this.db.transaction(() => {
422
478
  const current = this.requirePermit(input.permitId);
@@ -677,19 +733,14 @@ export class LocalSyncExecutionPermitStore {
677
733
  WHERE resolved_at IS NOT NULL
678
734
  ORDER BY resolved_at DESC, obligation_id DESC
679
735
  `).all().map(mapObligation);
680
- const protectedObligationIds = new Set(resolvedObligations
681
- .slice(0, LOCAL_SYNC_PERMIT_RETENTION_SOFT_LIMIT)
682
- .map((obligation) => obligation.obligationId));
683
- for (const obligation of resolvedObligations) {
684
- if (obligation.resolvedAt && obligation.resolvedAt >= cutoff) {
685
- protectedObligationIds.add(obligation.obligationId);
686
- }
687
- }
736
+ const obligationAgeDeleteCount = Math.max(0, resolvedObligations.length - LOCAL_SYNC_PERMIT_RETENTION_SOFT_LIMIT);
737
+ const obligationHardDeleteCount = Math.max(0, resolvedObligations.length - LOCAL_SYNC_OBLIGATION_RETENTION_HARD_LIMIT);
688
738
  const obligationCandidates = resolvedObligations
689
- .filter((obligation) => (obligation.resolvedAt
690
- && obligation.resolvedAt < cutoff
691
- && !protectedObligationIds.has(obligation.obligationId)))
739
+ .slice()
692
740
  .reverse()
741
+ .filter((obligation, index) => (index < obligationHardDeleteCount
742
+ || (index < obligationAgeDeleteCount
743
+ && Boolean(obligation.resolvedAt && obligation.resolvedAt < cutoff))))
693
744
  .slice(0, boundedDeleteLimit);
694
745
  const removeObligation = this.db.prepare(`
695
746
  DELETE FROM local_sync_recovery_obligations
@@ -707,14 +758,7 @@ export class LocalSyncExecutionPermitStore {
707
758
  AND completed_at IS NOT NULL
708
759
  ORDER BY completed_at DESC, permit_id DESC
709
760
  `).all().map(mapPermit);
710
- const protectedPermitIds = new Set(terminalPermits
711
- .slice(0, LOCAL_SYNC_PERMIT_RETENTION_SOFT_LIMIT)
712
- .map((permit) => permit.permitId));
713
- for (const permit of terminalPermits) {
714
- if (permit.completedAt && permit.completedAt >= cutoff) {
715
- protectedPermitIds.add(permit.permitId);
716
- }
717
- }
761
+ const protectedPermitIds = new Set();
718
762
  const referencedPermitRows = this.db.prepare(`
719
763
  SELECT permit_id
720
764
  FROM local_sync_recovery_obligations
@@ -723,11 +767,15 @@ export class LocalSyncExecutionPermitStore {
723
767
  if (row.permit_id)
724
768
  protectedPermitIds.add(row.permit_id);
725
769
  }
770
+ const permitAgeDeleteCount = Math.max(0, terminalPermits.length - LOCAL_SYNC_PERMIT_RETENTION_SOFT_LIMIT);
771
+ const permitHardDeleteCount = Math.max(0, terminalPermits.length - LOCAL_SYNC_PERMIT_RETENTION_HARD_LIMIT);
726
772
  const permitCandidates = terminalPermits
727
- .filter((permit) => (permit.completedAt
728
- && permit.completedAt < cutoff
729
- && !protectedPermitIds.has(permit.permitId)))
773
+ .slice()
730
774
  .reverse()
775
+ .filter((permit, index) => (!protectedPermitIds.has(permit.permitId)
776
+ && (index < permitHardDeleteCount
777
+ || (index < permitAgeDeleteCount
778
+ && Boolean(permit.completedAt && permit.completedAt < cutoff)))))
731
779
  .slice(0, boundedDeleteLimit);
732
780
  const removePermit = this.db.prepare(`
733
781
  DELETE FROM local_sync_execution_permits
@@ -500,10 +500,11 @@ export function workspaceSyncGatewayTimeoutMs(operation) {
500
500
  if (operation.type === 'initiative-repair'
501
501
  || operation.type === 'combined-repair'
502
502
  || operation.type === 'v3-feed-repair'
503
- || (operation.type === 'push' && operation.repairMode === true)
504
- || (operation.type === 'pull' && operation.repairMode === true)
505
503
  || (operation.type === 'v3-feed'
506
504
  && (operation.forceRepair === true || Boolean(operation.repairCursor))))
505
+ return 75_000;
506
+ if ((operation.type === 'push' && operation.repairMode === true)
507
+ || (operation.type === 'pull' && operation.repairMode === true))
507
508
  return 180_000;
508
509
  if (operation.type === 'push'
509
510
  || operation.type === 'bootstrap-push')
@@ -638,6 +639,7 @@ const CHANGE_FIELDS = {
638
639
  'task-relationship-delete': { allowed: ['op', 'relationship'], required: ['relationship'], objectField: 'relationship' },
639
640
  'initiative-upsert': { allowed: ['op', 'initiative'], required: ['initiative'], objectField: 'initiative' },
640
641
  'workstream-upsert': { allowed: ['op', 'workstream'], required: ['workstream'], objectField: 'workstream' },
642
+ 'planning-identity-upsert': { allowed: ['op', 'identity'], required: ['identity'], objectField: 'identity' },
641
643
  'taxonomy-upsert': { allowed: ['op', 'taxonomies', 'taxonomyState', 'updatedAt', 'durableRevision'], required: ['updatedAt'] },
642
644
  'ai-profile-upsert': { allowed: ['op', 'profile'], required: ['profile'], objectField: 'profile' },
643
645
  'document-upsert': { allowed: [
@@ -83,6 +83,11 @@ export interface WorkspaceSyncEngineRemoteServices {
83
83
  workspaceId: string;
84
84
  receipts: readonly WorkspaceSyncEngineRemoteTransferReceipt[];
85
85
  }): void | Promise<void>;
86
+ /** Keeps accepted applying receipts alive until a bounded transfer reaches its atomic commit. */
87
+ retainTransferReceipts?(input: {
88
+ workspaceId: string;
89
+ receipts: readonly WorkspaceSyncEngineRemoteTransferReceipt[];
90
+ }): void | Promise<void>;
86
91
  buildV2ApplyCompletionGroup(input: {
87
92
  workspaceId: string;
88
93
  previousCursor: string | null;
@@ -1,7 +1,7 @@
1
1
  import type { LocalSyncExecutionPermitStore } from '../coordinator/localSyncExecutionPermitStore.js';
2
2
  import type { WorkspaceSyncTransportGateway } from '../coordinator/workspaceSyncTransportGateway.js';
3
3
  import type { WorkspaceSyncEngineRemoteServices } from './workspaceSyncEngineRemoteServices.js';
4
- type ServerRemotePermitStore = Pick<LocalSyncExecutionPermitStore, 'issueServer' | 'read' | 'acknowledgeNonApplyingCompletion' | 'completeAcceptedPushWithoutEcho' | 'abandonPermit'>;
4
+ type ServerRemotePermitStore = Pick<LocalSyncExecutionPermitStore, 'issueServer' | 'read' | 'acknowledgeNonApplyingCompletion' | 'completeAcceptedPushWithoutEcho' | 'renewAcceptedApplyCompletion' | 'abandonPermit'>;
5
5
  type ServerRemoteGateway = Pick<WorkspaceSyncTransportGateway, 'executeServer'>;
6
6
  export interface ServerWorkspaceSyncEngineRemoteServicesOptions {
7
7
  workspaceId: string;
@@ -181,6 +181,7 @@ export function createServerWorkspaceSyncEngineRemoteServices(options) {
181
181
  workspaceId: options.workspaceId,
182
182
  previousBoundary: input.previousBoundary,
183
183
  operationClass,
184
+ retentionMs: permitDeadlineMs,
184
185
  });
185
186
  return {
186
187
  ...result,
@@ -417,6 +418,20 @@ export function createServerWorkspaceSyncEngineRemoteServices(options) {
417
418
  }
418
419
  }
419
420
  },
421
+ retainTransferReceipts: ({ workspaceId, receipts }) => {
422
+ const metadata = readReceipts(workspaceId, receipts);
423
+ const retainedAt = now();
424
+ for (const receipt of metadata) {
425
+ options.permitStore.renewAcceptedApplyCompletion({
426
+ permitId: receipt.permitId,
427
+ workspaceId,
428
+ coordinatorGeneration: options.coordinatorGeneration,
429
+ ownerMode: 'server',
430
+ serverInstanceId: options.serverInstanceId,
431
+ deadlineAt: new Date(retainedAt.getTime() + receipt.retentionMs).toISOString(),
432
+ });
433
+ }
434
+ },
420
435
  buildV2ApplyCompletionGroup: ({ workspaceId, previousCursor, nextCursor, receipts, }) => completionGroup(workspaceId, receipts, v2Boundary(previousCursor), v2Boundary(nextCursor)),
421
436
  buildV2PushApplyCompletionGroup: ({ workspaceId, pushBoundary, receipts, }) => completionGroup(workspaceId, receipts, parseWorkspaceSyncEngineV2PushBoundary(pushBoundary), parseWorkspaceSyncEngineV2PushBoundary(pushBoundary)),
422
437
  buildV3ApplyCompletionGroup: ({ workspaceId, apply, receipts }) => {
@@ -146,6 +146,20 @@ export function createWorkspaceSyncEngineServerRunnerOperations(options) {
146
146
  transformCheckpointAtTransferBoundary: (boundary) => (transferBoundaryCheckpointTransform(boundary)),
147
147
  },
148
148
  });
149
+ const prepareAndDrainRecoveryReplay = async (preparedRecovery) => {
150
+ let recovery = preparedRecovery
151
+ ?? await localServices.prepareOutboxRecovery(workspaceId);
152
+ if (recovery.kind === 'recovery' && recovery.state === 'replay_queued') {
153
+ const drainResult = await options.drainV3Outbox?.({
154
+ beforeTransport: () => undefined,
155
+ });
156
+ if (drainResult === 'v2-content-push-required') {
157
+ fail('v2-content-push', 'A referenced attachment must be published before durable repair can continue.', 'SYNC_RUNNER_V2_CONTENT_PUSH_REQUIRED');
158
+ }
159
+ recovery = await localServices.prepareOutboxRecovery(workspaceId);
160
+ }
161
+ return recovery;
162
+ };
149
163
  const ensureReady = options.ensureCloudWorkspaceReadyForSync
150
164
  || (async () => {
151
165
  const result = await options.remoteServices.handshakeWorkspace(workspaceId);
@@ -884,8 +898,7 @@ export function createWorkspaceSyncEngineServerRunnerOperations(options) {
884
898
  }
885
899
  if (localRecovery.kind === 'recovery'
886
900
  && localRecovery.state === 'replay_queued') {
887
- await options.drainV3Outbox?.({ beforeTransport: () => undefined });
888
- localRecovery = await localServices.prepareOutboxRecovery(workspaceId);
901
+ localRecovery = await prepareAndDrainRecoveryReplay(localRecovery);
889
902
  }
890
903
  if (localRecovery.kind === 'recovery'
891
904
  && localRecovery.state === 'replay_queued') {
@@ -912,7 +925,10 @@ export function createWorkspaceSyncEngineServerRunnerOperations(options) {
912
925
  const continuationInvalid = continuation !== null && (continuation.coordinatorGeneration !== input.coordinatorGeneration
913
926
  || continuation.ownershipFingerprint !== ownershipFingerprint
914
927
  || (recoveryBatchId !== null
915
- && continuation.recoveryBatchId !== recoveryBatchId));
928
+ && continuation.recoveryBatchId !== recoveryBatchId)
929
+ || (recoveryBatchId !== null
930
+ && continuation.kind !== 'v3-combined'
931
+ && continuation.kind !== 'v2-reconcile'));
916
932
  if (continuationInvalid || (checkpoint.repairContinuation !== null && !continuation)) {
917
933
  if (checkpoint.pendingV2Push) {
918
934
  await push({
@@ -1030,6 +1046,13 @@ export function createWorkspaceSyncEngineServerRunnerOperations(options) {
1030
1046
  }
1031
1047
  continuation = checkpoint.repairContinuation;
1032
1048
  }
1049
+ if (continuation.recoveryBatchId) {
1050
+ const postPullRecovery = await prepareAndDrainRecoveryReplay();
1051
+ if (postPullRecovery.kind === 'recovery'
1052
+ || Number(options.inspectV3Outbox?.().activeDepth || 0) > 0) {
1053
+ fail('repair', 'Durable recovery replay must settle before authoritative repair continues.', 'SYNC_RUNNER_RECOVERY_REPLAY_PENDING', RETRY_MAX_DELAY_MS);
1054
+ }
1055
+ }
1033
1056
  let pushResult = null;
1034
1057
  if (continuation.kind === 'final-push') {
1035
1058
  pushResult = await push({
@@ -279,6 +279,7 @@ export function createWorkspaceSyncV2ChangeDispatcher(dependencies) {
279
279
  workspaceId,
280
280
  incomingInitiatives: applyModel.planning.incomingInitiatives,
281
281
  incomingWorkstreams: applyModel.planning.incomingWorkstreams,
282
+ incomingIdentities: applyModel.planning.incomingIdentities,
282
283
  recordSyncDiagnostic,
283
284
  ...(options.coexistenceDirection ? {
284
285
  coexistenceDirection: options.coexistenceDirection,
@@ -722,6 +723,7 @@ export function createWorkspaceSyncV2ChangeDispatcher(dependencies) {
722
723
  workspaceId,
723
724
  incomingInitiatives: applyModel.planning.incomingInitiatives,
724
725
  incomingWorkstreams: applyModel.planning.incomingWorkstreams,
726
+ incomingIdentities: applyModel.planning.incomingIdentities,
725
727
  recordSyncDiagnostic,
726
728
  ...(options.coexistenceDirection ? {
727
729
  coexistenceDirection: options.coexistenceDirection,
@@ -1,6 +1,6 @@
1
1
  import type { TaskforceCore, TaskforceSyncCapable } from '../core/Taskforce.js';
2
2
  import type { DeletedTaskDetails, DeletedTaskSource, TaskRelationshipRecord } from '../core/Taskforce.js';
3
- import type { WorkspaceSyncAiProfileSnapshot, WorkspaceSyncAgentConversationSnapshot, WorkspaceSyncAgentRoleSnapshot, WorkspaceSyncAnnotatedAttachmentSessionSnapshot, WorkspaceSyncChange, WorkspaceSyncDocumentReviewCommentSnapshot, WorkspaceSyncDocumentReviewSessionSnapshot, WorkspaceSyncDurableTaskRevision, WorkspaceSyncDurableTaxonomyRevision, WorkspaceSyncEntityEventSnapshot, WorkspaceSyncInitiativeSnapshot, WorkspaceSyncTaskforceAgentSnapshot, WorkspaceSyncTaskforceAgentSkillSnapshot, WorkspaceSyncTaskSnapshot, WorkspaceSyncWorkspaceMemberSnapshot, WorkspaceSyncWorkstreamSnapshot } from './workspaceSyncModel.js';
3
+ import type { WorkspaceSyncAiProfileSnapshot, WorkspaceSyncAgentConversationSnapshot, WorkspaceSyncAgentRoleSnapshot, WorkspaceSyncAnnotatedAttachmentSessionSnapshot, WorkspaceSyncChange, WorkspaceSyncDocumentReviewCommentSnapshot, WorkspaceSyncDocumentReviewSessionSnapshot, WorkspaceSyncDurableTaskRevision, WorkspaceSyncDurableTaxonomyRevision, WorkspaceSyncEntityEventSnapshot, WorkspaceSyncInitiativeSnapshot, WorkspaceSyncPlanningIdentitySnapshot, WorkspaceSyncTaskforceAgentSnapshot, WorkspaceSyncTaskforceAgentSkillSnapshot, WorkspaceSyncTaskSnapshot, WorkspaceSyncWorkspaceMemberSnapshot, WorkspaceSyncWorkstreamSnapshot } from './workspaceSyncModel.js';
4
4
  import type { BinaryAssetSyncPayload, DocumentSyncPayload } from './contentSyncPayload.js';
5
5
  import type { WorkspaceTaxonomyState } from '../shared/taxonomyState.js';
6
6
  import { WorkspaceSyncDurabilityStore } from './v3/syncDurabilityStore.js';
@@ -55,6 +55,7 @@ export interface WorkspaceSyncIncomingApplyModel {
55
55
  incomingTaxonomyDurableRevision: WorkspaceSyncDurableTaxonomyRevision | null;
56
56
  incomingInitiatives: Map<string, WorkspaceSyncInitiativeSnapshot>;
57
57
  incomingWorkstreams: Map<string, WorkspaceSyncWorkstreamSnapshot>;
58
+ incomingIdentities: Map<string, WorkspaceSyncPlanningIdentitySnapshot>;
58
59
  };
59
60
  profiles: {
60
61
  incomingAiProfiles: Map<string, WorkspaceSyncAiProfileSnapshot>;
@@ -163,6 +164,7 @@ export declare function applyPlanningSyncChanges(input: {
163
164
  workspaceId: string;
164
165
  incomingInitiatives: Map<string, WorkspaceSyncInitiativeSnapshot>;
165
166
  incomingWorkstreams: Map<string, WorkspaceSyncWorkstreamSnapshot>;
167
+ incomingIdentities?: Map<string, WorkspaceSyncPlanningIdentitySnapshot>;
166
168
  recordSyncDiagnostic: RecordSyncDiagnostic;
167
169
  coexistenceDirection?: TaskMetadataCoexistenceDirection;
168
170
  assertedActiveV3InitiativeIds?: ReadonlySet<string>;
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { createHash } from 'crypto';
4
+ import { assertPlanningIdentityColor, assertPlanningIdentityIcon } from '../shared/planningIdentity.js';
4
5
  import { parseAiProfileColorSource } from '../shared/aiProfileColors.js';
5
6
  import { normalizeBinaryAssetDeleteSyncPayload, normalizeBinaryAssetSyncPayload, normalizeDocumentDeleteSyncPayload, normalizeDocumentSyncPayload, } from './contentSyncPayload.js';
6
7
  import { normalizeAiProfileSyncPayload, normalizeAgentConversationSyncPayload, normalizeAgentRoleSyncPayload, normalizeAnnotatedAttachmentSessionSyncPayload, normalizeDeleteSyncPayload, normalizeDocumentReviewCommentSyncPayload, normalizeDocumentReviewSessionSyncPayload, normalizeTaskforceAgentSkillSyncPayload, normalizeTaskforceAgentSyncPayload, } from './collaborationSyncPayload.js';
@@ -32,6 +33,7 @@ function createIncomingApplyModel() {
32
33
  incomingTaxonomyDurableRevision: null,
33
34
  incomingInitiatives: new Map(),
34
35
  incomingWorkstreams: new Map(),
36
+ incomingIdentities: new Map(),
35
37
  },
36
38
  profiles: {
37
39
  incomingAiProfiles: new Map(),
@@ -68,6 +70,7 @@ function shouldSkipTaskClassification(op) {
68
70
  || op === 'task-relationship-delete'
69
71
  || op === 'initiative-upsert'
70
72
  || op === 'workstream-upsert'
73
+ || op === 'planning-identity-upsert'
71
74
  || op === 'document-upsert'
72
75
  || op === 'document-delete'
73
76
  || op === 'asset-upsert'
@@ -185,6 +188,26 @@ export function classifyWorkspaceSyncChanges(input) {
185
188
  model.planning.incomingWorkstreams.set(workstream.id, workstream);
186
189
  continue;
187
190
  }
191
+ if (op === 'planning-identity-upsert') {
192
+ const value = raw.identity;
193
+ const entityType = value?.entityType === 'initiative' || value?.entityType === 'workstream'
194
+ ? value.entityType
195
+ : null;
196
+ const entityId = String(value?.entityId || '').trim();
197
+ const updatedAt = String(value?.updatedAt || raw.updatedAt || raw.watermark || '').trim();
198
+ if (!entityType || !entityId || !Number.isFinite(Date.parse(updatedAt))) {
199
+ throw new Error('Planning identity sync payload is invalid.');
200
+ }
201
+ const identity = {
202
+ entityType,
203
+ entityId,
204
+ icon: assertPlanningIdentityIcon(value?.icon),
205
+ color: assertPlanningIdentityColor(value?.color),
206
+ updatedAt,
207
+ };
208
+ model.planning.incomingIdentities.set(`${entityType}:${entityId}`, identity);
209
+ continue;
210
+ }
188
211
  if (op === 'ai-profile-upsert') {
189
212
  const profile = normalizeAiProfileSyncPayload(raw.profile, workspaceId, normalizeIncomingAiProfileSurfaceType, normalizeIncomingAiProfileSeatScope, 'apply.aiProfileUpsert');
190
213
  const profileId = String(profile?.id || '').trim();
@@ -690,6 +713,12 @@ export function applyPlanningSyncChanges(input) {
690
713
  }
691
714
  }
692
715
  }
716
+ for (const identity of input.incomingIdentities?.values() || []) {
717
+ const applied = core.applyPlanningEntityIdentityForSync(identity, workspaceId);
718
+ if (!applied) {
719
+ throw new Error(`Planning identity parent is missing: ${identity.entityType}:${identity.entityId}`);
720
+ }
721
+ }
693
722
  }
694
723
  export function applyTaxonomySyncChanges(input) {
695
724
  const hasTaxonomy = Boolean(input.incomingTaxonomyState) || Array.isArray(input.incomingTaxonomies);
@@ -217,6 +217,7 @@ function workspacePushChangeStableIdentity(change) {
217
217
  case 'task-relationship-delete': return `${change.op}:${change.relationship.id}`;
218
218
  case 'initiative-upsert': return `${change.op}:${change.initiative.id}`;
219
219
  case 'workstream-upsert': return `${change.op}:${change.workstream.id}`;
220
+ case 'planning-identity-upsert': return `${change.op}:${change.identity.entityType}:${change.identity.entityId}`;
220
221
  case 'taxonomy-upsert': return change.op;
221
222
  case 'ai-profile-upsert': return `${change.op}:${change.profile.id}`;
222
223
  case 'document-upsert':
@@ -284,6 +285,8 @@ function resolveWorkspacePushPhase(change) {
284
285
  return 0;
285
286
  case 'workstream-upsert':
286
287
  return 1;
288
+ case 'planning-identity-upsert':
289
+ return 2;
287
290
  case 'upsert':
288
291
  return 2;
289
292
  case 'document-upsert':
@@ -689,6 +692,12 @@ export async function runWorkspacePushSyncService(input) {
689
692
  completedChangeCount += plainBatchChanges.length;
690
693
  continue;
691
694
  }
695
+ if (pushTransferReceipts.length > 0) {
696
+ await remoteServices.retainTransferReceipts?.({
697
+ workspaceId: input.workspaceId,
698
+ receipts: pushTransferReceipts,
699
+ });
700
+ }
692
701
  const sendPush = async () => {
693
702
  const requestStartedAtMs = Date.now();
694
703
  const result = await remoteServices.pushWorkspaceChanges({
@@ -25,6 +25,7 @@ export interface TaskSyncPayload {
25
25
  referenceNumber?: number | null;
26
26
  comments?: Comment[];
27
27
  attachments?: Array<string | AttachmentItem>;
28
+ cardCoverAssetId?: string | null;
28
29
  checklistItems?: ChecklistItem[];
29
30
  }
30
31
  export declare function serializeTaskForSync(task: Partial<TaskItem> | null | undefined): TaskSyncPayload;
@@ -64,6 +64,7 @@ function cloneChecklistItems(value) {
64
64
  }
65
65
  export function serializeTaskForSync(task) {
66
66
  const source = task && typeof task === 'object' ? task : {};
67
+ const hasCardCoverAssetId = Object.prototype.hasOwnProperty.call(source, 'cardCoverAssetId');
67
68
  return {
68
69
  id: String(source.id || '').trim(),
69
70
  title: String(source.title || '').trim(),
@@ -89,6 +90,9 @@ export function serializeTaskForSync(task) {
89
90
  referenceNumber: toOptionalInteger(source.referenceNumber, { min: 1, nullable: true }),
90
91
  comments: cloneComments(source.comments),
91
92
  attachments: cloneAttachments(source.attachments),
93
+ ...(hasCardCoverAssetId
94
+ ? { cardCoverAssetId: toNullableString(source.cardCoverAssetId) }
95
+ : {}),
92
96
  checklistItems: cloneChecklistItems(source.checklistItems),
93
97
  };
94
98
  }
@@ -102,6 +106,7 @@ export function normalizeTaskSyncPayload(raw) {
102
106
  return serializeTaskForSync(source);
103
107
  }
104
108
  export function taskSyncPayloadToCoreTask(task) {
109
+ const hasCardCoverAssetId = Object.prototype.hasOwnProperty.call(task, 'cardCoverAssetId');
105
110
  return {
106
111
  id: task.id,
107
112
  title: task.title,
@@ -127,6 +132,7 @@ export function taskSyncPayloadToCoreTask(task) {
127
132
  referenceNumber: task.referenceNumber ?? undefined,
128
133
  comments: task.comments,
129
134
  attachments: task.attachments,
135
+ ...(hasCardCoverAssetId ? { cardCoverAssetId: task.cardCoverAssetId } : {}),
130
136
  checklistItems: task.checklistItems
131
137
  };
132
138
  }
@@ -40,6 +40,7 @@ export declare function createDurableTaskAttachmentLinksMutationRouter(input: {
40
40
  replace(args: {
41
41
  taskId: string;
42
42
  links: readonly WorkspaceSyncV3TaskAttachmentLink[];
43
+ cardCoverAssetId?: string | null;
43
44
  }): {
44
45
  state: "pending" | "committed";
45
46
  route: "local-outbox" | "cloud-acceptance";
@@ -4,6 +4,7 @@ import { enqueueLocalTaskAttachmentLinksMutation, } from './localTaskAttachmentL
4
4
  import { DurableTaskAttachmentLinksMutationError, runDurableTaskAttachmentLinksReplace, } from './taskAttachmentLinksMutationService.js';
5
5
  import { WorkspaceSyncDurabilityStore, WorkspaceSyncEntityBusyError } from './syncDurabilityStore.js';
6
6
  import { serializeWorkspaceSyncV3TaskAttachmentLinksPayload, } from './workspaceSyncV3NestedTaskCodecs.js';
7
+ import { resolveTaskCardCoverAssetIdForLinks } from '../../core/TaskAttachmentLinksDurableMutationService.js';
7
8
  const OPERATION_LEASE_MS = 30_000;
8
9
  function enabled(value) {
9
10
  return /^(1|true|on|yes)$/i.test(String(value || '').trim());
@@ -93,6 +94,22 @@ export function createDurableTaskAttachmentLinksMutationRouter(input) {
93
94
  catch (error) {
94
95
  throw new DurableTaskAttachmentLinksMutationError(error instanceof Error ? error.message : 'Task attachment links are invalid.', 'TASK_ATTACHMENT_LINKS_INVALID', 400);
95
96
  }
97
+ const currentTask = input.core.getTask(taskId, workspaceId);
98
+ if (!currentTask)
99
+ throw new DurableTaskAttachmentLinksMutationError('Task not found.', 'TASK_NOT_FOUND', 404);
100
+ let cardCoverAssetId;
101
+ try {
102
+ cardCoverAssetId = resolveTaskCardCoverAssetIdForLinks({
103
+ currentCardCoverAssetId: currentTask.cardCoverAssetId,
104
+ ...(Object.prototype.hasOwnProperty.call(args, 'cardCoverAssetId')
105
+ ? { requestedCardCoverAssetId: args.cardCoverAssetId }
106
+ : {}),
107
+ links,
108
+ });
109
+ }
110
+ catch (error) {
111
+ throw new DurableTaskAttachmentLinksMutationError(error instanceof Error ? error.message : 'Task card cover is invalid.', 'TASK_ATTACHMENT_LINKS_INVALID', Number(error?.statusCode) || 400);
112
+ }
96
113
  const acceptedAt = now();
97
114
  const acceptedAtIso = acceptedAt.toISOString();
98
115
  const operationId = operationIdRequired(input.operationId);
@@ -123,7 +140,8 @@ export function createDurableTaskAttachmentLinksMutationRouter(input) {
123
140
  catch {
124
141
  throw new DurableTaskAttachmentLinksMutationError('Captured task attachment-link payload is invalid.', 'TASK_ATTACHMENT_LINKS_IDENTITY_CONFLICT', 409);
125
142
  }
126
- if (canonicalWorkspaceSyncJson(frozen) !== canonicalWorkspaceSyncJson(links)) {
143
+ if (canonicalWorkspaceSyncJson(frozen) !== canonicalWorkspaceSyncJson(links)
144
+ || String(payload.cardCoverAssetId || '') !== String(cardCoverAssetId || '')) {
127
145
  throw new DurableTaskAttachmentLinksMutationError('Operation identity is assigned to different task attachment links.', 'TASK_ATTACHMENT_LINKS_IDENTITY_CONFLICT', 409);
128
146
  }
129
147
  const task = input.core.getTask(taskId, workspaceId);
@@ -138,7 +156,7 @@ export function createDurableTaskAttachmentLinksMutationRouter(input) {
138
156
  let result;
139
157
  try {
140
158
  result = enqueueLocalTaskAttachmentLinksMutation({
141
- store, core: serviceCore, identity, taskId, links, now: acceptedAtIso,
159
+ store, core: serviceCore, identity, taskId, links, cardCoverAssetId, now: acceptedAtIso,
142
160
  actorRef, onPostCommitError: input.onPostCommitError,
143
161
  requireV3CombinedOwnership: persistedOwnershipChecked,
144
162
  });
@@ -182,7 +200,7 @@ export function createDurableTaskAttachmentLinksMutationRouter(input) {
182
200
  });
183
201
  }
184
202
  const result = runDurableTaskAttachmentLinksReplace({
185
- store, core: serviceCore, tenantId, workspaceId, clientId, operationId, taskId, links,
203
+ store, core: serviceCore, tenantId, workspaceId, clientId, operationId, taskId, links, cardCoverAssetId,
186
204
  baseEntityRevision: coordinates.entityRevision, baseLifecycleRevision: coordinates.lifecycleRevision,
187
205
  now: acceptedAtIso, leaseExpiresAt: new Date(acceptedAt.getTime() + OPERATION_LEASE_MS).toISOString(),
188
206
  actorRef, onPostCommitError: input.onPostCommitError,
@@ -245,15 +245,22 @@ export function createDurableTaskHttpBulkMutationRouter(input) {
245
245
  if (hasAttachments && !Array.isArray(item.fields.attachments)) {
246
246
  throw new TaskforceRuleError('attachments must be an array.', 400, 'TASK_ATTACHMENT_LINKS_INVALID');
247
247
  }
248
- const attachmentOperationId = hasAttachments
248
+ const hasCardCover = Object.prototype.hasOwnProperty.call(item.fields, 'cardCoverAssetId');
249
+ if (hasCardCover && item.fields.cardCoverAssetId !== null
250
+ && (typeof item.fields.cardCoverAssetId !== 'string' || !item.fields.cardCoverAssetId.trim())) {
251
+ throw new TaskforceRuleError('cardCoverAssetId must be a non-empty string or null.', 400, 'TASK_CARD_COVER_INVALID');
252
+ }
253
+ const attachmentOperationId = hasAttachments || hasCardCover
249
254
  ? input.deriveChildOperationId('bulk-update-fields-attachments', item.index, item.id)
250
255
  : '';
251
- const attachmentLinks = hasAttachments
256
+ const attachmentLinks = hasAttachments || hasCardCover
252
257
  ? input.createAttachmentLinksRouter(attachmentOperationId)
253
258
  : null;
254
259
  const durableAttachmentLinks = attachmentLinks?.enabled === true;
255
260
  const normalizedAttachmentLinks = durableAttachmentLinks
256
- ? normalizeAttachmentLinks(item.fields.attachments)
261
+ ? normalizeAttachmentLinks(hasAttachments
262
+ ? item.fields.attachments
263
+ : input.core.getTask(item.id, input.workspaceId)?.attachments || [])
257
264
  : [];
258
265
  const checklistOperationId = hasChecklist
259
266
  ? input.deriveChildOperationId('bulk-update-fields-checklist', item.index, item.id)
@@ -262,7 +269,8 @@ export function createDurableTaskHttpBulkMutationRouter(input) {
262
269
  const durableChecklist = checklist?.enabled === true;
263
270
  const rootFields = durableChecklist || durableAttachmentLinks
264
271
  ? Object.fromEntries(Object.entries(item.fields).filter(([key]) => (!(durableChecklist && key === 'checklistItems')
265
- && !(durableAttachmentLinks && key === 'attachments'))))
272
+ && !(durableAttachmentLinks && key === 'attachments')
273
+ && !(durableAttachmentLinks && key === 'cardCoverAssetId'))))
266
274
  : item.fields;
267
275
  let updated = input.core.getTask(item.id, input.workspaceId);
268
276
  if (Object.keys(rootFields).length > 0) {
@@ -299,6 +307,11 @@ export function createDurableTaskHttpBulkMutationRouter(input) {
299
307
  updated = batch.runChild(() => attachmentLinks.replace({
300
308
  taskId: item.id,
301
309
  links: normalizedAttachmentLinks,
310
+ ...(hasCardCover ? {
311
+ cardCoverAssetId: item.fields.cardCoverAssetId === null
312
+ ? null
313
+ : String(item.fields.cardCoverAssetId).trim(),
314
+ } : {}),
302
315
  }).task);
303
316
  }
304
317
  if (updated)
@@ -294,8 +294,9 @@ function validateActivitySemantics(input) {
294
294
  const authoritativeAttachmentLinks = validateCanonicalPlanningAttachmentLinks(input.authoritative.attachments);
295
295
  const comparableExpectedDetails = normalizePlanningActivityDetailsForComparison(expectedDetails, input.record.mutationKind, authoritativeAttachmentLinks);
296
296
  const comparableAuthoritativeDetails = normalizePlanningActivityDetailsForComparison((input.activity.details || {}), input.record.mutationKind, authoritativeAttachmentLinks);
297
- if (fingerprintWorkspaceSyncPayload(comparableExpectedDetails)
298
- !== fingerprintWorkspaceSyncPayload(comparableAuthoritativeDetails)) {
297
+ if (!input.boundedAttachmentRebase
298
+ && fingerprintWorkspaceSyncPayload(comparableExpectedDetails)
299
+ !== fingerprintWorkspaceSyncPayload(comparableAuthoritativeDetails)) {
299
300
  throw new LocalAuthoritativeInitiativeError('Authoritative initiative activity details differ from the captured mutation.');
300
301
  }
301
302
  }
@@ -437,6 +438,7 @@ export function applyAuthoritativeInitiativeAndAck(input) {
437
438
  payload: payload,
438
439
  authoritative: initiative,
439
440
  activity,
441
+ boundedAttachmentRebase,
440
442
  });
441
443
  const revision = validateInitiativeActivityAuthoritativeRevision({
442
444
  response: response,
@@ -279,7 +279,11 @@ export async function dispatchLocalOutboxBatch(input) {
279
279
  const details = errorDetails(result.data, `WORKSPACE_SYNC_TRANSPORT_${result.status || 'NETWORK'}`, 'Workspace sync delivery failed.');
280
280
  const stableErrorCode = result.status === 401 ? 'WORKSPACE_SYNC_TRANSPORT_401' : details.code;
281
281
  const contentPushRequired = result.status === 425
282
- && stableErrorCode === 'TASK_ATTACHMENT_ASSET_NOT_READY';
282
+ && [
283
+ 'TASK_ATTACHMENT_ASSET_NOT_READY',
284
+ 'WORKSTREAM_ATTACHMENT_NOT_READY',
285
+ 'INITIATIVE_ATTACHMENT_NOT_READY',
286
+ ].includes(stableErrorCode);
283
287
  const inProgressDelay = operationRetryAt(result.data, now().getTime());
284
288
  const cloudV3Disabled = result.status === 404 && isCloudV3Disabled(record, result.data);
285
289
  // A first metadata edit may dispatch before legacy reconciliation has
@@ -17,8 +17,13 @@ export function buildTaskAttachmentLinksEnvelope(record) {
17
17
  }
18
18
  const source = payload;
19
19
  if (!Object.prototype.hasOwnProperty.call(source, 'links')
20
- || Object.keys(source).some((key) => key !== 'links' && key !== 'originActor')) {
21
- throw new Error('Local task attachment-link outbox payload must contain links and optional originActor.');
20
+ || Object.keys(source).some((key) => !['links', 'cardCoverAssetId', 'originActor'].includes(key))) {
21
+ throw new Error('Local task attachment-link outbox payload must contain links and optional card cover metadata.');
22
+ }
23
+ const hasCardCoverAssetId = Object.prototype.hasOwnProperty.call(source, 'cardCoverAssetId');
24
+ if (hasCardCoverAssetId && source.cardCoverAssetId !== null
25
+ && (typeof source.cardCoverAssetId !== 'string' || !source.cardCoverAssetId.trim())) {
26
+ throw new Error('Local task attachment-link card cover must be a non-empty string or null.');
22
27
  }
23
28
  const aggregate = validateWorkspaceSyncV3TaskAttachmentLinksPayload({ taskId: record.entityId, links: source.links });
24
29
  return {
@@ -31,6 +36,11 @@ export function buildTaskAttachmentLinksEnvelope(record) {
31
36
  baseEntityRevision: record.baseEntityRevision, baseLifecycleRevision: '0',
32
37
  payload: {
33
38
  links: aggregate.links,
39
+ ...(hasCardCoverAssetId ? {
40
+ cardCoverAssetId: source.cardCoverAssetId === null
41
+ ? null
42
+ : String(source.cardCoverAssetId).trim(),
43
+ } : {}),
34
44
  ...(source.originActor !== undefined
35
45
  ? { originActor: parseWorkspaceSyncV3OriginActor(source.originActor) }
36
46
  : {}),