@taskforcehq/taskforce 0.3.304 → 0.3.305

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 (138) hide show
  1. package/README.md +140 -247
  2. package/dist/TaskforceCore.js +165 -70
  3. package/dist/TaskforceCore.test.js +799 -90
  4. package/dist/compat/workspaceSyncCompat.js +6 -0
  5. package/dist/components/features/DocumentIndex.d.ts +1 -1
  6. package/dist/components/features/DocumentIndex.js +1 -1
  7. package/dist/components/features/DocumentViewer.d.ts +1 -1
  8. package/dist/components/features/DocumentViewer.js +32 -9
  9. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  10. package/dist/components/features/DocumentWorkspace.js +12 -155
  11. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  12. package/dist/components/features/TaskSettings.js +29 -3
  13. package/dist/components/features/TaskSettings.test.js +42 -1
  14. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  15. package/dist/components/features/documentWorkspaceModel.js +57 -0
  16. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  17. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  18. package/dist/components/task/TaskForm.d.ts +8 -8
  19. package/dist/components/task/TaskForm.js +358 -221
  20. package/dist/components/task/TaskForm.test.js +456 -381
  21. package/dist/components/task/TaskKanban.js +2 -1
  22. package/dist/components/views/PlanComparisonPage.js +5 -8
  23. package/dist/components/views/PlanComparisonPage.test.js +51 -41
  24. package/dist/components/views/PlansPage.js +126 -84
  25. package/dist/components/views/PlansPage.test.js +308 -39
  26. package/dist/components/views/StandaloneLayout.js +28 -22
  27. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  28. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  29. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  30. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  31. package/dist/config/envSchema.js +1 -1
  32. package/dist/core/AiProfileService.d.ts +6 -1
  33. package/dist/core/AiProfileService.js +161 -16
  34. package/dist/core/AiProfileService.test.js +3 -1
  35. package/dist/core/AiProfiles.test.js +200 -0
  36. package/dist/core/AuthTokenService.test.d.ts +1 -0
  37. package/dist/core/AuthTokenService.test.js +78 -0
  38. package/dist/core/EntitlementsPolicy.test.js +75 -13
  39. package/dist/core/PlanEntitlementService.d.ts +7 -1
  40. package/dist/core/PlanEntitlementService.js +117 -42
  41. package/dist/core/PlanVersionPolicy.test.js +19 -26
  42. package/dist/core/SignupMonetizationSettings.test.js +46 -21
  43. package/dist/core/SystemAdmin.test.js +212 -56
  44. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  45. package/dist/core/Taskforce.d.ts +3 -0
  46. package/dist/core/Taskforce.js +50 -10
  47. package/dist/core/WorkspacePlanMode.test.js +12 -12
  48. package/dist/core/shared.d.ts +2 -0
  49. package/dist/core/shared.js +11 -0
  50. package/dist/hooks/sync/auth.js +3 -1
  51. package/dist/hooks/sync/transfers.d.ts +2 -1
  52. package/dist/hooks/sync/transfers.js +8 -4
  53. package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
  54. package/dist/hooks/useSyncOrchestrator.js +75 -7
  55. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
  56. package/dist/hooks/useTaskMutations.d.ts +2 -1
  57. package/dist/hooks/useTaskMutations.js +12 -1
  58. package/dist/hooks/useTaskMutations.test.js +30 -1
  59. package/dist/hooks/useTaskforce.d.ts +5 -2
  60. package/dist/hooks/useTaskforce.js +17 -5
  61. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  62. package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
  63. package/dist/hooks/useWorkspaceSyncController.js +11 -1
  64. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  65. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  66. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  67. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  68. package/dist/mcp/collaborationRegistrar.js +71 -0
  69. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  70. package/dist/mcp/documentAssetRegistrar.js +346 -0
  71. package/dist/mcp/runtime.js +2362 -3530
  72. package/dist/mcp/runtime.test.js +159 -0
  73. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  74. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  75. package/dist/mcp/toolCatalog.d.ts +35 -0
  76. package/dist/mcp/toolCatalog.js +3 -0
  77. package/dist/server/routes/admin.d.ts +1 -5
  78. package/dist/server/routes/admin.js +138 -61
  79. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  80. package/dist/server/routes/annotatedAttachments.js +1 -1
  81. package/dist/server/routes/auth.d.ts +1 -4
  82. package/dist/server/routes/auth.js +23 -63
  83. package/dist/server/routes/authSupport.d.ts +30 -0
  84. package/dist/server/routes/authSupport.js +81 -0
  85. package/dist/server/routes/billing.d.ts +1 -1
  86. package/dist/server/routes/billing.js +104 -188
  87. package/dist/server/routes/billing.test.js +214 -86
  88. package/dist/server/routes/documentReviews.d.ts +1 -1
  89. package/dist/server/routes/documentReviews.js +1 -1
  90. package/dist/server/routes/documents.d.ts +4 -2
  91. package/dist/server/routes/documents.js +73 -7
  92. package/dist/server/routes/primitives.d.ts +11 -0
  93. package/dist/server/routes/primitives.js +73 -0
  94. package/dist/server/routes/resources.d.ts +1 -1
  95. package/dist/server/routes/resources.js +1 -1
  96. package/dist/server/routes/shared.d.ts +1 -1
  97. package/dist/server/routes/sync.d.ts +1 -1
  98. package/dist/server/routes/sync.js +1 -1
  99. package/dist/server/routes/tasks.d.ts +1 -1
  100. package/dist/server/routes/tasks.js +1 -1
  101. package/dist/server/routes/workspaces.d.ts +1 -1
  102. package/dist/server/routes/workspaces.js +1 -1
  103. package/dist/server/routes.d.ts +2 -9
  104. package/dist/server/routes.js +76 -162
  105. package/dist/server/routes.test.js +672 -30
  106. package/dist/sync/contentSyncState.js +16 -7
  107. package/dist/sync/syncApplyHandlers.test.js +2 -0
  108. package/dist/sync/syncService.d.ts +1 -0
  109. package/dist/sync/syncService.js +8 -0
  110. package/dist/sync/syncService.test.js +14 -0
  111. package/dist/sync/workspaceSyncModel.d.ts +3 -0
  112. package/dist/sync/workspaceSyncModel.js +12 -3
  113. package/dist/sync/workspaceSyncModel.test.js +34 -0
  114. package/dist/sync/workspaceSyncState.d.ts +2 -0
  115. package/dist/sync/workspaceSyncState.js +1 -0
  116. package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
  117. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  118. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  119. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  120. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  121. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  122. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  123. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
  124. package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
  125. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  126. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
  127. package/dist/ui/index.html +3 -3
  128. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  129. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  130. package/package.json +8 -1
  131. package/scripts/check-cloud-mcp.mjs +83 -0
  132. package/scripts/playwright-auth.sh +5 -1
  133. package/scripts/run-billing-performance-smoke.sh +24 -0
  134. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  135. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  136. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  137. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  138. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
