@taskforcehq/taskforce 0.3.303 → 0.3.304

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 (165) hide show
  1. package/dist/Taskforce.module.css +102 -5
  2. package/dist/TaskforceCore.js +514 -212
  3. package/dist/TaskforceCore.test.js +1172 -133
  4. package/dist/annotatedAttachments/service.d.ts +1 -1
  5. package/dist/annotatedAttachments/types.d.ts +1 -1
  6. package/dist/components/features/AgentsModule.js +1 -1
  7. package/dist/components/features/AgentsModule.test.js +1 -1
  8. package/dist/components/features/TaskSettings.js +58 -38
  9. package/dist/components/features/TaskSettings.test.js +139 -40
  10. package/dist/components/task/TaskActionHeader.d.ts +2 -1
  11. package/dist/components/task/TaskActionHeader.js +3 -3
  12. package/dist/components/task/TaskActionHeader.test.js +8 -0
  13. package/dist/components/task/TaskCard.js +16 -1
  14. package/dist/components/task/TaskCard.test.js +31 -0
  15. package/dist/components/task/TaskForm.d.ts +2 -1
  16. package/dist/components/task/TaskForm.js +22 -4
  17. package/dist/components/task/TaskForm.test.js +46 -0
  18. package/dist/components/task/TaskKanban.d.ts +1 -1
  19. package/dist/components/task/TaskKanban.js +31 -160
  20. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  21. package/dist/components/task/TaskStatusActions.js +8 -3
  22. package/dist/components/task/TaskStatusActions.test.js +9 -0
  23. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  24. package/dist/components/task/taskKanbanDropRules.js +187 -0
  25. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  26. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  27. package/dist/components/ui/Modal.d.ts +1 -1
  28. package/dist/components/ui/Modal.js +1 -0
  29. package/dist/components/views/AppShellHeader.js +5 -1
  30. package/dist/components/views/AppShellHeader.test.js +4 -0
  31. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  32. package/dist/components/views/PlanComparisonPage.js +4 -2
  33. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  34. package/dist/components/views/PlansPage.d.ts +5 -1
  35. package/dist/components/views/PlansPage.js +39 -12
  36. package/dist/components/views/PlansPage.test.js +59 -2
  37. package/dist/components/views/StandaloneLayout.js +113 -38
  38. package/dist/components/views/WidgetView.js +11 -2
  39. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  40. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  41. package/dist/core/PlanEntitlementService.d.ts +3 -1
  42. package/dist/core/PlanEntitlementService.js +61 -11
  43. package/dist/core/SignupMonetizationSettings.test.js +37 -2
  44. package/dist/core/TaskActivityService.d.ts +4 -1
  45. package/dist/core/TaskActivityService.js +45 -0
  46. package/dist/core/TaskAuditTimeline.test.js +52 -1
  47. package/dist/core/Taskforce.d.ts +15 -11
  48. package/dist/core/Taskforce.js +43 -2
  49. package/dist/core/WorkspacePlanMode.test.js +65 -0
  50. package/dist/documentReviews/service.d.ts +1 -1
  51. package/dist/documentReviews/types.d.ts +1 -1
  52. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  53. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  54. package/dist/hooks/sync/auth.d.ts +37 -0
  55. package/dist/hooks/sync/auth.js +61 -0
  56. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  57. package/dist/hooks/sync/bootstrap.js +52 -0
  58. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  59. package/dist/hooks/sync/orchestratorShared.js +209 -0
  60. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  61. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  62. package/dist/hooks/sync/realtime.d.ts +25 -0
  63. package/dist/hooks/sync/realtime.js +60 -0
  64. package/dist/hooks/sync/recovery.d.ts +69 -0
  65. package/dist/hooks/sync/recovery.js +118 -0
  66. package/dist/hooks/sync/transfers.d.ts +122 -0
  67. package/dist/hooks/sync/transfers.js +431 -0
  68. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  69. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  70. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  71. package/dist/hooks/ui/useResourceExport.js +5 -2
  72. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  73. package/dist/hooks/useRealtimeSync.js +2 -1
  74. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  75. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  76. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  77. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  78. package/dist/hooks/useSyncOrchestrator.js +314 -873
  79. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +568 -3
  80. package/dist/hooks/useTaskMutations.js +45 -6
  81. package/dist/hooks/useTaskMutations.test.js +147 -0
  82. package/dist/hooks/useTaskforce.d.ts +8 -2
  83. package/dist/hooks/useTaskforce.js +285 -812
  84. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  85. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  86. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  87. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  88. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  89. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  90. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  91. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  92. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  93. package/dist/localization/locales/en-US.js +2 -2
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  95. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  96. package/dist/mcp/clientIntegrations.d.ts +12 -1
  97. package/dist/mcp/clientIntegrations.js +161 -6
  98. package/dist/mcp/clientIntegrations.test.js +76 -4
  99. package/dist/mcp/documentHelpers.d.ts +100 -0
  100. package/dist/mcp/documentHelpers.js +161 -0
  101. package/dist/mcp/runtime.js +174 -476
  102. package/dist/mcp/runtime.test.js +81 -8
  103. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  104. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  105. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  106. package/dist/mcp/taskAttachmentTypes.js +1 -0
  107. package/dist/plugins/vite.js +1 -1
  108. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  109. package/dist/runtime/appGateDefinitions.js +1 -1
  110. package/dist/server/index.d.ts +1 -0
  111. package/dist/server/index.js +46 -3
  112. package/dist/server/index.test.js +12 -1
  113. package/dist/server/routes/admin.js +13 -19
  114. package/dist/server/routes/auth.js +37 -2
  115. package/dist/server/routes/billing.js +18 -67
  116. package/dist/server/routes/billing.test.js +43 -0
  117. package/dist/server/routes/shared.d.ts +30 -0
  118. package/dist/server/routes/shared.js +26 -0
  119. package/dist/server/routes/sync.integration.test.js +20 -0
  120. package/dist/server/routes/sync.js +110 -237
  121. package/dist/server/routes/tasks.js +49 -7
  122. package/dist/server/routes.js +8 -19
  123. package/dist/server/routes.test.js +243 -5
  124. package/dist/shared/taskActor.d.ts +10 -0
  125. package/dist/shared/taskActor.js +1 -0
  126. package/dist/sync/collaborationSyncPayload.js +4 -0
  127. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  128. package/dist/sync/collaborationSyncState.d.ts +3 -1
  129. package/dist/sync/collaborationSyncState.js +19 -0
  130. package/dist/sync/contentSyncState.js +12 -10
  131. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  132. package/dist/sync/syncApplyHandlers.js +370 -8
  133. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  134. package/dist/sync/syncApplyHandlers.test.js +377 -0
  135. package/dist/sync/syncService.d.ts +8 -1
  136. package/dist/sync/syncService.js +36 -2
  137. package/dist/sync/syncService.test.js +48 -0
  138. package/dist/sync/workspacePullFeed.d.ts +1 -0
  139. package/dist/sync/workspacePullFeed.js +66 -59
  140. package/dist/sync/workspacePullFeed.test.js +72 -4
  141. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  142. package/dist/sync/workspaceSyncModel.js +65 -0
  143. package/dist/sync/workspaceSyncModel.test.js +119 -0
  144. package/dist/types.d.ts +2 -10
  145. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  146. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-C_TmXZwA.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  147. package/dist/ui/assets/{DocumentWorkspace-C3GyzrWm.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  148. package/dist/ui/assets/{InitiativesModule-4-Rh2K2n.js → InitiativesModule-BjR6iBf9.js} +1 -1
  149. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  150. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  151. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  152. package/dist/ui/assets/{WorkflowsModule-CGk9s3NJ.js → WorkflowsModule-DnFqdUME.js} +1 -1
  153. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  154. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  155. package/dist/ui/assets/{vendor-icons-pWocEipT.js → vendor-icons-I7ZwG1z_.js} +1 -1
  156. package/dist/ui/index.html +4 -4
  157. package/dist/utils/taskActivity.js +4 -0
  158. package/dist/utils/taskActivity.test.js +25 -1
  159. package/package.json +3 -1
  160. package/scripts/playwright-auth.sh +4 -0
  161. package/dist/ui/assets/AgentsModule-CpsTmrIW.js +0 -1
  162. package/dist/ui/assets/PlansPage-BP7AYOqr.js +0 -1
  163. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +0 -8
  164. package/dist/ui/assets/index-CLIMgR6g.js +0 -6
  165. package/dist/ui/assets/index-DneETxcu.css +0 -1
@@ -99,7 +99,52 @@ function buildInput(overrides) {
99
99
  describe('useSyncOrchestrator', () => {
100
100
  beforeEach(() => {
101
101
  localStorage.clear();
102
+ ensureCloudWorkspaceReadyForSyncServiceMock.mockReset();
103
+ runWorkspacePullSyncServiceMock.mockReset();
104
+ runWorkspacePushSyncServiceMock.mockReset();
105
+ syncUserGlobalSettingsServiceMock.mockReset();
102
106
  ensureCloudWorkspaceReadyForSyncServiceMock.mockResolvedValue({ success: true });
107
+ runWorkspacePullSyncServiceMock.mockResolvedValue({
108
+ success: true,
109
+ syncedAt: '2026-03-15T18:00:00.000Z',
110
+ cursor: null,
111
+ pages: 0,
112
+ appliedChanges: 0,
113
+ pullRequestMs: 1,
114
+ applyMs: 0,
115
+ pulledDeleteTaskIds: new Set(),
116
+ pulledActiveUpserts: false,
117
+ pulledArchivedUpserts: false,
118
+ documentDecryptFailures: []
119
+ });
120
+ runWorkspacePushSyncServiceMock.mockResolvedValue({
121
+ success: true,
122
+ syncedAt: '2026-03-15T18:00:00.000Z',
123
+ requestMs: 1,
124
+ changeCount: 0,
125
+ deleteCount: 0,
126
+ currentTaskIds: new Set(),
127
+ currentInitiativeIds: new Set(),
128
+ currentInitiativeWatermarks: new Map(),
129
+ currentWorkstreamIds: new Set(),
130
+ currentWorkstreamWatermarks: new Map(),
131
+ currentAiProfileIds: new Set(),
132
+ currentAiProfileWatermarks: new Map(),
133
+ currentDocumentPaths: new Set(),
134
+ currentDocumentWatermarks: new Map(),
135
+ currentAssetPaths: new Set(),
136
+ currentAssetWatermarks: new Map(),
137
+ currentDocumentReviewSessionIds: new Set(),
138
+ currentDocumentReviewSessionWatermarks: new Map(),
139
+ currentDocumentReviewCommentIds: new Set(),
140
+ currentDocumentReviewCommentWatermarks: new Map(),
141
+ currentAnnotatedAttachmentSessionIds: new Set(),
142
+ currentAnnotatedAttachmentSessionWatermarks: new Map(),
143
+ currentTaskEventWatermarks: new Map(),
144
+ deleteTaskIds: new Set(),
145
+ pushedWatermarks: new Map(),
146
+ emittedEventIds: []
147
+ });
103
148
  syncUserGlobalSettingsServiceMock.mockResolvedValue({
104
149
  success: true,
105
150
  mode: 'pulled',
@@ -298,6 +343,226 @@ describe('useSyncOrchestrator', () => {
298
343
  expect(result.current.workspaceLastPullAt).toBe('2026-03-15T18:05:00.000Z');
299
344
  unmount();
300
345
  });
346
+ it('refreshes local task collections after event-only task activity pulls', async () => {
347
+ const fetchSpy = vi.fn((url, init) => {
348
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
349
+ return Promise.resolve({
350
+ ok: true,
351
+ json: () => Promise.resolve({
352
+ success: true,
353
+ state: {
354
+ version: 2,
355
+ enabled: true,
356
+ phase: 'active',
357
+ pullCursor: null,
358
+ lastPullAt: null,
359
+ lastPushAt: null,
360
+ lastSyncedAt: null
361
+ }
362
+ })
363
+ });
364
+ }
365
+ if (url.includes('/api/taskforce/ui-state') && String(init?.method || '').toUpperCase() === 'POST') {
366
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
367
+ }
368
+ if (url.includes('/api/taskforce/sync/workspace/documents')) {
369
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ documents: [] }) });
370
+ }
371
+ if (url.includes('/api/taskforce/sync/workspace/annotated-attachments')) {
372
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ sessions: [] }) });
373
+ }
374
+ if (url.includes('/api/taskforce/sync/events')) {
375
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
376
+ }
377
+ if (url.includes('/api/taskforce/tasks')) {
378
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ tasks: [] }) });
379
+ }
380
+ if (url.includes('/api/taskforce/archive')) {
381
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ archived: [] }) });
382
+ }
383
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
384
+ });
385
+ vi.stubGlobal('fetch', fetchSpy);
386
+ runWorkspacePullSyncServiceMock.mockResolvedValueOnce({
387
+ success: true,
388
+ syncedAt: '2026-03-15T18:06:00.000Z',
389
+ cursor: null,
390
+ pages: 1,
391
+ appliedChanges: 1,
392
+ pullRequestMs: 1,
393
+ applyMs: 0,
394
+ pulledDeleteTaskIds: new Set(),
395
+ pulledActiveUpserts: false,
396
+ pulledArchivedUpserts: false,
397
+ pulledTaskEventUpserts: true,
398
+ documentDecryptFailures: []
399
+ });
400
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
401
+ initialProps: buildInput()
402
+ });
403
+ act(() => {
404
+ result.current.applyWorkspaceSyncStateSnapshot({
405
+ enabled: true,
406
+ phase: 'active',
407
+ pullCursor: null
408
+ });
409
+ });
410
+ await waitFor(() => {
411
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
412
+ });
413
+ await waitFor(() => {
414
+ expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/api/taskforce/tasks'))).toBe(true);
415
+ expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/api/taskforce/archive'))).toBe(true);
416
+ });
417
+ unmount();
418
+ });
419
+ it('marks sync auth as blocked on explicit 401 pull failures', async () => {
420
+ const setAuthBlocked = vi.fn();
421
+ const setIsAuthenticated = vi.fn();
422
+ const checkAuthSession = vi.fn(async () => false);
423
+ runWorkspacePullSyncServiceMock.mockResolvedValueOnce({
424
+ success: false,
425
+ kind: 'pull_http',
426
+ status: 401,
427
+ transient: false,
428
+ retryAfterMs: null,
429
+ cursor: null,
430
+ pages: 0,
431
+ appliedChanges: 0,
432
+ pullRequestMs: 1,
433
+ applyMs: 0,
434
+ pulledDeleteTaskIds: new Set(),
435
+ pulledActiveUpserts: false,
436
+ pulledArchivedUpserts: false,
437
+ documentDecryptFailures: []
438
+ });
439
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
440
+ initialProps: buildInput({
441
+ setAuthBlocked,
442
+ setIsAuthenticated,
443
+ checkAuthSession
444
+ })
445
+ });
446
+ act(() => {
447
+ result.current.applyWorkspaceSyncStateSnapshot({
448
+ enabled: true,
449
+ phase: 'active',
450
+ pullCursor: null
451
+ });
452
+ });
453
+ await waitFor(() => {
454
+ expect(setAuthBlocked).toHaveBeenCalledWith(true);
455
+ expect(setIsAuthenticated).toHaveBeenCalledWith(false);
456
+ expect(checkAuthSession).toHaveBeenCalled();
457
+ expect(result.current.workspaceLastErrorMessage).toBe('Session expired. Sign in again to resume cloud sync.');
458
+ });
459
+ unmount();
460
+ });
461
+ it('suppresses self-emitted realtime event ids but still pulls on later replay signals', async () => {
462
+ vi.useFakeTimers();
463
+ vi.stubGlobal('WebSocket', MockWebSocket);
464
+ runWorkspacePushSyncServiceMock.mockResolvedValue({
465
+ success: true,
466
+ syncedAt: '2026-03-15T15:00:00.000Z',
467
+ requestMs: 1,
468
+ changeCount: 1,
469
+ deleteCount: 0,
470
+ currentTaskIds: new Set(['task-1']),
471
+ currentInitiativeIds: new Set(),
472
+ currentInitiativeWatermarks: new Map(),
473
+ currentWorkstreamIds: new Set(),
474
+ currentWorkstreamWatermarks: new Map(),
475
+ currentAiProfileIds: new Set(),
476
+ currentAiProfileWatermarks: new Map(),
477
+ currentDocumentPaths: new Set(),
478
+ currentDocumentWatermarks: new Map(),
479
+ currentAssetPaths: new Set(),
480
+ currentAssetWatermarks: new Map(),
481
+ currentDocumentReviewSessionIds: new Set(),
482
+ currentDocumentReviewSessionWatermarks: new Map(),
483
+ currentDocumentReviewCommentIds: new Set(),
484
+ currentDocumentReviewCommentWatermarks: new Map(),
485
+ currentAnnotatedAttachmentSessionIds: new Set(),
486
+ currentAnnotatedAttachmentSessionWatermarks: new Map(),
487
+ currentTaskEventWatermarks: new Map(),
488
+ deleteTaskIds: new Set(),
489
+ pushedWatermarks: new Map([['task-1', '2026-03-14T12:00:00.000Z']]),
490
+ emittedEventIds: ['evt-self-1']
491
+ });
492
+ runWorkspacePullSyncServiceMock.mockResolvedValue({
493
+ success: true,
494
+ syncedAt: '2026-03-15T18:05:00.000Z',
495
+ cursor: null,
496
+ pages: 1,
497
+ appliedChanges: 0,
498
+ pullRequestMs: 1,
499
+ applyMs: 0,
500
+ pulledDeleteTaskIds: new Set(),
501
+ pulledActiveUpserts: false,
502
+ pulledArchivedUpserts: false,
503
+ documentDecryptFailures: []
504
+ });
505
+ const task = {
506
+ id: 'task-1',
507
+ status: 'task',
508
+ title: 'Realtime suppression',
509
+ createdAt: '2026-03-14T12:00:00.000Z',
510
+ updatedAt: '2026-03-14T12:00:00.000Z'
511
+ };
512
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
513
+ initialProps: buildInput({
514
+ realtimeSyncEnabled: true,
515
+ tasks: [task]
516
+ })
517
+ });
518
+ act(() => {
519
+ result.current.applyWorkspaceSyncStateSnapshot({
520
+ enabled: true,
521
+ phase: 'active',
522
+ pullCursor: null
523
+ });
524
+ });
525
+ await act(async () => {
526
+ await Promise.resolve();
527
+ await Promise.resolve();
528
+ await vi.advanceTimersByTimeAsync(400);
529
+ });
530
+ expect(MockWebSocket.instances).toHaveLength(1);
531
+ const stabilizedPullCalls = runWorkspacePullSyncServiceMock.mock.calls.length;
532
+ await act(async () => {
533
+ await result.current.pushWorkspaceChangesToCloud('sig-self-event');
534
+ await vi.advanceTimersByTimeAsync(200);
535
+ });
536
+ const pullCallsAfterPush = runWorkspacePullSyncServiceMock.mock.calls.length;
537
+ expect(pullCallsAfterPush).toBeGreaterThanOrEqual(stabilizedPullCalls);
538
+ act(() => {
539
+ MockWebSocket.instances[0]?.open();
540
+ MockWebSocket.instances[0]?.message({
541
+ type: 'taskforce:update',
542
+ workspaceId: 'workspace-local-active',
543
+ eventId: 'evt-self-1'
544
+ });
545
+ });
546
+ await act(async () => {
547
+ await vi.advanceTimersByTimeAsync(300);
548
+ });
549
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(pullCallsAfterPush);
550
+ act(() => {
551
+ MockWebSocket.instances[0]?.message({
552
+ type: 'taskforce:update',
553
+ workspaceId: 'workspace-local-active',
554
+ eventId: 'evt-replay-2'
555
+ });
556
+ });
557
+ await act(async () => {
558
+ await vi.advanceTimersByTimeAsync(300);
559
+ });
560
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(pullCallsAfterPush + 1);
561
+ await act(async () => {
562
+ unmount();
563
+ await vi.runOnlyPendingTimersAsync();
564
+ });
565
+ });
301
566
  it('retries transient push failures automatically', async () => {
302
567
  runWorkspacePushSyncServiceMock
303
568
  .mockResolvedValueOnce({
@@ -391,7 +656,20 @@ describe('useSyncOrchestrator', () => {
391
656
  })
392
657
  .mockImplementationOnce(() => new Promise((resolve) => {
393
658
  resolveRetryPull = resolve;
394
- }));
659
+ }))
660
+ .mockResolvedValue({
661
+ success: true,
662
+ syncedAt: '2026-03-15T19:30:00.000Z',
663
+ cursor: null,
664
+ pages: 1,
665
+ appliedChanges: 0,
666
+ pullRequestMs: 1,
667
+ applyMs: 0,
668
+ pulledDeleteTaskIds: new Set(),
669
+ pulledActiveUpserts: false,
670
+ pulledArchivedUpserts: false,
671
+ documentDecryptFailures: []
672
+ });
395
673
  const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
396
674
  initialProps: buildInput()
397
675
  });
