@taskforcehq/taskforce 0.3.302 → 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 (192) hide show
  1. package/dist/Taskforce.module.css +113 -5
  2. package/dist/TaskforceCore.js +560 -208
  3. package/dist/TaskforceCore.test.js +1833 -91
  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/TaskKanban.test.js +17 -0
  21. package/dist/components/task/TaskStatusActions.d.ts +6 -1
  22. package/dist/components/task/TaskStatusActions.js +8 -3
  23. package/dist/components/task/TaskStatusActions.test.js +9 -0
  24. package/dist/components/task/taskKanbanDropRules.d.ts +58 -0
  25. package/dist/components/task/taskKanbanDropRules.js +187 -0
  26. package/dist/components/task/taskKanbanDropRules.test.d.ts +1 -0
  27. package/dist/components/task/taskKanbanDropRules.test.js +114 -0
  28. package/dist/components/ui/Modal.d.ts +1 -1
  29. package/dist/components/ui/Modal.js +1 -0
  30. package/dist/components/views/AppShellHeader.js +5 -1
  31. package/dist/components/views/AppShellHeader.test.js +4 -0
  32. package/dist/components/views/PlanComparisonPage.d.ts +3 -2
  33. package/dist/components/views/PlanComparisonPage.js +4 -2
  34. package/dist/components/views/PlanComparisonPage.test.js +4 -0
  35. package/dist/components/views/PlansPage.d.ts +7 -1
  36. package/dist/components/views/PlansPage.js +139 -53
  37. package/dist/components/views/PlansPage.test.d.ts +1 -0
  38. package/dist/components/views/PlansPage.test.js +157 -0
  39. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  40. package/dist/components/views/StandaloneLayout.js +216 -186
  41. package/dist/components/views/WidgetView.js +11 -2
  42. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +8 -3
  43. package/dist/components/views/standalone/modals/AccountHubModal.js +16 -2
  44. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  45. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  46. package/dist/components/views/standalone/modals/SyncStatusModal.js +1 -1
  47. package/dist/core/DeletedTaskLifecycleService.d.ts +2 -1
  48. package/dist/core/EntitlementsPolicy.test.js +2 -2
  49. package/dist/core/GlobalSettingsService.js +78 -18
  50. package/dist/core/PlanEntitlementService.d.ts +31 -5
  51. package/dist/core/PlanEntitlementService.js +350 -222
  52. package/dist/core/SignupMonetizationSettings.test.js +208 -49
  53. package/dist/core/SystemAdmin.test.js +186 -96
  54. package/dist/core/TaskActivityService.d.ts +4 -1
  55. package/dist/core/TaskActivityService.js +45 -0
  56. package/dist/core/TaskAuditTimeline.test.js +52 -1
  57. package/dist/core/Taskforce.d.ts +65 -20
  58. package/dist/core/Taskforce.js +261 -51
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.js +12 -3
  61. package/dist/core/WorkspacePlanMode.test.js +65 -0
  62. package/dist/core/types.d.ts +11 -12
  63. package/dist/documentReviews/service.d.ts +1 -1
  64. package/dist/documentReviews/types.d.ts +1 -1
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.d.ts +48 -0
  66. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +219 -0
  67. package/dist/hooks/sync/auth.d.ts +37 -0
  68. package/dist/hooks/sync/auth.js +61 -0
  69. package/dist/hooks/sync/bootstrap.d.ts +34 -0
  70. package/dist/hooks/sync/bootstrap.js +52 -0
  71. package/dist/hooks/sync/orchestratorShared.d.ts +36 -0
  72. package/dist/hooks/sync/orchestratorShared.js +209 -0
  73. package/dist/hooks/sync/orchestratorShared.test.d.ts +1 -0
  74. package/dist/hooks/sync/orchestratorShared.test.js +49 -0
  75. package/dist/hooks/sync/realtime.d.ts +25 -0
  76. package/dist/hooks/sync/realtime.js +60 -0
  77. package/dist/hooks/sync/recovery.d.ts +69 -0
  78. package/dist/hooks/sync/recovery.js +118 -0
  79. package/dist/hooks/sync/transfers.d.ts +122 -0
  80. package/dist/hooks/sync/transfers.js +431 -0
  81. package/dist/hooks/sync/useDataVersionRefresh.d.ts +16 -0
  82. package/dist/hooks/sync/useDataVersionRefresh.js +73 -0
  83. package/dist/hooks/ui/useResourceExport.d.ts +3 -1
  84. package/dist/hooks/ui/useResourceExport.js +5 -2
  85. package/dist/hooks/ui/useWorkflowTemplates.js +11 -6
  86. package/dist/hooks/useRealtimeSync.js +2 -1
  87. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +8 -0
  88. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +1 -0
  89. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +228 -0
  90. package/dist/hooks/useSyncOrchestrator.d.ts +5 -4
  91. package/dist/hooks/useSyncOrchestrator.js +315 -860
  92. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +665 -8
  93. package/dist/hooks/useTaskMutations.js +45 -6
  94. package/dist/hooks/useTaskMutations.test.js +147 -0
  95. package/dist/hooks/useTaskforce.d.ts +11 -2
  96. package/dist/hooks/useTaskforce.js +290 -812
  97. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  98. package/dist/hooks/useWorkspaceSyncController.js +22 -10
  99. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +17 -0
  100. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +166 -0
  101. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +434 -0
  102. package/dist/hooks/workspace/workspaceLocalState.d.ts +19 -0
  103. package/dist/hooks/workspace/workspaceLocalState.js +100 -0
  104. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +1 -0
  105. package/dist/hooks/workspace/workspaceLocalState.test.js +59 -0
  106. package/dist/localization/locales/en-US.js +2 -2
  107. package/dist/mcp/canonicalAssetHelpers.d.ts +70 -0
  108. package/dist/mcp/canonicalAssetHelpers.js +259 -0
  109. package/dist/mcp/clientIntegrations.d.ts +12 -1
  110. package/dist/mcp/clientIntegrations.js +161 -6
  111. package/dist/mcp/clientIntegrations.test.js +76 -4
  112. package/dist/mcp/documentHelpers.d.ts +100 -0
  113. package/dist/mcp/documentHelpers.js +161 -0
  114. package/dist/mcp/runtime.js +264 -505
  115. package/dist/mcp/runtime.test.js +353 -89
  116. package/dist/mcp/taskAttachmentHelpers.d.ts +41 -0
  117. package/dist/mcp/taskAttachmentHelpers.js +60 -0
  118. package/dist/mcp/taskAttachmentTypes.d.ts +37 -0
  119. package/dist/mcp/taskAttachmentTypes.js +1 -0
  120. package/dist/migrations/billingSchemaParity.test.js +3 -0
  121. package/dist/migrations/taskSchemaMigrations.js +15 -0
  122. package/dist/plugins/vite.js +1 -1
  123. package/dist/runtime/appGateDefinitions.d.ts +1 -1
  124. package/dist/runtime/appGateDefinitions.js +1 -1
  125. package/dist/server/index.cookieProxy.test.js +1 -0
  126. package/dist/server/index.d.ts +1 -0
  127. package/dist/server/index.js +48 -3
  128. package/dist/server/index.test.js +14 -2
  129. package/dist/server/mockStripe.d.ts +1 -0
  130. package/dist/server/mockStripe.js +2 -0
  131. package/dist/server/routes/admin.js +91 -40
  132. package/dist/server/routes/auth.d.ts +1 -0
  133. package/dist/server/routes/auth.js +231 -55
  134. package/dist/server/routes/billing.js +473 -39
  135. package/dist/server/routes/billing.test.js +972 -37
  136. package/dist/server/routes/shared.d.ts +30 -0
  137. package/dist/server/routes/shared.js +31 -3
  138. package/dist/server/routes/sync.integration.test.js +180 -0
  139. package/dist/server/routes/sync.js +125 -242
  140. package/dist/server/routes/tasks.js +49 -7
  141. package/dist/server/routes.js +11 -22
  142. package/dist/server/routes.test.js +530 -81
  143. package/dist/shared/taskActor.d.ts +10 -0
  144. package/dist/shared/taskActor.js +1 -0
  145. package/dist/sync/cloudSyncApi.d.ts +1 -0
  146. package/dist/sync/collaborationSyncPayload.js +4 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +8 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -1
  149. package/dist/sync/collaborationSyncState.js +19 -0
  150. package/dist/sync/contentSyncState.d.ts +10 -0
  151. package/dist/sync/contentSyncState.js +277 -14
  152. package/dist/sync/syncApplyHandlers.d.ts +87 -1
  153. package/dist/sync/syncApplyHandlers.js +370 -8
  154. package/dist/sync/syncApplyHandlers.test.d.ts +1 -0
  155. package/dist/sync/syncApplyHandlers.test.js +377 -0
  156. package/dist/sync/syncService.d.ts +9 -1
  157. package/dist/sync/syncService.js +57 -10
  158. package/dist/sync/syncService.test.js +48 -0
  159. package/dist/sync/workspacePullFeed.d.ts +11 -0
  160. package/dist/sync/workspacePullFeed.js +123 -68
  161. package/dist/sync/workspacePullFeed.test.js +180 -0
  162. package/dist/sync/workspaceSyncModel.d.ts +22 -1
  163. package/dist/sync/workspaceSyncModel.js +65 -0
  164. package/dist/sync/workspaceSyncModel.test.js +119 -0
  165. package/dist/types.d.ts +2 -10
  166. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +1 -0
  167. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-Dvi02Y7q.js → AnnotatedAttachmentWorkspace-BlS-cqnl.js} +1 -1
  168. package/dist/ui/assets/{DocumentWorkspace-iys3zfca.js → DocumentWorkspace-BH_6DF6x.js} +1 -1
  169. package/dist/ui/assets/{InitiativesModule-DJvLJI3A.js → InitiativesModule-BjR6iBf9.js} +1 -1
  170. package/dist/ui/assets/{PlansPage-BlVC_lRq.css → PlansPage-C2dd2n1X.css} +1 -1
  171. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +1 -0
  172. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +9 -0
  173. package/dist/ui/assets/{WorkflowsModule-BguHeQaV.js → WorkflowsModule-DnFqdUME.js} +1 -1
  174. package/dist/ui/assets/index-DXUdtH1B.js +6 -0
  175. package/dist/ui/assets/index-Ii0B0rTO.css +1 -0
  176. package/dist/ui/assets/{vendor-icons-C8EED3Oh.js → vendor-icons-I7ZwG1z_.js} +1 -1
  177. package/dist/ui/index.html +4 -4
  178. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  179. package/dist/utils/accountProfileSummaryCache.js +97 -0
  180. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  181. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  182. package/dist/utils/taskActivity.js +4 -0
  183. package/dist/utils/taskActivity.test.js +25 -1
  184. package/package.json +5 -3
  185. package/scripts/playwright-auth.sh +4 -0
  186. package/dist/ui/assets/AgentsModule-JG5jc3he.js +0 -1
  187. package/dist/ui/assets/PlansPage-BMPlpFpw.js +0 -1
  188. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +0 -8
  189. package/dist/ui/assets/index-MXeWoebC.css +0 -1
  190. package/dist/ui/assets/index-YJ4k5QNa.js +0 -6
  191. package/dist/utils/billingStatusCache.d.ts +0 -21
  192. package/dist/utils/billingStatusCache.js +0 -70