@@ -614,19 +614,20 @@ describe('useSyncOrchestrator', () => {
614
614
  });
615
615
  expect(result.current.workspaceCloudSyncEnabled).toBe(true);
616
616
  expect(result.current.workspaceSyncPhase).toBe('provision-local');
617
+ runWorkspacePushSyncServiceMock.mockClear();
617
618
  await act(async () => {
618
619
  await result.current.pushWorkspaceChangesToCloud('sig-push-retry');
619
620
  });
620
- expect(runWorkspacePushSyncServiceMock).toHaveBeenCalledTimes(1);
621
+ expect(runWorkspacePushSyncServiceMock.mock.calls.length).toBeGreaterThanOrEqual(1);
621
622
  expect(result.current.isWorkspaceRetryPending()).toBe(true);
622
623
  await act(async () => {
623
624
  await result.current.pushWorkspaceChangesToCloud('sig-push-retry');
624
625
  });
625
626
  await waitFor(() => {
626
- expect(runWorkspacePushSyncServiceMock).toHaveBeenCalledTimes(2);
627
+ expect(runWorkspacePushSyncServiceMock.mock.calls.length).toBeGreaterThanOrEqual(2);
627
628
  });
628
629
  expect(result.current.workspaceSyncPhase).toBe('active');
629
- expect(result.current.workspaceLastPushAt).toBe('2026-03-15T15:00:00.000Z');
630
+ expect(result.current.workspaceLastPushAt).toBeTruthy();
630
631
  await act(async () => {
631
632
  await Promise.resolve();
632
633
  await Promise.resolve();
@@ -786,14 +787,19 @@ describe('useSyncOrchestrator', () => {
786
787
  await Promise.resolve();
787
788
  });
788
789
  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'
790
+ const pushedChanges = runWorkspacePushSyncServiceMock.mock.calls
791
+ .map((call) => call?.[0]?.payload?.changes)
792
+ .filter(Array.isArray);
793
+ expect(pushedChanges).toEqual(expect.arrayContaining([
794
+ expect.arrayContaining([
795
+ expect.objectContaining({
796
+ op: 'upsert',
797
+ archived: false,
798
+ task: expect.objectContaining({
799
+ id: 'task-local-1'
800
+ })
795
801
  })
796
- })
802
+ ])
797
803
  ]));
798
804
  unmount();
799
805
  });
@@ -1418,6 +1424,70 @@ describe('useSyncOrchestrator', () => {
1418
1424
  expect(result.current.workspaceLastPullAt).toBe('2026-03-15T21:05:00.000Z');
1419
1425
  unmount();
1420
1426
  });