@@ -435,6 +713,218 @@ describe('useSyncOrchestrator', () => {
435
713
  });
436
714
  unmount();
437
715
  });
716
+ it('schedules a push when local task state changes without waiting for snapshot fingerprint changes', async () => {
717
+ vi.useFakeTimers();
718
+ runWorkspacePullSyncServiceMock.mockResolvedValue({
719
+ success: true,
720
+ syncedAt: '2026-03-15T19:30:00.000Z',
721
+ cursor: null,
722
+ pages: 1,
723
+ appliedChanges: 0,
724
+ pullRequestMs: 1,
725
+ applyMs: 0,
726
+ pulledDeleteTaskIds: new Set(),
727
+ pulledActiveUpserts: false,
728
+ pulledArchivedUpserts: false,
729
+ documentDecryptFailures: []
730
+ });
731
+ runWorkspacePushSyncServiceMock.mockResolvedValue({
732
+ success: true,
733
+ syncedAt: '2026-03-15T15:00:00.000Z',
734
+ requestMs: 1,
735
+ changeCount: 1,
736
+ deleteCount: 0,
737
+ currentTaskIds: new Set(['task-local-1']),
738
+ deleteTaskIds: new Set(),
739
+ currentDocumentPaths: new Set(),
740
+ deleteDocumentPaths: new Set(),
741
+ currentDocumentWatermarks: new Map(),
742
+ currentAssetPaths: new Set(),
743
+ deleteAssetPaths: new Set(),
744
+ currentAssetWatermarks: new Map(),
745
+ currentDocumentReviewSessionIds: new Set(),
746
+ deleteDocumentReviewSessionIds: new Set(),
747
+ currentDocumentReviewSessionWatermarks: new Map(),
748
+ currentAnnotatedAttachmentSessionIds: new Set(),
749
+ deleteAnnotatedAttachmentSessionIds: new Set(),
750
+ currentAnnotatedAttachmentSessionWatermarks: new Map(),
751
+ pushedWatermarks: new Map([['task-local-1', '2026-03-14T12:00:00.000Z']]),
752
+ emittedEventIds: []
753
+ });
754
+ let input = buildInput();
755
+ const { result, rerender, unmount } = renderHook((props) => useSyncOrchestrator(props), {
756
+ initialProps: input
757
+ });
758
+ act(() => {
759
+ result.current.applyWorkspaceSyncStateSnapshot({
760
+ enabled: true,
761
+ phase: 'provision-local',
762
+ pullCursor: null
763
+ });
764
+ });
765
+ await act(async () => {
766
+ await vi.runOnlyPendingTimersAsync();
767
+ await Promise.resolve();
768
+ await Promise.resolve();
769
+ });
770
+ runWorkspacePushSyncServiceMock.mockClear();
771
+ input = buildInput({
772
+ tasks: [{
773
+ id: 'task-local-1',
774
+ title: 'Local pending task',
775
+ status: 'task',
776
+ createdAt: '2026-03-14T12:00:00.000Z',
777
+ updatedAt: '2026-03-14T12:00:00.000Z',
778
+ localReferenceNumber: 7,
779
+ referenceNumber: null
780
+ }]
781
+ });
782
+ rerender(input);
783
+ await act(async () => {
784
+ await vi.advanceTimersByTimeAsync(1600);
785
+ await Promise.resolve();
786
+ await Promise.resolve();
787
+ });
788
+ expect(runWorkspacePushSyncServiceMock.mock.calls.length).toBeGreaterThanOrEqual(1);
789
+ expect(runWorkspacePushSyncServiceMock.mock.calls[0]?.[0]?.payload?.changes).toEqual(expect.arrayContaining([
790
+ expect.objectContaining({
791
+ op: 'upsert',
792
+ archived: false,
793
+ task: expect.objectContaining({
794
+ id: 'task-local-1'
795
+ })
796
+ })
797
+ ]));
798
+ unmount();
799
+ });
800
+ it('schedules a push when only task activity events change', async () => {
801
+ vi.useFakeTimers();
802
+ const baseTask = {
803
+ id: 'task-local-activity-1',
804
+ title: 'Local task activity',
805
+ status: 'task',
806
+ createdAt: '2026-03-14T12:00:00.000Z',
807
+ updatedAt: '2026-03-14T12:00:00.000Z',
808
+ taskEvents: []
809
+ };
810
+ runWorkspacePushSyncServiceMock.mockResolvedValue({
811
+ success: true,
812
+ syncedAt: '2026-03-15T15:05:00.000Z',
813
+ requestMs: 1,
814
+ changeCount: 1,
815
+ deleteCount: 0,
816
+ currentTaskIds: new Set(['task-local-activity-1']),
817
+ deleteTaskIds: new Set(),
818
+ currentDocumentPaths: new Set(),
819
+ deleteDocumentPaths: new Set(),
820
+ currentDocumentWatermarks: new Map(),
821
+ currentAssetPaths: new Set(),
822
+ deleteAssetPaths: new Set(),
823
+ currentAssetWatermarks: new Map(),
824
+ currentDocumentReviewSessionIds: new Set(),
825
+ deleteDocumentReviewSessionIds: new Set(),
826
+ currentDocumentReviewSessionWatermarks: new Map(),
827
+ currentAnnotatedAttachmentSessionIds: new Set(),
828
+ deleteAnnotatedAttachmentSessionIds: new Set(),
829
+ currentAnnotatedAttachmentSessionWatermarks: new Map(),
830
+ currentTaskEventWatermarks: new Map(),
831
+ pushedWatermarks: new Map([['task-local-activity-1', '2026-03-14T12:00:00.000Z']]),
832
+ emittedEventIds: []
833
+ });
834
+ let input = buildInput({
835
+ tasks: [baseTask]
836
+ });
837
+ const { result, rerender, unmount } = renderHook((props) => useSyncOrchestrator(props), {
838
+ initialProps: input
839
+ });
840
+ act(() => {
841
+ result.current.applyWorkspaceSyncStateSnapshot({
842
+ enabled: true,
843
+ phase: 'provision-local',
844
+ pullCursor: null
845
+ });
846
+ });
847
+ await act(async () => {
848
+ await vi.runOnlyPendingTimersAsync();
849
+ await Promise.resolve();
850
+ await Promise.resolve();
851
+ });
852
+ runWorkspacePushSyncServiceMock.mockClear();
853
+ input = buildInput({
854
+ tasks: [{
855
+ ...baseTask,
856
+ taskEvents: [{
857
+ id: 'event-1',
858
+ taskId: 'task-local-activity-1',
859
+ workspaceId: 'workspace-local-active',
860
+ action: 'updated',
861
+ actor: 'user-1',
862
+ actorType: 'human',
863
+ createdAt: '2026-03-15T15:06:00.000Z',
864
+ details: {
865
+ changes: {
866
+ status: {
867
+ from: 'task',
868
+ to: 'task'
869
+ }
870
+ }
871
+ }
872
+ }]
873
+ }]
874
+ });
875
+ rerender(input);
876
+ await act(async () => {
877
+ await vi.advanceTimersByTimeAsync(1600);
878
+ await Promise.resolve();
879
+ await Promise.resolve();
880
+ });
881
+ expect(runWorkspacePushSyncServiceMock.mock.calls.length).toBeGreaterThanOrEqual(1);
882
+ expect(runWorkspacePushSyncServiceMock.mock.calls[0]?.[0]?.payload?.changes).toEqual(expect.arrayContaining([
883
+ expect.objectContaining({
884
+ op: 'task-event-upsert',
885
+ event: expect.objectContaining({
886
+ id: 'event-1',
887
+ taskId: 'task-local-activity-1'
888
+ })
889
+ })
890
+ ]));
891
+ unmount();
892
+ });
893
+ it('runs a startup full reconcile after the first successful pull when a saved cursor exists', async () => {
894
+ vi.useFakeTimers();
895
+ runWorkspacePullSyncServiceMock.mockResolvedValue({
896
+ success: true,
897
+ syncedAt: '2026-03-15T19:30:00.000Z',
898
+ cursor: 'server-cursor-1',
899
+ pages: 1,
900
+ appliedChanges: 0,
901
+ pullRequestMs: 1,
902
+ applyMs: 0,
903
+ pulledDeleteTaskIds: new Set(),
904
+ pulledActiveUpserts: false,
905
+ pulledArchivedUpserts: false,
906
+ documentDecryptFailures: []
907
+ });
908
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
909
+ initialProps: buildInput()
910
+ });
911
+ act(() => {
912
+ result.current.applyWorkspaceSyncStateSnapshot({
913
+ enabled: true,
914
+ phase: 'active',
915
+ pullCursor: 'stale-cursor'
916
+ });
917
+ });
918
+ await act(async () => {
919
+ await vi.advanceTimersByTimeAsync(300);
920
+ await Promise.resolve();
921
+ await Promise.resolve();
922
+ });
923
+ expect(runWorkspacePullSyncServiceMock.mock.calls.length).toBeGreaterThanOrEqual(2);
924
+ expect(runWorkspacePullSyncServiceMock.mock.calls.some((call) => call?.[0]?.cursor === 'stale-cursor')).toBe(true);
925
+ expect(runWorkspacePullSyncServiceMock.mock.calls.some((call) => call?.[0]?.cursor === null)).toBe(true);
926
+ unmount();
927
+ });
438
928
  it('finishes provision-local bootstrap by moving to active after the first push', async () => {
439
929
  runWorkspacePushSyncServiceMock.mockResolvedValue({
440
930
  success: true,
@@ -506,6 +996,80 @@ describe('useSyncOrchestrator', () => {
506
996
  });
507
997
  unmount();
508
998
  });
999
+ it('finishes attach-cloud bootstrap locally even if persisting the active phase fails', async () => {
1000
+ vi.stubGlobal('fetch', vi.fn((url, init) => {
1001
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
1002
+ return Promise.resolve({
1003
+ ok: true,
1004
+ json: () => Promise.resolve({
1005
+ success: true,
1006
+ state: {
1007
+ version: 2,
1008
+ enabled: true,
1009
+ phase: 'attach-cloud',
1010
+ pullCursor: null,
1011
+ lastPullAt: null,
1012
+ lastPushAt: null,
1013
+ lastSyncedAt: null
1014
+ }
1015
+ })
1016
+ });
1017
+ }
1018
+ if (url.includes('/api/taskforce/ui-state') && String(init?.method || '').toUpperCase() === 'POST') {
1019
+ return Promise.resolve({
1020
+ ok: false,
1021
+ status: 502,
1022
+ json: () => Promise.resolve({ success: false, error: 'Bad gateway' })
1023
+ });
1024
+ }
1025
+ if (url.includes('/api/taskforce/sync/workspace/documents')) {
1026
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ documents: [] }) });
1027
+ }
1028
+ if (url.includes('/api/taskforce/sync/workspace/annotated-attachments')) {
1029
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ sessions: [] }) });
1030
+ }
1031
+ if (url.includes('/api/taskforce/sync/events')) {
1032
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
1033
+ }
1034
+ if (url.includes('/api/taskforce/tasks')) {
1035
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ tasks: [] }) });
1036
+ }
1037
+ if (url.includes('/api/taskforce/archive')) {
1038
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ archived: [] }) });
1039
+ }
1040
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
1041
+ }));
1042
+ runWorkspacePullSyncServiceMock.mockResolvedValue({
1043
+ success: true,
1044
+ syncedAt: '2026-03-15T19:05:00.000Z',
1045
+ cursor: null,
1046
+ pages: 1,
1047
+ appliedChanges: 0,
1048
+ pullRequestMs: 1,
1049
+ applyMs: 0,
1050
+ pulledDeleteTaskIds: new Set(),
1051
+ pulledActiveUpserts: false,
1052
+ pulledArchivedUpserts: false,
1053
+ documentDecryptFailures: []
1054
+ });
1055
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
1056
+ initialProps: buildInput()
1057
+ });
1058
+ act(() => {
1059
+ result.current.applyWorkspaceSyncStateSnapshot({
1060
+ enabled: true,
1061
+ phase: 'attach-cloud',
1062
+ pullCursor: null
1063
+ });
1064
+ });
1065
+ await waitFor(() => {
1066
+ expect(result.current.workspaceSyncPhase).toBe('active');
1067
+ expect(result.current.workspaceLastPullAt).toBe('2026-03-15T19:05:00.000Z');
1068
+ });
1069
+ expect(result.current.workspaceLastErrorMessage).toBeNull();
1070
+ expect(result.current.workspaceSyncStatus).toBe('healthy');
1071
+ unmount();
1072
+ });
509
1073
  it('does not auto-push local state immediately after attach-cloud bootstrap completes', async () => {
510
1074
  vi.useFakeTimers();
511
1075
  const task = {
@@ -822,7 +1386,8 @@ describe('useSyncOrchestrator', () => {
822
1386
  await Promise.resolve();
823
1387
  await Promise.resolve();
824
1388
  });
825
- expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
1389
+ const pullCallsDuringRepair = runWorkspacePullSyncServiceMock.mock.calls.length;
1390
+ expect(pullCallsDuringRepair).toBeGreaterThanOrEqual(1);
826
1391
  await act(async () => {
827
1392
  const pushed = await result.current.pushWorkspaceChangesToCloud('sig-during-repair');
828
1393
  const pulled = await result.current.pullWorkspaceChangesFromCloud();
@@ -830,7 +1395,7 @@ describe('useSyncOrchestrator', () => {
830
1395
  expect(pulled).toBe(false);
831
1396
  });
832
1397
  expect(runWorkspacePushSyncServiceMock).not.toHaveBeenCalled();
833
- expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
1398
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(pullCallsDuringRepair);
834
1399
  await act(async () => {
835
1400
  resolveRepairPull?.({
836
1401
  success: true,