@@ -3,19 +3,44 @@ import { act, renderHook, waitFor } from '@testing-library/react';
3
3
  import { useSyncOrchestrator } from './useSyncOrchestrator';
4
4
  class MockWebSocket {
5
5
  static instances = [];
6
+ static OPEN = 1;
7
+ static CLOSED = 3;
6
8
  url;
9
+ readyState = 0;
10
+ sent = [];
11
+ listeners = new Map();
7
12
  constructor(url) {
8
13
  this.url = url;
9
14
  MockWebSocket.instances.push(this);
10
15
  }
11
16
  close() {
12
- // noop for orchestration gating tests
17
+ this.readyState = MockWebSocket.CLOSED;
18
+ this.emit('close', {});
13
19
  }
14
- addEventListener() {
15
- // noop for orchestration gating tests
20
+ addEventListener(type, listener) {
21
+ const bucket = this.listeners.get(type) || new Set();
22
+ bucket.add(listener);
23
+ this.listeners.set(type, bucket);
16
24
  }
17
- send() {
18
- // noop for orchestration gating tests
25
+ send(payload) {
26
+ if (typeof payload === 'string') {
27
+ this.sent.push(payload);
28
+ }
29
+ }
30
+ emit(type, event) {
31
+ const bucket = this.listeners.get(type);
32
+ if (!bucket)
33
+ return;
34
+ for (const listener of bucket) {
35
+ listener(event);
36
+ }
37
+ }
38
+ open() {
39
+ this.readyState = MockWebSocket.OPEN;
40
+ this.emit('open', {});
41
+ }
42
+ message(payload) {
43
+ this.emit('message', { data: JSON.stringify(payload) });
19
44
  }
20
45
  }
21
46
  const { ensureCloudWorkspaceReadyForSyncServiceMock, runWorkspacePullSyncServiceMock, runWorkspacePushSyncServiceMock, syncUserGlobalSettingsServiceMock } = vi.hoisted(() => ({
@@ -74,7 +99,52 @@ function buildInput(overrides) {
74
99
  describe('useSyncOrchestrator', () => {
75
100
  beforeEach(() => {
76
101
  localStorage.clear();
102
+ ensureCloudWorkspaceReadyForSyncServiceMock.mockReset();
103
+ runWorkspacePullSyncServiceMock.mockReset();
104
+ runWorkspacePushSyncServiceMock.mockReset();
105
+ syncUserGlobalSettingsServiceMock.mockReset();
77
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
+ });
78
148
  syncUserGlobalSettingsServiceMock.mockResolvedValue({
79
149
  success: true,
80
150
  mode: 'pulled',
@@ -206,6 +276,293 @@ describe('useSyncOrchestrator', () => {
206
276
  expect(MockWebSocket.instances[0]?.url).toBe('wss://api.example.com/taskforce-ws');
207
277
  unmount();
208
278
  });
279
+ it('lets a realtime signal break transient retry backoff and pull immediately', async () => {
280
+ vi.useFakeTimers();
281
+ vi.stubGlobal('WebSocket', MockWebSocket);
282
+ runWorkspacePullSyncServiceMock
283
+ .mockResolvedValueOnce({
284
+ success: false,
285
+ kind: 'pull_http',
286
+ status: 502,
287
+ transient: true,
288
+ retryAfterMs: 60_000,
289
+ cursor: null,
290
+ pages: 0,
291
+ appliedChanges: 0,
292
+ pullRequestMs: 1,
293
+ applyMs: 0,
294
+ pulledDeleteTaskIds: new Set(),
295
+ pulledActiveUpserts: false,
296
+ pulledArchivedUpserts: false,
297
+ documentDecryptFailures: []
298
+ })
299
+ .mockResolvedValueOnce({
300
+ success: true,
301
+ syncedAt: '2026-03-15T18:05:00.000Z',
302
+ cursor: null,
303
+ pages: 1,
304
+ appliedChanges: 1,
305
+ pullRequestMs: 1,
306
+ applyMs: 0,
307
+ pulledDeleteTaskIds: new Set(),
308
+ pulledActiveUpserts: true,
309
+ pulledArchivedUpserts: false,
310
+ documentDecryptFailures: []
311
+ });
312
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
313
+ initialProps: buildInput({ realtimeSyncEnabled: true })
314
+ });
315
+ act(() => {
316
+ result.current.applyWorkspaceSyncStateSnapshot({
317
+ enabled: true,
318
+ phase: 'active',
319
+ pullCursor: null
320
+ });
321
+ });
322
+ await act(async () => {
323
+ await Promise.resolve();
324
+ await Promise.resolve();
325
+ });
326
+ expect(result.current.workspaceRetryAt).not.toBeNull();
327
+ expect(MockWebSocket.instances).toHaveLength(1);
328
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
329
+ act(() => {
330
+ MockWebSocket.instances[0]?.open();
331
+ MockWebSocket.instances[0]?.message({
332
+ type: 'taskforce:update',
333
+ workspaceId: 'workspace-local-active'
334
+ });
335
+ });
336
+ await act(async () => {
337
+ await vi.advanceTimersByTimeAsync(300);
338
+ await Promise.resolve();
339
+ await Promise.resolve();
340
+ });
341
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(2);
342
+ expect(result.current.workspaceRetryAt).toBeNull();
343
+ expect(result.current.workspaceLastPullAt).toBe('2026-03-15T18:05:00.000Z');
344
+ unmount();
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
+ });
209
566
  it('retries transient push failures automatically', async () => {
210
567
  runWorkspacePushSyncServiceMock
211
568
  .mockResolvedValueOnce({
@@ -299,7 +656,20 @@ describe('useSyncOrchestrator', () => {
299
656
  })
300
657
  .mockImplementationOnce(() => new Promise((resolve) => {
301
658
  resolveRetryPull = resolve;
302
- }));
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
+ });
303
673
  const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
304
674
  initialProps: buildInput()
305
675
  });
@@ -343,6 +713,218 @@ describe('useSyncOrchestrator', () => {
343
713
  });
344
714
  unmount();
345
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
+ });
346
928
  it('finishes provision-local bootstrap by moving to active after the first push', async () => {
347
929
  runWorkspacePushSyncServiceMock.mockResolvedValue({
348
930
  success: true,
@@ -414,6 +996,80 @@ describe('useSyncOrchestrator', () => {
414
996
  });
415
997
  unmount();
416
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
+ });
417
1073
  it('does not auto-push local state immediately after attach-cloud bootstrap completes', async () => {
418
1074
  vi.useFakeTimers();
419
1075
  const task = {
@@ -730,7 +1386,8 @@ describe('useSyncOrchestrator', () => {
730
1386
  await Promise.resolve();
731
1387
  await Promise.resolve();
732
1388
  });
733
- expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
1389
+ const pullCallsDuringRepair = runWorkspacePullSyncServiceMock.mock.calls.length;
1390
+ expect(pullCallsDuringRepair).toBeGreaterThanOrEqual(1);
734
1391
  await act(async () => {
735
1392
  const pushed = await result.current.pushWorkspaceChangesToCloud('sig-during-repair');
736
1393
  const pulled = await result.current.pullWorkspaceChangesFromCloud();
@@ -738,7 +1395,7 @@ describe('useSyncOrchestrator', () => {
738
1395
  expect(pulled).toBe(false);
739
1396
  });
740
1397
  expect(runWorkspacePushSyncServiceMock).not.toHaveBeenCalled();
741
- expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
1398
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(pullCallsDuringRepair);
742
1399
  await act(async () => {
743
1400
  resolveRepairPull?.({
744
1401
  success: true,