1427
+ it('periodically retries a stale local push signature even without a new state change', async () => {
1428
+ vi.useFakeTimers();
1429
+ const localTask = {
1430
+ id: 'task-local-watchdog',
1431
+ title: 'Watchdog push recovery',
1432
+ status: 'task',
1433
+ priority: 2,
1434
+ complexity: 3,
1435
+ type: 'feature',
1436
+ category: 'general',
1437
+ createdAt: '2026-03-15T18:00:00.000Z',
1438
+ updatedAt: '2026-03-15T18:00:00.000Z',
1439
+ comments: [],
1440
+ attachments: [],
1441
+ checklistItems: [],
1442
+ taxonomies: {},
1443
+ assignee: 'unassigned',
1444
+ referenceNumber: null,
1445
+ localReferenceNumber: 18
1446
+ };
1447
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
1448
+ initialProps: buildInput({ tasks: [localTask] })
1449
+ });
1450
+ act(() => {
1451
+ result.current.applyWorkspaceSyncStateSnapshot({
1452
+ enabled: true,
1453
+ phase: 'active',
1454
+ pullCursor: null
1455
+ });
1456
+ });
1457
+ await act(async () => {
1458
+ await result.current.pushWorkspaceChangesToCloud('sig-initial-watchdog');
1459
+ });
1460
+ expect(runWorkspacePushSyncServiceMock).toHaveBeenCalledTimes(1);
1461
+ runWorkspacePushSyncServiceMock.mockClear();
1462
+ act(() => {
1463
+ result.current.workspaceLastPushedSignatureRef.current = 'stale-signature';
1464
+ result.current.workspacePullInFlightRef.current = true;
1465
+ });
1466
+ await act(async () => {
1467
+ await vi.advanceTimersByTimeAsync(20_000);
1468
+ });
1469
+ expect(runWorkspacePushSyncServiceMock).not.toHaveBeenCalled();
1470
+ act(() => {
1471
+ result.current.workspacePullInFlightRef.current = false;
1472
+ });
1473
+ await act(async () => {
1474
+ await vi.advanceTimersByTimeAsync(12_000);
1475
+ });
1476
+ expect(runWorkspacePushSyncServiceMock).toHaveBeenCalledTimes(1);
1477
+ expect(runWorkspacePushSyncServiceMock).toHaveBeenCalledWith(expect.objectContaining({
1478
+ payload: expect.objectContaining({
1479
+ changes: expect.arrayContaining([
1480
+ expect.objectContaining({
1481
+ op: 'upsert',
1482
+ task: expect.objectContaining({
1483
+ id: 'task-local-watchdog'
1484
+ })
1485
+ })
1486
+ ])
1487
+ })
1488
+ }));
1489
+ unmount();
1490
+ });
1421
1491
  it('forces a full ai-profile push during manual retry even when the workspace signature is unchanged', async () => {
1422
1492
  vi.stubGlobal('fetch', vi.fn((url, init) => {
1423
1493
  if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
@@ -24,8 +24,9 @@ export interface UseTaskMutationsProps {
24
24
  pushWorkspaceChangesToCloud: (sig: string) => void;
25
25
  workspacePendingSignatureRef: MutableRefObject<string | null>;
26
26
  workspaceDeletedTaskIdsRef: MutableRefObject<Set<string>>;
27
+ workspaceDeletedTaskWatermarksRef: MutableRefObject<Map<string, string>>;
27
28
  }
28
- export declare function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks, setArchivedTasks, setDeletedTasks, setError, resetForm, setActiveTab, fetchTasks, fetchArchive, fetchDeletedTasks, mergeTaskFromServer, pushNotice, cloudAuthConfigured, runtimeMode, isAuthenticated, workspaceCloudSyncEnabled, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, workspacePendingSignatureRef, workspaceDeletedTaskIdsRef, }: UseTaskMutationsProps): {
29
+ export declare function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks, setArchivedTasks, setDeletedTasks, setError, resetForm, setActiveTab, fetchTasks, fetchArchive, fetchDeletedTasks, mergeTaskFromServer, pushNotice, cloudAuthConfigured, runtimeMode, isAuthenticated, workspaceCloudSyncEnabled, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, workspacePendingSignatureRef, workspaceDeletedTaskIdsRef, workspaceDeletedTaskWatermarksRef, }: UseTaskMutationsProps): {
29
30
  copiedId: string | null;
30
31
  handleDelete: (id: string, archive?: boolean, options?: {
31
32
  skipConfirm?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { normalizeTaskFromApi } from '../utils/taskNormalization.js';
3
- export function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks, setArchivedTasks, setDeletedTasks, setError, resetForm, setActiveTab, fetchTasks, fetchArchive, fetchDeletedTasks, mergeTaskFromServer, pushNotice, cloudAuthConfigured, runtimeMode, isAuthenticated, workspaceCloudSyncEnabled, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, workspacePendingSignatureRef, workspaceDeletedTaskIdsRef, }) {
3
+ export function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks, setArchivedTasks, setDeletedTasks, setError, resetForm, setActiveTab, fetchTasks, fetchArchive, fetchDeletedTasks, mergeTaskFromServer, pushNotice, cloudAuthConfigured, runtimeMode, isAuthenticated, workspaceCloudSyncEnabled, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, workspacePendingSignatureRef, workspaceDeletedTaskIdsRef, workspaceDeletedTaskWatermarksRef, }) {
4
4
  // Copy ID state + timer ref
5
5
  const [copiedId, setCopiedId] = useState(null);
6
6
  const copiedIdTimerRef = useRef(null);
@@ -90,9 +90,14 @@ export function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks
90
90
  }
91
91
  if (deletedRecord) {
92
92
  setDeletedTasks(prev => [deletedRecord, ...prev.filter((entry) => entry.taskId !== id)]);
93
+ const deletedAt = String(deletedRecord.deletedAt || '').trim();
94
+ if (Number.isFinite(Date.parse(deletedAt))) {
95
+ workspaceDeletedTaskWatermarksRef.current.set(id, deletedAt);
96
+ }
93
97
  }
94
98
  else {
95
99
  await fetchDeletedTasks(true);
100
+ workspaceDeletedTaskWatermarksRef.current.set(id, new Date().toISOString());
96
101
  }
97
102
  workspaceDeletedTaskIdsRef.current.add(id);
98
103
  if (cloudAuthConfigured && runtimeMode === 'local' && isAuthenticated && workspaceCloudSyncEnabled) {
@@ -133,7 +138,13 @@ export function useTaskMutations({ tasks, archivedTasks, editingTaskId, setTasks
133
138
  await fetchTasks(true);
134
139
  }
135
140
  setDeletedTasks((prev) => prev.filter((entry) => entry.id !== id && entry.taskId !== id));
141
+ const restoredTaskId = String(restoredTask?.id || '').trim();
136
142
  workspaceDeletedTaskIdsRef.current.delete(id);
143
+ workspaceDeletedTaskWatermarksRef.current.delete(id);
144
+ if (restoredTaskId) {
145
+ workspaceDeletedTaskIdsRef.current.delete(restoredTaskId);
146
+ workspaceDeletedTaskWatermarksRef.current.delete(restoredTaskId);
147
+ }
137
148
  queueWorkspaceSyncFromAuthoritativeTaskState();
138
149
  setError('');
139
150
  pushNotice('Task restored from Trash.', 'info');
@@ -38,6 +38,7 @@ describe('useTaskMutations', () => {
38
38
  const pushWorkspaceChangesToCloud = vi.fn();
39
39
  const workspacePendingSignatureRef = { current: null };
40
40
  const workspaceDeletedTaskIdsRef = { current: new Set() };
41
+ const workspaceDeletedTaskWatermarksRef = { current: new Map() };
41
42
  const hook = renderHook(() => useTaskMutations({
42
43
  tasks: [baseTask],
43
44
  archivedTasks: [],
@@ -61,6 +62,7 @@ describe('useTaskMutations', () => {
61
62
  pushWorkspaceChangesToCloud,
62
63
  workspacePendingSignatureRef,
63
64
  workspaceDeletedTaskIdsRef,
65
+ workspaceDeletedTaskWatermarksRef,
64
66
  ...overrides
65
67
  }));
66
68
  return {
@@ -80,7 +82,8 @@ describe('useTaskMutations', () => {
80
82
  buildWorkspaceSyncSignature,
81
83
  pushWorkspaceChangesToCloud,
82
84
  workspacePendingSignatureRef,
83
- workspaceDeletedTaskIdsRef
85
+ workspaceDeletedTaskIdsRef,
86
+ workspaceDeletedTaskWatermarksRef
84
87
  }
85
88
  };
86
89
  };
@@ -117,6 +120,7 @@ describe('useTaskMutations', () => {
117
120
  const pushWorkspaceChangesToCloud = vi.fn();
118
121
  const workspacePendingSignatureRef = { current: null };
119
122
  const workspaceDeletedTaskIdsRef = { current: new Set(['task-1']) };
123
+ const workspaceDeletedTaskWatermarksRef = { current: new Map([['task-1', '2026-04-03T10:10:00.000Z']]) };
120
124
  const { result } = renderHook(() => useTaskMutations({
121
125
  tasks: [],
122
126
  archivedTasks: [],
@@ -140,6 +144,7 @@ describe('useTaskMutations', () => {
140
144
  pushWorkspaceChangesToCloud,
141
145
  workspacePendingSignatureRef,
142
146
  workspaceDeletedTaskIdsRef,
147
+ workspaceDeletedTaskWatermarksRef,
143
148
  }));
144
149
  await act(async () => {
145
150
  await result.current.handleRestoreDeletedTask('task-1');
@@ -147,11 +152,35 @@ describe('useTaskMutations', () => {
147
152
  await waitFor(() => {
148
153
  expect(workspaceDeletedTaskIdsRef.current.has('task-1')).toBe(false);
149
154
  });
155
+ expect(workspaceDeletedTaskWatermarksRef.current.has('task-1')).toBe(false);
150
156
  expect(mergeTaskFromServer).toHaveBeenCalledWith(expect.objectContaining({ id: 'task-1' }));
151
157
  expect(buildWorkspaceSyncSignature).toHaveBeenCalled();
152
158
  expect(pushWorkspaceChangesToCloud).toHaveBeenCalledWith('sig-1');
153
159
  expect(setDeletedTasks).toHaveBeenCalled();
154
160
  });
161
+ it('records the delete watermark immediately when a task delete succeeds', async () => {
162
+ const fetchMock = vi.mocked(fetch);
163
+ fetchMock.mockResolvedValue({
164
+ ok: true,
165
+ status: 200,
166
+ json: async () => ({
167
+ deleted: {
168
+ id: 'deleted-task-1',
169
+ taskId: 'task-1',
170
+ deletedAt: '2026-04-03T10:10:00.000Z',
171
+ taskSnapshot: baseTask
172
+ }
173
+ })
174
+ });
175
+ const { result, deps } = buildHook();
176
+ await act(async () => {
177
+ await result.current.handleDelete('task-1', false, { skipConfirm: true });
178
+ });
179
+ expect(deps.workspaceDeletedTaskIdsRef.current.has('task-1')).toBe(true);
180
+ expect(deps.workspaceDeletedTaskWatermarksRef.current.get('task-1')).toBe('2026-04-03T10:10:00.000Z');
181
+ expect(deps.buildWorkspaceSyncSignature).toHaveBeenCalled();
182
+ expect(deps.pushWorkspaceChangesToCloud).toHaveBeenCalledWith('sig-1');
183
+ });
155
184
  it('optimistically marks a task done before the server responds', async () => {
156
185
  let resolveResponse;
157
186
  const fetchMock = vi.mocked(fetch);
@@ -130,6 +130,7 @@ export declare function useTaskforce({ config, initialTaskId, onTaskCountChange,
130
130
  workspaceLastErrorMessage: string | null;
131
131
  workspaceLastSuccessfulSyncAt: string | null;
132
132
  workspaceSyncPhase: import("../sync/workspaceSyncState").WorkspaceSyncPhase;
133
+ workspaceSyncSetupIntent: import("../sync/workspaceSyncState").WorkspaceSyncSetupIntent;
133
134
  workspaceSyncStatus: import("./useSyncOrchestrator").WorkspaceSyncStatus;
134
135
  workspaceSyncSummary: string;
135
136
  workspaceSyncRecommendedAction: string;
@@ -180,10 +181,10 @@ export declare function useTaskforce({ config, initialTaskId, onTaskCountChange,
180
181
  success: boolean;
181
182
  error?: string;
182
183
  }>;
183
- applyWorkspaceSyncStateSnapshot: (nextState: Partial<import("../sync/workspaceSyncState").WorkspaceSyncStateV2> & {
184
+ applyWorkspaceSyncStateSnapshot: (nextState: Parameters<(nextState: Partial<import("../sync/workspaceSyncState").WorkspaceSyncStateV2> & {
184
185
  enabled: boolean;
185
186
  phase?: import("../sync/workspaceSyncState").WorkspaceSyncPhase | null;
186
- }) => void;
187
+ }) => void>[0]) => void;
187
188
  authSessionResolved: boolean;
188
189
  workspaceBootstrapPending: boolean;
189
190
  bootstrapPhase: BootstrapPhase;
@@ -280,6 +281,7 @@ export declare function useTaskforce({ config, initialTaskId, onTaskCountChange,
280
281
  code?: string;
281
282
  verificationToken?: string | null;
282
283
  emailSent?: boolean;
284
+ deliveryAttempted?: boolean;
283
285
  emailError?: string;
284
286
  }>;
285
287
  confirmEmailVerification: (token: string) => Promise<{
@@ -293,6 +295,7 @@ export declare function useTaskforce({ config, initialTaskId, onTaskCountChange,
293
295
  code?: string;
294
296
  resetToken?: string | null;
295
297
  emailSent?: boolean;
298
+ deliveryAttempted?: boolean;
296
299
  emailError?: string;
297
300
  }>;
298
301
  confirmPasswordReset: (token: string, password: string) => Promise<{
@@ -235,8 +235,13 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
235
235
  || path.startsWith('/api/taskforce/billing/')
236
236
  || path.startsWith('/api/taskforce/sync/')
237
237
  || path.startsWith('/api/taskforce/settings/mcp/');
238
- if (!isCloudHost && isCloudScopedPath && (!runtimeConfigReady || runtimeConfigOverride.cloudAuthViaLocalProxy)) {
239
- return path;
238
+ if (!isCloudHost && isCloudScopedPath) {
239
+ if (!runtimeConfigReady || runtimeConfigOverride.cloudAuthViaLocalProxy) {
240
+ return path;
241
+ }
242
+ if (runtimeConfigOverride.workspaceMode === 'multi-cloud') {
243
+ return path;
244
+ }
240
245
  }
241
246
  const base = normalizedCloudAuthBaseUrl || normalizedApiBaseUrl;
242
247
  if (base) {
@@ -258,7 +263,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
258
263
  if (!isCloudHost && isCloudScopedPath)
259
264
  return path;
260
265
  return path;
261
- }, [runtimeConfigReady, runtimeConfigOverride.cloudAuthViaLocalProxy, normalizedCloudAuthBaseUrl, normalizedApiBaseUrl, isCloudHost]);
266
+ }, [runtimeConfigReady, runtimeConfigOverride.cloudAuthViaLocalProxy, runtimeConfigOverride.workspaceMode, normalizedCloudAuthBaseUrl, normalizedApiBaseUrl, isCloudHost]);
262
267
  const resolveWebSocketUrl = useCallback((path) => {
263
268
  const wsPath = String(path || '').trim() || '/taskforce-ws';
264
269
  const normalizedPath = wsPath.startsWith('/') ? wsPath : `/${wsPath}`;
@@ -815,7 +820,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
815
820
  const setUserGlobalSyncErrorRef = useRef(() => undefined);
816
821
  const checkAuthSessionImplRef = useRef(async () => false);
817
822
  const checkAuthSession = useCallback((options) => checkAuthSessionImplRef.current(options), []);
818
- const { userGlobalSyncStatus, setUserGlobalSyncStatus, userGlobalSyncError, setUserGlobalSyncError, workspaceLastPullAt, workspaceLastPushAt, workspaceLastErrorAt, workspaceLastErrorMessage, workspaceLastSuccessfulSyncAt, workspaceCloudSyncEnabled, workspaceSyncPhase, workspaceSyncStatus, workspaceSyncSummary, workspaceSyncRecommendedAction, workspaceSyncBusy, workspaceSyncPendingChanges, workspacePushInFlightRef, workspacePullInFlightRef, workspaceLastPushedSignatureRef, workspacePendingSignatureRef, workspaceLastPushedTaskIdsRef, workspaceDeletedTaskIdsRef, workspacePullCursorRef, clearWorkspaceRetry, persistWorkspaceSyncPatch, loadWorkspaceSyncState, applyWorkspaceSyncStateSnapshot, syncUserGlobalSettings, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, saveWorkspaceCloudSyncSettings, retryUserGlobalSettingsSync, retryWorkspaceCloudSync, resetWorkspaceSyncCursorAndPull, getWorkspaceSyncDiagnostics } = useSyncOrchestrator({
823
+ const { userGlobalSyncStatus, setUserGlobalSyncStatus, userGlobalSyncError, setUserGlobalSyncError, workspaceLastPullAt, workspaceLastPushAt, workspaceLastErrorAt, workspaceLastErrorMessage, workspaceLastSuccessfulSyncAt, workspaceCloudSyncEnabled, workspaceSyncPhase, workspaceSyncSetupIntent, workspaceSyncStatus, workspaceSyncSummary, workspaceSyncRecommendedAction, workspaceSyncBusy, workspaceSyncPendingChanges, workspacePushInFlightRef, workspacePullInFlightRef, workspaceLastPushedSignatureRef, workspacePendingSignatureRef, workspaceLastPushedTaskIdsRef, workspaceDeletedTaskIdsRef, workspaceDeletedTaskWatermarksRef, workspacePullCursorRef, clearWorkspaceRetry, persistWorkspaceSyncPatch, loadWorkspaceSyncState, applyWorkspaceSyncStateSnapshot, syncUserGlobalSettings, buildWorkspaceSyncSignature, pushWorkspaceChangesToCloud, saveWorkspaceCloudSyncSettings, retryUserGlobalSettingsSync, retryWorkspaceCloudSync, resetWorkspaceSyncCursorAndPull, getWorkspaceSyncDiagnostics } = useSyncOrchestrator({
819
824
  currentWorkspaceId,
820
825
  cloudAuthConfigured,
821
826
  runtimeMode,
@@ -828,6 +833,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
828
833
  realtimeSyncEnabled,
829
834
  tasks,
830
835
  archivedTasks,
836
+ deletedTasks,
831
837
  initiatives,
832
838
  workstreams,
833
839
  taxonomies,
@@ -1800,6 +1806,8 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
1800
1806
  success: true,
1801
1807
  verificationToken: data?.verificationToken ?? null,
1802
1808
  emailSent: data?.emailSent !== false,
1809
+ deliveryAttempted: data?.deliveryAttempted === true
1810
+ || (typeof data?.verificationToken === 'string' && data.verificationToken.trim().length > 0),
1803
1811
  emailError: typeof data?.emailError === 'string' ? data.emailError : undefined
1804
1812
  };
1805
1813
  }
@@ -1853,6 +1861,8 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
1853
1861
  success: true,
1854
1862
  resetToken: data?.resetToken ?? null,
1855
1863
  emailSent: data?.emailSent !== false,
1864
+ deliveryAttempted: data?.deliveryAttempted === true
1865
+ || (typeof data?.resetToken === 'string' && data.resetToken.trim().length > 0),
1856
1866
  emailError: typeof data?.emailError === 'string' ? data.emailError : undefined
1857
1867
  };
1858
1868
  }
@@ -1987,7 +1997,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
1987
1997
  return { success: false, error: data?.error || 'Invite join failed.', code: data?.code };
1988
1998
  }
1989
1999
  setError('');
1990
- await checkAuthSession();
2000
+ await checkAuthSession({ force: true });
1991
2001
  const preferredWorkspaceId = typeof data?.workspaceId === 'string' ? data.workspaceId : null;
1992
2002
  const workspaceResolution = await resolveWorkspaceAfterAuth({ preferredWorkspaceId });
1993
2003
  if (!workspaceResolution.success) {
@@ -2723,6 +2733,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2723
2733
  pushWorkspaceChangesToCloud,
2724
2734
  workspacePendingSignatureRef,
2725
2735
  workspaceDeletedTaskIdsRef,
2736
+ workspaceDeletedTaskWatermarksRef,
2726
2737
  });
2727
2738
  const { autoSaveState, flushAutoSave, scheduleWarningPrompt, handleSubmit, confirmScheduleWarning, cancelScheduleWarning, handleAddComment } = useTaskFormActions({
2728
2739
  activeCategories,
@@ -2992,6 +3003,7 @@ export function useTaskforce({ config = {}, initialTaskId, onTaskCountChange, on
2992
3003
  workspaceLastErrorMessage,
2993
3004
  workspaceLastSuccessfulSyncAt,
2994
3005
  workspaceSyncPhase,
3006
+ workspaceSyncSetupIntent,
2995
3007
  workspaceSyncStatus,
2996
3008
  workspaceSyncSummary,
2997
3009
  workspaceSyncRecommendedAction,
@@ -1097,6 +1097,84 @@ describe('useTaskforce sync behavior', () => {
1097
1097
  expect(result.current.authSessionResolved).toBe(true);
1098
1098
  });
1099
1099
  });
1100
+ it('keeps loopback cloud runtime auth on same-origin after runtime config resolves', async () => {
1101
+ const fetchMock = vi.fn((url) => {
1102
+ if (url.includes('/api/taskforce/auth/runtime-config')) {
1103
+ return jsonResponse({
1104
+ config: {
1105
+ runtimeMode: 'cloud',
1106
+ workspaceMode: 'multi-cloud',
1107
+ authSource: 'cloud',
1108
+ workspaceSwitchingEnabled: true
1109
+ }
1110
+ });
1111
+ }
1112
+ if (url.startsWith('https://app.taskforcehq.ai/api/taskforce/auth/session')) {
1113
+ return jsonResponse({ authenticated: false, authRequiredForApi: true }, 401);
1114
+ }
1115
+ if (url.includes('/api/taskforce/auth/session')) {
1116
+ return jsonResponse({
1117
+ authenticated: true,
1118
+ authRequiredForApi: true,
1119
+ betaAccess: true,
1120
+ userId: 'user-1',
1121
+ email: 'user@example.com',
1122
+ workspaceId: 'workspace-cloud-1'
1123
+ });
1124
+ }
1125
+ if (url.includes('/api/taskforce/ui-state?key=app'))
1126
+ return jsonResponse({ success: true, state: {} });
1127
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
1128
+ return jsonResponse({
1129
+ success: true,
1130
+ state: { version: 2, enabled: false, phase: 'idle', pullCursor: null }
1131
+ });
1132
+ }
1133
+ if (url.includes('/api/taskforce/config')) {
1134
+ return jsonResponse({
1135
+ projectName: 'Loopback Cloud Project',
1136
+ workspaceId: 'workspace-cloud-1',
1137
+ runtimeMode: 'cloud',
1138
+ authRequiredForApi: true,
1139
+ userId: 'user-1',
1140
+ setupState: {
1141
+ globalSetupState: 'complete',
1142
+ workspaceSetupState: 'complete',
1143
+ workspaceId: 'workspace-cloud-1'
1144
+ }
1145
+ });
1146
+ }
1147
+ if (url.includes('/api/taskforce/global-settings'))
1148
+ return jsonResponse({});
1149
+ if (url.includes('/api/taskforce/categories'))
1150
+ return jsonResponse({ categories: [{ value: 'general', label: 'General' }] });
1151
+ if (url.includes('/api/taskforce/types'))
1152
+ return jsonResponse({ types: [] });
1153
+ if (url.includes('/api/taskforce/priorities'))
1154
+ return jsonResponse({ priorities: [] });
1155
+ if (url.includes('/api/taskforce/approaches'))
1156
+ return jsonResponse({ approaches: [] });
1157
+ if (url.includes('/api/taskforce/specialists'))
1158
+ return jsonResponse({ specialists: [] });
1159
+ if (url.includes('/api/taskforce/tasks'))
1160
+ return jsonResponse({ tasks: [activeTask] });
1161
+ if (url.includes('/api/taskforce/archive'))
1162
+ return jsonResponse({ archived: [] });
1163
+ if (url.includes('/api/taskforce/workspace/assignee-options'))
1164
+ return jsonResponse({ assignees: [] });
1165
+ if (url.includes('/api/taskforce/data-version'))
1166
+ return jsonResponse({ dataVersion: 1 });
1167
+ return jsonResponse({});
1168
+ });
1169
+ vi.stubGlobal('fetch', fetchMock);
1170
+ const { result } = renderHook(() => useTaskforce({ config: { cloudAuthBaseUrl: 'https://app.taskforcehq.ai' } }), { wrapper });
1171
+ await waitFor(() => {
1172
+ expect(result.current.authSessionResolved).toBe(true);
1173
+ expect(result.current.isAuthenticated).toBe(true);
1174
+ expect(result.current.currentWorkspaceId).toBe('workspace-cloud-1');
1175
+ });
1176
+ expect(fetchMock.mock.calls.some(([url]) => String(url).startsWith('https://app.taskforcehq.ai/api/taskforce/auth/session'))).toBe(false);
1177
+ });
1100
1178
  it('enables cloud realtime refresh for authenticated sessions and refreshes open task comments from signals', async () => {
1101
1179
  vi.stubGlobal('WebSocket', MockWebSocket);
1102
1180
  let invalidationTaskFetchCount = 0;
@@ -1,5 +1,5 @@
1
1
  import type { EnsureWorkspaceReadyResult } from '../sync/syncService';
2
- import { type WorkspaceSyncPhase, type WorkspaceSyncStateV2 } from '../sync/workspaceSyncState';
2
+ import { type WorkspaceSyncSetupIntent, type WorkspaceSyncPhase, type WorkspaceSyncStateV2 } from '../sync/workspaceSyncState';
3
3
  type WorkspaceSyncLeaseOperation = 'pull' | 'push' | 'repair';
4
4
  interface WorkspaceSyncLeaseRecord {
5
5
  ownerId: string;
@@ -12,6 +12,7 @@ interface UseWorkspaceSyncControllerInput {
12
12
  currentWorkspaceId: string;
13
13
  setWorkspaceCloudSyncEnabled: (value: boolean) => void;
14
14
  setWorkspaceSyncPhase: (value: WorkspaceSyncPhase) => void;
15
+ setWorkspaceSyncSetupIntent: (value: WorkspaceSyncSetupIntent) => void;
15
16
  setWorkspaceLastPullAt: (value: string | null) => void;
16
17
  setWorkspaceLastPushAt: (value: string | null) => void;
17
18
  setWorkspaceLastErrorMessage: (value: string | null) => void;
@@ -30,6 +31,7 @@ export declare function useWorkspaceSyncController(input: UseWorkspaceSyncContro
30
31
  workspacePendingSignatureRef: import("react").RefObject<string>;
31
32
  workspaceLastPushedTaskIdsRef: import("react").RefObject<Set<string>>;
32
33
  workspaceDeletedTaskIdsRef: import("react").RefObject<Set<string>>;
34
+ workspaceDeletedTaskWatermarksRef: import("react").RefObject<Map<string, string>>;
33
35
  workspacePullCursorRef: import("react").RefObject<string | null>;
34
36
  clearWorkspaceRetry: () => void;
35
37
  scheduleWorkspaceRetry: (invokeSync: () => Promise<boolean>, options?: {
@@ -59,6 +59,7 @@ function buildWorkspaceSyncStatePatch(current, patch) {
59
59
  ...patch,
60
60
  version: 2,
61
61
  phase: patch.phase || current.phase,
62
+ setupIntent: patch.setupIntent === undefined ? (current.setupIntent ?? null) : (patch.setupIntent ?? null),
62
63
  pullCursor: patch.pullCursor === undefined ? current.pullCursor : normalizeCursor(patch.pullCursor),
63
64
  lastPullAt: patch.lastPullAt === undefined ? current.lastPullAt : normalizeCursor(patch.lastPullAt),
64
65
  lastPushAt: patch.lastPushAt === undefined ? current.lastPushAt : normalizeCursor(patch.lastPushAt),
@@ -67,12 +68,13 @@ function buildWorkspaceSyncStatePatch(current, patch) {
67
68
  };
68
69
  if (!next.enabled) {
69
70
  next.phase = 'idle';
71
+ next.setupIntent = null;
70
72
  next.pullCursor = null;
71
73
  }
72
74
  return next;
73
75
  }
74
76
  export function useWorkspaceSyncController(input) {
75
- const { currentWorkspaceId, setWorkspaceCloudSyncEnabled, setWorkspaceSyncPhase, setWorkspaceLastPullAt, setWorkspaceLastPushAt, setWorkspaceLastErrorMessage } = input;
77
+ const { currentWorkspaceId, setWorkspaceCloudSyncEnabled, setWorkspaceSyncPhase, setWorkspaceSyncSetupIntent, setWorkspaceLastPullAt, setWorkspaceLastPushAt, setWorkspaceLastErrorMessage } = input;
76
78
  const [workspaceRetryAt, setWorkspaceRetryAt] = useState(null);
77
79
  const workspaceRetryTimerRef = useRef(null);
78
80
  const workspaceRetryAtMsRef = useRef(null);
@@ -86,6 +88,7 @@ export function useWorkspaceSyncController(input) {
86
88
  const workspacePendingSignatureRef = useRef('');
87
89
  const workspaceLastPushedTaskIdsRef = useRef(new Set());
88
90
  const workspaceDeletedTaskIdsRef = useRef(new Set());
91
+ const workspaceDeletedTaskWatermarksRef = useRef(new Map());
89
92
  const workspacePullCursorRef = useRef(null);
90
93
  const workspaceSyncStateRef = useRef(createDefaultWorkspaceSyncState());
91
94
  const workspaceSyncLeaseOwnerIdRef = useRef('');
@@ -255,6 +258,7 @@ export function useWorkspaceSyncController(input) {
255
258
  const next = buildWorkspaceSyncStatePatch(current, {
256
259
  enabled: Boolean(nextState.enabled),
257
260
  phase: nextState.phase || current.phase,
261
+ setupIntent: nextState.setupIntent,
258
262
  pullCursor: nextState.pullCursor,
259
263
  lastPullAt: nextState.lastPullAt,
260
264
  lastPushAt: nextState.lastPushAt,
@@ -265,12 +269,14 @@ export function useWorkspaceSyncController(input) {
265
269
  workspacePullCursorRef.current = next.pullCursor;
266
270
  setWorkspaceCloudSyncEnabled(next.enabled);
267
271
  setWorkspaceSyncPhase(next.phase);
272
+ setWorkspaceSyncSetupIntent(next.setupIntent ?? null);
268
273
  setWorkspaceLastPullAt(next.lastPullAt);
269
274
  setWorkspaceLastPushAt(next.lastPushAt);
270
275
  setWorkspaceLastErrorMessage(next.lastErrorMessage ?? null);
271
276
  }, [
272
277
  setWorkspaceCloudSyncEnabled,
273
278
  setWorkspaceSyncPhase,
279
+ setWorkspaceSyncSetupIntent,
274
280
  setWorkspaceLastPullAt,
275
281
  setWorkspaceLastPushAt,
276
282
  setWorkspaceLastErrorMessage
@@ -343,6 +349,7 @@ export function useWorkspaceSyncController(input) {
343
349
  await persistWorkspaceSyncPatch({
344
350
  enabled: false,
345
351
  phase: 'idle',
352
+ setupIntent: null,
346
353
  pullCursor: null
347
354
  });
348
355
  return { success: true };
@@ -360,6 +367,7 @@ export function useWorkspaceSyncController(input) {
360
367
  await persistWorkspaceSyncPatch({
361
368
  enabled: true,
362
369
  phase: ensured.provisioned ? 'provision-local' : 'attach-cloud',
370
+ setupIntent: null,
363
371
  pullCursor: null,
364
372
  lastPullAt: null,
365
373
  lastPushAt: null,
@@ -376,6 +384,7 @@ export function useWorkspaceSyncController(input) {
376
384
  workspacePendingSignatureRef.current = '';
377
385
  workspaceLastPushedTaskIdsRef.current = new Set();
378
386
  workspaceDeletedTaskIdsRef.current = new Set();
387
+ workspaceDeletedTaskWatermarksRef.current = new Map();
379
388
  workspacePullCursorRef.current = null;
380
389
  workspaceSyncStateRef.current = createDefaultWorkspaceSyncState();
381
390
  workspacePushInFlightRef.current = false;
@@ -413,6 +422,7 @@ export function useWorkspaceSyncController(input) {
413
422
  workspacePendingSignatureRef,
414
423
  workspaceLastPushedTaskIdsRef,
415
424
  workspaceDeletedTaskIdsRef,
425
+ workspaceDeletedTaskWatermarksRef,
416
426
  workspacePullCursorRef,
417
427
  clearWorkspaceRetry,
418
428
  scheduleWorkspaceRetry,
@@ -5,6 +5,7 @@ const makeInput = (overrides = {}) => ({
5
5
  currentWorkspaceId: 'ws-test',
6
6
  setWorkspaceCloudSyncEnabled: vi.fn(),
7
7
  setWorkspaceSyncPhase: vi.fn(),
8
+ setWorkspaceSyncSetupIntent: vi.fn(),
8
9
  setWorkspaceLastPullAt: vi.fn(),
9
10
  setWorkspaceLastPushAt: vi.fn(),
10
11
  setWorkspaceLastErrorMessage: vi.fn(),
@@ -0,0 +1,2 @@
1
+ import type { DomainToolExecutor, RegisterMcpTool } from './toolCatalog.js';
2
+ export declare function registerAdminWorkspaceTools(registerTool: RegisterMcpTool, executeTool: DomainToolExecutor): void;