@taskforcehq/taskforce 0.3.306 → 0.3.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.js +9 -1
  38. package/dist/components/views/PlanComparisonPage.test.js +19 -0
  39. package/dist/components/views/PlansPage.js +1 -1
  40. package/dist/components/views/StandaloneLayout.js +256 -69
  41. package/dist/components/views/WidgetView.js +10 -8
  42. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  43. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  44. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  45. package/dist/config/envSchema.js +1 -0
  46. package/dist/core/AiProfileService.d.ts +7 -0
  47. package/dist/core/AiProfileService.js +460 -35
  48. package/dist/core/AiProfiles.test.js +648 -2
  49. package/dist/core/AttachmentLinkService.d.ts +3 -0
  50. package/dist/core/AttachmentLinkService.js +58 -13
  51. package/dist/core/GlobalSettingsService.js +12 -0
  52. package/dist/core/McpTokenService.d.ts +12 -0
  53. package/dist/core/McpTokenService.js +44 -1
  54. package/dist/core/PlanningEntities.test.js +366 -4
  55. package/dist/core/TaskActivityService.d.ts +28 -1
  56. package/dist/core/TaskActivityService.js +227 -46
  57. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  58. package/dist/core/TaskActivitySync.test.js +90 -0
  59. package/dist/core/TaskAuditTimeline.test.js +96 -0
  60. package/dist/core/Taskforce.d.ts +190 -26
  61. package/dist/core/Taskforce.ids.test.js +58 -0
  62. package/dist/core/Taskforce.js +775 -71
  63. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  64. package/dist/core/types.d.ts +7 -0
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  66. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  67. package/dist/hooks/bootstrapTimeouts.js +8 -0
  68. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  69. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  70. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  71. package/dist/hooks/sync/orchestratorShared.js +61 -1
  72. package/dist/hooks/sync/recovery.js +8 -0
  73. package/dist/hooks/sync/recovery.test.js +5 -5
  74. package/dist/hooks/sync/transfers.d.ts +2 -1
  75. package/dist/hooks/sync/transfers.js +36 -9
  76. package/dist/hooks/sync/transfers.test.js +534 -2
  77. package/dist/hooks/useFilterSort.js +13 -13
  78. package/dist/hooks/useFilterSort.test.js +87 -1
  79. package/dist/hooks/useSyncOrchestrator.js +50 -5
  80. package/dist/hooks/useTaskforce.d.ts +85 -50
  81. package/dist/hooks/useTaskforce.js +44 -14
  82. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  83. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  84. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  85. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  86. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  87. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  88. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  89. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  90. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  91. package/dist/mcp/clientIntegrations.d.ts +1 -0
  92. package/dist/mcp/clientIntegrations.js +25 -0
  93. package/dist/mcp/clientIntegrations.test.js +14 -3
  94. package/dist/mcp/clientProfileDefaults.js +3 -3
  95. package/dist/mcp/collaborationRegistrar.js +7 -7
  96. package/dist/mcp/documentAssetRegistrar.js +113 -7
  97. package/dist/mcp/runtime.d.ts +1 -0
  98. package/dist/mcp/runtime.js +969 -122
  99. package/dist/mcp/runtime.test.js +1083 -11
  100. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  101. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  102. package/dist/mcp/toolRegistry.d.ts +56 -0
  103. package/dist/mcp/toolRegistry.js +8 -0
  104. package/dist/mcp/toolRegistry.test.js +4 -0
  105. package/dist/migrations/taskSchemaMigrations.js +21 -0
  106. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  107. package/dist/server/aiProfileAvatarAssets.js +319 -0
  108. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  109. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  110. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/index.test.js +6 -1
  113. package/dist/server/routes/admin.js +187 -9
  114. package/dist/server/routes/annotatedAttachments.js +51 -0
  115. package/dist/server/routes/auth.d.ts +11 -0
  116. package/dist/server/routes/auth.js +189 -27
  117. package/dist/server/routes/authSupport.d.ts +4 -0
  118. package/dist/server/routes/authSupport.js +12 -1
  119. package/dist/server/routes/billing.js +11 -0
  120. package/dist/server/routes/billing.test.js +3 -0
  121. package/dist/server/routes/documents.js +10 -0
  122. package/dist/server/routes/shared.js +0 -10
  123. package/dist/server/routes/sync.integration.test.js +221 -0
  124. package/dist/server/routes/sync.js +13 -3
  125. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  126. package/dist/server/routes/tasks.js +12 -9
  127. package/dist/server/routes.js +87 -8
  128. package/dist/server/routes.test.js +890 -22
  129. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  130. package/dist/shared/aiProfileSurfaceType.js +10 -8
  131. package/dist/storage/objectStorage.d.ts +1 -0
  132. package/dist/storage/objectStorage.js +2 -1
  133. package/dist/storage/objectStorageClient.d.ts +1 -0
  134. package/dist/storage/objectStorageClient.js +41 -8
  135. package/dist/storage/objectStorageClient.test.js +26 -1
  136. package/dist/storage/objectStorageConfig.js +3 -1
  137. package/dist/storage/objectStorageConfig.test.js +13 -1
  138. package/dist/sync/collaborationSyncPayload.js +2 -0
  139. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  140. package/dist/sync/collaborationSyncState.d.ts +3 -3
  141. package/dist/sync/collaborationSyncState.js +19 -12
  142. package/dist/sync/contentSyncPayload.d.ts +5 -1
  143. package/dist/sync/contentSyncPayload.js +9 -0
  144. package/dist/sync/contentSyncPayload.test.js +39 -0
  145. package/dist/sync/contentSyncState.d.ts +5 -1
  146. package/dist/sync/contentSyncState.js +231 -37
  147. package/dist/sync/planningSyncPayload.js +5 -0
  148. package/dist/sync/planningSyncPayload.test.js +24 -0
  149. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  150. package/dist/sync/syncApplyHandlers.js +89 -31
  151. package/dist/sync/syncApplyHandlers.test.js +184 -4
  152. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  153. package/dist/sync/syncResourceAdapters.js +2 -0
  154. package/dist/sync/syncService.d.ts +1 -0
  155. package/dist/sync/syncService.js +13 -1
  156. package/dist/sync/syncService.test.js +39 -0
  157. package/dist/sync/workspacePullFeed.d.ts +4 -0
  158. package/dist/sync/workspacePullFeed.js +129 -70
  159. package/dist/sync/workspacePullFeed.test.js +49 -0
  160. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  161. package/dist/sync/workspaceSyncModel.js +159 -45
  162. package/dist/sync/workspaceSyncModel.test.js +269 -3
  163. package/dist/types.d.ts +33 -2
  164. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  165. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  166. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  167. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  168. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  169. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  170. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  171. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  172. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
  173. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  174. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  175. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  176. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  177. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  178. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  179. package/dist/ui/assets/index-607WPo_X.js +4 -0
  180. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  181. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  182. package/dist/ui/index.html +3 -3
  183. package/dist/ui/product/agent manager.png +0 -0
  184. package/dist/ui/product/agent_manager_02.png +0 -0
  185. package/dist/utils/aiProfileEvents.d.ts +7 -0
  186. package/dist/utils/aiProfileEvents.js +6 -0
  187. package/dist/utils/assignees.d.ts +6 -0
  188. package/dist/utils/assignees.js +8 -1
  189. package/dist/utils/assignees.test.d.ts +1 -0
  190. package/dist/utils/assignees.test.js +25 -0
  191. package/dist/utils/avatarDisplay.d.ts +1 -0
  192. package/dist/utils/avatarDisplay.js +15 -0
  193. package/dist/utils/contextFiles.d.ts +3 -1
  194. package/dist/utils/contextFiles.js +2 -1
  195. package/dist/utils/taskActivity.d.ts +7 -1
  196. package/dist/utils/taskActivity.js +63 -1
  197. package/dist/utils/taskActivity.test.js +23 -1
  198. package/dist/utils/taskSearch.d.ts +22 -0
  199. package/dist/utils/taskSearch.js +87 -0
  200. package/dist/utils/taskSearch.test.d.ts +1 -0
  201. package/dist/utils/taskSearch.test.js +69 -0
  202. package/dist/utils/taskUtils.js +10 -3
  203. package/dist/utils/taskUtils.test.d.ts +1 -0
  204. package/dist/utils/taskUtils.test.js +19 -0
  205. package/package.json +1 -1
  206. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  207. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  208. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  209. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  210. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  211. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  212. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  213. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  214. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  215. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -149,4 +149,98 @@ describe('useTaskforce runtime API routing', () => {
149
149
  ]));
150
150
  expect(taskforceReads.some((url) => url.startsWith('https://performance-app.taskforcehq.ai'))).toBe(false);
151
151
  });
152
+ it('does not stall cloud bootstrap when optional build metadata times out', async () => {
153
+ vi.stubGlobal('fetch', vi.fn((input) => {
154
+ const url = requestUrl(input);
155
+ if (url.includes('/api/taskforce/auth/runtime-config')) {
156
+ return jsonResponse({
157
+ config: {
158
+ runtimeMode: 'cloud',
159
+ workspaceMode: 'multi-cloud',
160
+ authSource: 'cloud',
161
+ workspaceSwitchingEnabled: true
162
+ }
163
+ });
164
+ }
165
+ if (url.includes('/api/taskforce/auth/session')) {
166
+ return jsonResponse({
167
+ authenticated: true,
168
+ authRequiredForApi: true,
169
+ betaAccess: true,
170
+ userId: 'user-1',
171
+ email: 'user@example.com',
172
+ workspaceId: 'workspace-cloud-1'
173
+ });
174
+ }
175
+ if (url.includes('/api/taskforce/workspaces')) {
176
+ return jsonResponse({
177
+ success: true,
178
+ currentWorkspaceId: 'workspace-cloud-1',
179
+ workspaces: [{ id: 'workspace-cloud-1', name: 'Cloud Workspace', role: 'owner' }]
180
+ });
181
+ }
182
+ if (url.includes('/api/taskforce/ui-state?key=app'))
183
+ return jsonResponse({ success: true, state: {} });
184
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
185
+ return jsonResponse({ success: true, state: { version: 2, enabled: false, phase: 'idle', pullCursor: null } });
186
+ }
187
+ if (url.includes('/api/taskforce/config')) {
188
+ return jsonResponse({
189
+ projectName: 'Cloud Project',
190
+ workspaceId: 'workspace-cloud-1',
191
+ runtimeMode: 'cloud',
192
+ authRequiredForApi: true,
193
+ userId: 'user-1',
194
+ setupState: {
195
+ globalSetupState: 'complete',
196
+ workspaceSetupState: 'complete',
197
+ workspaceId: 'workspace-cloud-1'
198
+ }
199
+ });
200
+ }
201
+ if (url.includes('/api/taskforce/version')) {
202
+ return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
203
+ }
204
+ if (url.includes('/api/taskforce/taxonomy-state')) {
205
+ return jsonResponse({
206
+ categories: [{ value: 'general', label: 'General' }],
207
+ types: [{ value: 'feature', label: 'Feature' }],
208
+ priorities: [{ value: 2, label: 'Medium' }],
209
+ taxonomies: []
210
+ });
211
+ }
212
+ if (url.includes('/api/taskforce/workflow-templates'))
213
+ return jsonResponse({ workflows: [] });
214
+ if (url.includes('/api/taskforce/initiative-templates'))
215
+ return jsonResponse({ templates: [] });
216
+ if (url.includes('/api/taskforce/environments'))
217
+ return jsonResponse({ environments: [] });
218
+ if (url.includes('/api/taskforce/workspace/assignee-options'))
219
+ return jsonResponse({ assignees: [] });
220
+ if (url.includes('/api/taskforce/auth/workspace-members'))
221
+ return jsonResponse({ users: [] });
222
+ if (url.includes('/api/taskforce/planning/bootstrap'))
223
+ return jsonResponse({ initiatives: [], workstreams: [], workstreamTaskSummaries: [] });
224
+ if (url.includes('/api/taskforce/tasks'))
225
+ return jsonResponse({ tasks: [] });
226
+ if (url.includes('/api/taskforce/archive'))
227
+ return jsonResponse({ archived: [] });
228
+ if (url.includes('/api/taskforce/data-version'))
229
+ return jsonResponse({ dataVersion: 1 });
230
+ if (url.includes('/api/taskforce/global-settings'))
231
+ return jsonResponse({});
232
+ return jsonResponse({});
233
+ }));
234
+ const { result } = renderHook(() => useTaskforce({
235
+ config: {
236
+ cloudAuthBaseUrl: 'https://performance-app.taskforcehq.ai'
237
+ }
238
+ }), { wrapper });
239
+ await waitFor(() => {
240
+ expect(result.current.runtimeMode).toBe('cloud');
241
+ expect(result.current.bootstrapState.ready).toBe(true);
242
+ expect(result.current.bootstrapState.stalled).toBe(false);
243
+ expect(result.current.bootstrapState.error).toBeNull();
244
+ }, { timeout: 2500 });
245
+ });
152
246
  });
@@ -487,6 +487,105 @@ describe('useTaskforce sync behavior', () => {
487
487
  expect(result.current.workstreams.some((item) => item.id === workstream.id)).toBe(true);
488
488
  }, { timeout: 7000 });
489
489
  }, 10000);
490
+ it('retries a timed-out planning bootstrap without falling back to legacy endpoints during startup', async () => {
491
+ const initiative = {
492
+ id: 'initiative-timeout-1',
493
+ title: 'Timeout initiative',
494
+ description: null,
495
+ createdAt: '2026-03-15T10:00:00.000Z',
496
+ };
497
+ const workstream = {
498
+ id: 'workstream-timeout-1',
499
+ title: 'Timeout workstream',
500
+ description: null,
501
+ initiativeId: initiative.id,
502
+ createdAt: '2026-03-15T10:05:00.000Z',
503
+ };
504
+ let planningBootstrapCalls = 0;
505
+ let initiativesCalls = 0;
506
+ let workstreamsCalls = 0;
507
+ vi.stubGlobal('fetch', vi.fn((url) => {
508
+ if (url.includes('/api/taskforce/auth/runtime-config')) {
509
+ return jsonResponse({ config: { runtimeMode: 'local', workspaceMode: 'single-local', authSource: 'cloud' } });
510
+ }
511
+ if (url.includes('/api/taskforce/auth/session')) {
512
+ return jsonResponse({
513
+ authenticated: false,
514
+ authRequiredForApi: false,
515
+ betaAccess: true,
516
+ userId: 'anonymous',
517
+ email: '',
518
+ workspaceId: 'workspace-local-active'
519
+ });
520
+ }
521
+ if (url.includes('/api/taskforce/ui-state?key=app'))
522
+ return jsonResponse({ success: true, state: {} });
523
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
524
+ return jsonResponse({ success: true, state: { version: 2, enabled: false, phase: 'idle', pullCursor: null } });
525
+ }
526
+ if (url.includes('/api/taskforce/config')) {
527
+ return jsonResponse({
528
+ projectName: 'Test Project',
529
+ workspaceId: 'workspace-local-active',
530
+ runtimeMode: 'local',
531
+ setupState: {
532
+ globalSetupState: 'complete',
533
+ workspaceSetupState: 'complete',
534
+ workspaceId: 'workspace-local-active'
535
+ }
536
+ });
537
+ }
538
+ if (url.includes('/api/taskforce/global-settings'))
539
+ return jsonResponse({});
540
+ if (url.includes('/api/taskforce/categories'))
541
+ return jsonResponse({ categories: [{ value: 'general', label: 'General' }] });
542
+ if (url.includes('/api/taskforce/types'))
543
+ return jsonResponse({ types: [] });
544
+ if (url.includes('/api/taskforce/priorities'))
545
+ return jsonResponse({ priorities: [] });
546
+ if (url.includes('/api/taskforce/approaches'))
547
+ return jsonResponse({ approaches: [] });
548
+ if (url.includes('/api/taskforce/specialists'))
549
+ return jsonResponse({ specialists: [] });
550
+ if (url.includes('/api/taskforce/tasks'))
551
+ return jsonResponse({ tasks: [activeTask] });
552
+ if (url.includes('/api/taskforce/archive'))
553
+ return jsonResponse({ archived: [] });
554
+ if (url.includes('/api/taskforce/planning/bootstrap')) {
555
+ planningBootstrapCalls += 1;
556
+ if (planningBootstrapCalls === 1) {
557
+ return Promise.reject(new DOMException('timed out', 'AbortError'));
558
+ }
559
+ return jsonResponse({
560
+ initiatives: [initiative],
561
+ workstreams: [workstream],
562
+ workstreamTaskSummaries: [],
563
+ });
564
+ }
565
+ if (url.includes('/api/taskforce/initiatives')) {
566
+ initiativesCalls += 1;
567
+ return jsonResponse([initiative]);
568
+ }
569
+ if (url.includes('/api/taskforce/workstreams')) {
570
+ workstreamsCalls += 1;
571
+ return jsonResponse([workstream]);
572
+ }
573
+ if (url.includes('/api/taskforce/data-version'))
574
+ return jsonResponse({ dataVersion: 1 });
575
+ return jsonResponse({});
576
+ }));
577
+ const { result } = renderHook(() => useTaskforce({ config: {} }), { wrapper });
578
+ await waitFor(() => {
579
+ expect(result.current.tasks.some((task) => task.id === activeTask.id)).toBe(true);
580
+ });
581
+ await waitFor(() => {
582
+ expect(result.current.initiatives.some((item) => item.id === initiative.id)).toBe(true);
583
+ expect(result.current.workstreams.some((item) => item.id === workstream.id)).toBe(true);
584
+ }, { timeout: 7000 });
585
+ expect(planningBootstrapCalls).toBeGreaterThanOrEqual(2);
586
+ expect(initiativesCalls).toBe(0);
587
+ expect(workstreamsCalls).toBe(0);
588
+ }, 10000);
490
589
  it('does not block cloud bootstrap readiness on a slow planning bootstrap response', async () => {
491
590
  const delayedPlanningResponseMs = 1500;
492
591
  vi.stubGlobal('fetch', vi.fn((url) => {
@@ -74,6 +74,12 @@ function buildWorkspaceSyncStatePatch(current, patch) {
74
74
  }
75
75
  return next;
76
76
  }
77
+ function buildRemoteWorkspaceSyncStatePatch(state) {
78
+ return {
79
+ ...state,
80
+ pushBaseline: null
81
+ };
82
+ }
77
83
  export function useWorkspaceSyncController(input) {
78
84
  const { currentWorkspaceId, setWorkspaceCloudSyncEnabled, setWorkspaceSyncPhase, setWorkspaceSyncSetupIntent, setWorkspaceLastPullAt, setWorkspaceLastPushAt, setWorkspaceLastErrorMessage } = input;
79
85
  const [workspaceRetryAt, setWorkspaceRetryAt] = useState(null);
@@ -297,7 +303,7 @@ export function useWorkspaceSyncController(input) {
297
303
  const result = await persistRemoteUiState({
298
304
  workspaceId: currentWorkspaceId,
299
305
  stateKey: 'workspace-sync',
300
- patch: next,
306
+ patch: buildRemoteWorkspaceSyncStatePatch(next),
301
307
  throwOnError: true,
302
308
  respectFailureCooldown: false
303
309
  });
@@ -89,6 +89,54 @@ describe('useWorkspaceSyncController', () => {
89
89
  expect(input.setWorkspaceCloudSyncEnabled).toHaveBeenCalledWith(true);
90
90
  expect(input.setWorkspaceSyncPhase).toHaveBeenCalledWith('attach-cloud');
91
91
  });
92
+ it('clears local-only push baselines from remote ui-state patches', async () => {
93
+ const uiStatePosts = [];
94
+ vi.stubGlobal('fetch', vi.fn((url, init) => {
95
+ if (url.includes('/api/taskforce/ui-state?key=workspace-sync')) {
96
+ return Promise.resolve({
97
+ ok: true,
98
+ json: () => Promise.resolve({
99
+ success: true,
100
+ state: {
101
+ version: 2,
102
+ enabled: true,
103
+ phase: 'active',
104
+ pullCursor: 'cursor-1',
105
+ pushBaseline: {
106
+ taskIds: ['task-1'],
107
+ taskWatermarks: [['task-1', 'x'.repeat(1024 * 1024)]],
108
+ initiativeWatermarks: [],
109
+ workstreamWatermarks: [],
110
+ aiProfileWatermarks: [],
111
+ documentWatermarks: [],
112
+ assetWatermarks: [],
113
+ documentReviewSessionWatermarks: [],
114
+ documentReviewCommentWatermarks: [],
115
+ annotatedAttachmentSessionWatermarks: [],
116
+ taskEventWatermarks: [],
117
+ taxonomyStateFingerprint: ''
118
+ }
119
+ }
120
+ })
121
+ });
122
+ }
123
+ if (url.includes('/api/taskforce/ui-state') && String(init?.method || '').toUpperCase() === 'POST') {
124
+ uiStatePosts.push(JSON.parse(String(init?.body || '{}')));
125
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
126
+ }
127
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
128
+ }));
129
+ const { result } = renderHook(() => useWorkspaceSyncController(makeInput()));
130
+ await act(async () => {
131
+ await result.current.loadWorkspaceSyncState();
132
+ await result.current.persistWorkspaceSyncPatch({ lastPullAt: '2026-04-27T20:30:00.000Z' });
133
+ });
134
+ expect(result.current.readWorkspaceSyncStateSnapshot().pushBaseline?.taskWatermarks[0]?.[1]).toHaveLength(1024 * 1024);
135
+ expect(uiStatePosts).toHaveLength(1);
136
+ expect(uiStatePosts[0]?.patch?.lastPullAt).toBe('2026-04-27T20:30:00.000Z');
137
+ expect(uiStatePosts[0]?.patch?.pushBaseline).toBeNull();
138
+ expect(JSON.stringify(uiStatePosts[0])).not.toContain('xxxx');
139
+ });
92
140
  it('persists attach-cloud phase when enabling sync for an existing cloud workspace', async () => {
93
141
  const uiStatePosts = [];
94
142
  vi.stubGlobal('fetch', vi.fn((url, init) => {
@@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react';
2
2
  import { createDefaultAssigneeOptions, createWorkspaceAssigneeOptions, mergeAssigneeOptions } from '../../utils/assignees';
3
3
  import { hasWorkspaceTaxonomyStatePayload, normalizeWorkspaceTaxonomyStatePayload } from '../../shared/taxonomyState.js';
4
4
  import { logBootstrapDebug, readPersistedWorkspaceId } from './workspaceLocalState';
5
+ import { resolveBootstrapRequestTimeoutMs } from '../bootstrapTimeouts';
5
6
  function getBootstrapTimestamp() {
6
7
  return typeof performance !== 'undefined' ? performance.now() : Date.now();
7
8
  }
@@ -135,7 +136,7 @@ export function useTaskforceWorkspaceBootstrap(args) {
135
136
  const abortController = new AbortController();
136
137
  bootstrapConfigAbortRef.current = abortController;
137
138
  try {
138
- const res = await fetchWithTimeout('/api/taskforce/config', undefined, 12000, { signal: abortController.signal });
139
+ const res = await fetchWithTimeout('/api/taskforce/config', undefined, resolveBootstrapRequestTimeoutMs(runtimeMode), { signal: abortController.signal });
139
140
  if (isWorkspaceEpochStale(requestState))
140
141
  return;
141
142
  if (res.status === 401) {
@@ -193,7 +194,7 @@ export function useTaskforceWorkspaceBootstrap(args) {
193
194
  if (bootstrapConfigAbortRef.current === abortController)
194
195
  bootstrapConfigAbortRef.current = null;
195
196
  }
196
- }, [applyBootstrapConfig, bootstrapConfigAbortRef, currentWorkspaceIdRef, fetchBuildInfo, fetchWithTimeout, getWorkspaceRequestState, handleUnauthorized, hasLoadedUiStateRef, isAbortError, isBootstrapTimeoutError, isWorkspaceEpochStale, loadPersistedUiState, markBootstrapPhase, markBootstrapStalled, setConfigLoaded, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
197
+ }, [applyBootstrapConfig, bootstrapConfigAbortRef, currentWorkspaceIdRef, fetchBuildInfo, fetchWithTimeout, getWorkspaceRequestState, handleUnauthorized, hasLoadedUiStateRef, isAbortError, isBootstrapTimeoutError, isWorkspaceEpochStale, loadPersistedUiState, markBootstrapPhase, markBootstrapStalled, runtimeMode, setConfigLoaded, shouldBlockProtectedApiCalls, shouldDeferProtectedApiCalls]);
197
198
  const fetchConfig = useCallback(async (options) => {
198
199
  await fetchBootstrapConfig(options);
199
200
  await fetchReferenceData(options);
@@ -219,7 +220,7 @@ export function useTaskforceWorkspaceBootstrap(args) {
219
220
  const abortController = new AbortController();
220
221
  workspaceListAbortRef.current = abortController;
221
222
  try {
222
- const res = await fetchWithTimeout('/api/taskforce/workspaces', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal });
223
+ const res = await fetchWithTimeout('/api/taskforce/workspaces', { method: 'GET', credentials: 'include' }, resolveBootstrapRequestTimeoutMs(runtimeMode), { signal: abortController.signal });
223
224
  if (isWorkspaceEpochStale(requestState))
224
225
  return { success: false, error: 'Workspace changed while loading workspaces.' };
225
226
  if (res.status === 401) {
@@ -284,9 +285,9 @@ export function useTaskforceWorkspaceBootstrap(args) {
284
285
  setAssigneeOptionsLoaded(false);
285
286
  try {
286
287
  const [assigneeResult, workspaceMembersResult] = await Promise.all([
287
- fetchWithTimeout('/api/taskforce/workspace/assignee-options', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal }),
288
+ fetchWithTimeout('/api/taskforce/workspace/assignee-options', { method: 'GET', credentials: 'include' }, resolveBootstrapRequestTimeoutMs(runtimeMode), { signal: abortController.signal }),
288
289
  (authSessionResolved && isAuthenticated)
289
- ? fetchWithTimeout('/api/taskforce/auth/workspace-members', { method: 'GET', credentials: 'include' }, 12000, { signal: abortController.signal }).catch(() => null)
290
+ ? fetchWithTimeout('/api/taskforce/auth/workspace-members', { method: 'GET', credentials: 'include' }, resolveBootstrapRequestTimeoutMs(runtimeMode), { signal: abortController.signal }).catch(() => null)
290
291
  : Promise.resolve(null)
291
292
  ]);
292
293
  if (isWorkspaceRequestStale(requestState))
@@ -330,7 +331,13 @@ export function useTaskforceWorkspaceBootstrap(args) {
330
331
  icon: String(entry?.icon || 'Bot'),
331
332
  color: String(entry?.color || '#8b5cf6'),
332
333
  kind: 'agent',
333
- avatarUrl: typeof entry?.avatarUrl === 'string' && entry.avatarUrl.trim().length > 0 ? entry.avatarUrl.trim() : null
334
+ avatarUrl: typeof entry?.avatarUrl === 'string' && entry.avatarUrl.trim().length > 0 ? entry.avatarUrl.trim() : null,
335
+ avatarRevision: Number.isFinite(Number(entry?.avatarRevision)) ? Math.max(0, Math.floor(Number(entry.avatarRevision))) : null,
336
+ avatarUpdatedAt: typeof entry?.avatarUpdatedAt === 'string' && entry.avatarUpdatedAt.trim().length > 0 ? entry.avatarUpdatedAt.trim() : null,
337
+ username: typeof entry?.username === 'string' && entry.username.trim().length > 0 ? entry.username.trim() : null,
338
+ archivedAt: typeof entry?.archivedAt === 'string' && entry.archivedAt.trim().length > 0 ? entry.archivedAt.trim() : null,
339
+ createdAt: typeof entry?.createdAt === 'string' && entry.createdAt.trim().length > 0 ? entry.createdAt.trim() : null,
340
+ updatedAt: typeof entry?.updatedAt === 'string' && entry.updatedAt.trim().length > 0 ? entry.updatedAt.trim() : null
334
341
  };
335
342
  if (kindRaw === 'member' || kindRaw === 'human')
336
343
  return {
@@ -400,7 +407,7 @@ export function useTaskforceWorkspaceBootstrap(args) {
400
407
  if (assigneeOptionsAbortRef.current === abortController)
401
408
  assigneeOptionsAbortRef.current = null;
402
409
  }
403
- }, [assigneeOptionsAbortRef, authSessionResolved, authUserAvatarUrl, authUserId, fetchWithTimeout, getWorkspaceRequestState, isAbortError, isAuthenticated, isWorkspaceRequestStale, setAssigneeOptions, setAssigneeOptionsLoaded, setHydratedWorkspaceRole, workspaceSwitchingEnabled]);
410
+ }, [assigneeOptionsAbortRef, authSessionResolved, authUserAvatarUrl, authUserId, fetchWithTimeout, getWorkspaceRequestState, isAbortError, isAuthenticated, isWorkspaceRequestStale, runtimeMode, setAssigneeOptions, setAssigneeOptionsLoaded, setHydratedWorkspaceRole, workspaceSwitchingEnabled]);
404
411
  const switchWorkspace = useCallback(async (workspaceId, options) => {
405
412
  if (!workspaceSwitchingEnabled)
406
413
  return { success: false, error: 'Workspace switching is unavailable in local mode.', code: 'WORKSPACE_SWITCHING_DISABLED' };
@@ -1,4 +1,4 @@
1
- import type { WorkspaceAssetEntry, WorkspaceAssetKind, WorkspaceAssetLinkRole, WorkspaceAssetScanStatus, WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
1
+ import type { WorkspaceAssetEntry, WorkspaceAssetKind, WorkspaceAssetLinkRole, WorkspaceAssetLinkTargetType, WorkspaceAssetScanStatus, WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
2
2
  import type { McpTaskAttachmentDescriptor, TaskAttachmentRecord } from './taskAttachmentTypes.js';
3
3
  export interface CanonicalAssetHelpersDeps {
4
4
  activeWorkspaceId: string;
@@ -9,6 +9,7 @@ export interface CanonicalAssetHelpersDeps {
9
9
  getObject(storageKey: string): Promise<{
10
10
  body: Buffer;
11
11
  } | null>;
12
+ putObject?(storageKey: string, body: Buffer, contentType: string): Promise<void>;
12
13
  } | null;
13
14
  sanitizeFilenameForPath: (filenameRaw: unknown, fallback?: string) => string;
14
15
  sanitizeDisplayName: (valueRaw: unknown, fallback: string) => string;
@@ -31,7 +32,7 @@ export declare function createCanonicalAssetHelpers(deps: CanonicalAssetHelpersD
31
32
  getAttachmentAssetRecord: (attachment: TaskAttachmentRecord) => WorkspaceAssetEntry | null;
32
33
  buildAttachmentDownloadUrl: (attachment: McpTaskAttachmentDescriptor) => string;
33
34
  buildDocumentDownloadUrl: (asset: WorkspaceAssetEntry) => string;
34
- readCanonicalAttachmentBuffer: (taskId: string, attachment: McpTaskAttachmentDescriptor) => Promise<Buffer>;
35
+ readCanonicalAttachmentBuffer: (taskId: string, attachment: McpTaskAttachmentDescriptor, targetLabel?: string) => Promise<Buffer>;
35
36
  readCanonicalDocumentBuffer: (asset: WorkspaceAssetEntry) => Promise<Buffer>;
36
37
  toTaskAttachmentRecord: (attachment: string | TaskAttachmentRecord) => TaskAttachmentRecord;
37
38
  resolveAttachmentLogicalFilename: (attachmentInput: string | TaskAttachmentRecord, assetOverride?: ReturnType<(attachment: TaskAttachmentRecord) => WorkspaceAssetEntry | null> | null) => string;
@@ -47,7 +48,7 @@ export declare function createCanonicalAssetHelpers(deps: CanonicalAssetHelpersD
47
48
  absolutePath: string;
48
49
  logicalFilename: string;
49
50
  };
50
- ensureCanonicalTaskAttachment: (relativePathRaw: string, taskIdRaw: string, captionRaw: string | undefined, timestampRaw: string | undefined, role: WorkspaceAssetLinkRole, options?: {
51
+ ensureCanonicalAttachment: (relativePathRaw: string, targetTypeRaw: WorkspaceAssetLinkTargetType, targetIdRaw: string, captionRaw: string | undefined, timestampRaw: string | undefined, role: WorkspaceAssetLinkRole, options?: {
51
52
  originalFilename?: string;
52
53
  logicalName?: string;
53
54
  assetId?: string;
@@ -57,7 +58,18 @@ export declare function createCanonicalAssetHelpers(deps: CanonicalAssetHelpersD
57
58
  scanStatus?: WorkspaceAssetScanStatus;
58
59
  scanEngine?: string | null;
59
60
  scanDetails?: string | null;
60
- }) => WorkspaceAssetEntry | null;
61
+ }) => Promise<WorkspaceAssetEntry | null>;
62
+ ensureCanonicalTaskAttachment: (relativePathRaw: string, taskIdRaw: string, captionRaw: string | undefined, timestampRaw: string | undefined, role: WorkspaceAssetLinkRole, options?: Parameters<(relativePathRaw: string, targetTypeRaw: WorkspaceAssetLinkTargetType, targetIdRaw: string, captionRaw: string | undefined, timestampRaw: string | undefined, role: WorkspaceAssetLinkRole, options?: {
63
+ originalFilename?: string;
64
+ logicalName?: string;
65
+ assetId?: string;
66
+ documentId?: string | null;
67
+ createdAt?: string;
68
+ createdByUserId?: string | null;
69
+ scanStatus?: WorkspaceAssetScanStatus;
70
+ scanEngine?: string | null;
71
+ scanDetails?: string | null;
72
+ }) => Promise<WorkspaceAssetEntry | null>>[6]) => Promise<WorkspaceAssetEntry | null>;
61
73
  buildCanonicalWorkspaceAssetRelativePath: (params: {
62
74
  workspaceIdRaw: unknown;
63
75
  kind: WorkspaceAssetKind;
@@ -65,13 +65,13 @@ export function createCanonicalAssetHelpers(deps) {
65
65
  ];
66
66
  return parts.join(', ');
67
67
  };
68
- const readCanonicalAttachmentBuffer = async (taskId, attachment) => {
68
+ const readCanonicalAttachmentBuffer = async (taskId, attachment, targetLabel = 'task') => {
69
69
  const assetId = String(attachment.assetId || '').trim();
70
70
  const asset = assetId && deps.workspaceAssetStore
71
71
  ? deps.workspaceAssetStore.get(assetId, deps.activeWorkspaceId)
72
72
  : null;
73
73
  if (!asset || asset.deletedAt) {
74
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
74
+ throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
75
75
  }
76
76
  if (asset.scanStatus === 'infected') {
77
77
  throw new Error('Attachment is quarantined');
@@ -93,16 +93,16 @@ export function createCanonicalAssetHelpers(deps) {
93
93
  throw new Error(`Attachment read failed from R2 (${attachmentContext}): ${message || 'Unknown object storage error'}`);
94
94
  }
95
95
  if (!object) {
96
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
96
+ throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
97
97
  }
98
98
  return object.body;
99
99
  }
100
100
  const resolvedPath = path.resolve(deps.basePath, asset.storageKey);
101
101
  if (!resolvedPath.startsWith(path.resolve(deps.basePath))) {
102
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
102
+ throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
103
103
  }
104
104
  if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
105
- throw new Error(`Attachment ${attachment.attachmentId} not found on task ${taskId}`);
105
+ throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
106
106
  }
107
107
  return fs.readFileSync(resolvedPath);
108
108
  };
@@ -189,12 +189,13 @@ export function createCanonicalAssetHelpers(deps) {
189
189
  }
190
190
  return null;
191
191
  };
192
- const ensureCanonicalTaskAttachment = (relativePathRaw, taskIdRaw, captionRaw, timestampRaw, role, options) => {
192
+ const ensureCanonicalAttachment = async (relativePathRaw, targetTypeRaw, targetIdRaw, captionRaw, timestampRaw, role, options) => {
193
193
  if (!deps.workspaceAssetStore)
194
194
  return null;
195
195
  const relativePath = String(relativePathRaw || '').trim();
196
- const taskId = String(taskIdRaw || '').trim();
197
- if (!relativePath || !taskId)
196
+ const targetType = String(targetTypeRaw || 'task').trim();
197
+ const targetId = String(targetIdRaw || '').trim();
198
+ if (!relativePath || !targetId)
198
199
  return null;
199
200
  const absolutePath = path.resolve(deps.projectRoot, relativePath);
200
201
  if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile())
@@ -206,6 +207,10 @@ export function createCanonicalAssetHelpers(deps) {
206
207
  const kind = deps.inferAssetKind(relativePath, mimeType);
207
208
  const timestamp = String(timestampRaw || new Date().toISOString()).trim() || new Date().toISOString();
208
209
  const existing = deps.workspaceAssetStore.getByStorageKey(storageKey, deps.activeWorkspaceId);
210
+ const shouldPersistToObjectStorage = typeof deps.objectStorageClient?.putObject === 'function';
211
+ if (shouldPersistToObjectStorage) {
212
+ await deps.objectStorageClient.putObject(storageKey, buffer, mimeType || 'application/octet-stream');
213
+ }
209
214
  const originalFilename = deps.sanitizeFilenameForPath(options?.originalFilename || existing?.originalFilename || path.basename(relativePath));
210
215
  const logicalName = deps.sanitizeDisplayName(options?.logicalName || existing?.logicalName || originalFilename, originalFilename);
211
216
  const asset = deps.workspaceAssetStore.upsertAsset({
@@ -215,7 +220,7 @@ export function createCanonicalAssetHelpers(deps) {
215
220
  logicalName,
216
221
  originalFilename,
217
222
  mimeType,
218
- storageProvider: 'local',
223
+ storageProvider: shouldPersistToObjectStorage ? 'r2' : 'local',
219
224
  storageKey,
220
225
  contentSha256: createHash('sha256').update(buffer).digest('hex'),
221
226
  sizeBytes: stats.size,
@@ -234,7 +239,8 @@ export function createCanonicalAssetHelpers(deps) {
234
239
  deps.workspaceAssetStore.upsertLink({
235
240
  workspaceId: deps.activeWorkspaceId,
236
241
  assetId: asset.assetId,
237
- taskId,
242
+ targetType,
243
+ targetId,
238
244
  role,
239
245
  displayName: kind === 'document' ? null : (captionRaw || null),
240
246
  updatedAt: timestamp,
@@ -242,6 +248,7 @@ export function createCanonicalAssetHelpers(deps) {
242
248
  });
243
249
  return asset;
244
250
  };
251
+ const ensureCanonicalTaskAttachment = (relativePathRaw, taskIdRaw, captionRaw, timestampRaw, role, options) => ensureCanonicalAttachment(relativePathRaw, 'task', taskIdRaw, captionRaw, timestampRaw, role, options);
245
252
  return {
246
253
  normalizeAttachmentStorageKey,
247
254
  buildSyntheticAttachmentId,
@@ -253,6 +260,7 @@ export function createCanonicalAssetHelpers(deps) {
253
260
  toTaskAttachmentRecord,
254
261
  resolveAttachmentLogicalFilename,
255
262
  resolveCanonicalOverwriteTarget,
263
+ ensureCanonicalAttachment,
256
264
  ensureCanonicalTaskAttachment,
257
265
  buildCanonicalWorkspaceAssetRelativePath: deps.buildCanonicalWorkspaceAssetRelativePath,
258
266
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { TaskforceCore } from '../core/Taskforce';
6
+ import { createCanonicalAssetHelpers } from './canonicalAssetHelpers';
7
+ function makeCore() {
8
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-mcp-canonical-assets-'));
9
+ const core = new TaskforceCore({
10
+ projectRoot,
11
+ storagePath: path.join(projectRoot, '.taskforce')
12
+ });
13
+ return {
14
+ projectRoot,
15
+ core,
16
+ cleanup: () => fs.rmSync(projectRoot, { recursive: true, force: true })
17
+ };
18
+ }
19
+ describe('MCP canonical asset helpers', () => {
20
+ it('uploads canonical attachments to object storage before recording durable cloud metadata', async () => {
21
+ const { projectRoot, core, cleanup } = makeCore();
22
+ try {
23
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
24
+ const task = core.createTask({ title: 'Object-backed attachment' }, { workspaceId: 'workspace-1' });
25
+ const relativePath = '.taskforce/workspaces/workspace-1/assets/documents/asset-test-note.md';
26
+ const absolutePath = path.join(projectRoot, relativePath);
27
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
28
+ fs.writeFileSync(absolutePath, '# Stored in object storage\n', 'utf8');
29
+ const putObject = vi.fn(async () => { });
30
+ const helpers = createCanonicalAssetHelpers({
31
+ activeWorkspaceId: 'workspace-1',
32
+ projectRoot,
33
+ basePath: core.getPaths().basePath,
34
+ workspaceAssetStore: core.getWorkspaceAssetStore(),
35
+ objectStorageClient: {
36
+ getObject: vi.fn(async () => null),
37
+ putObject,
38
+ },
39
+ sanitizeFilenameForPath: (value, fallback = 'file.txt') => String(value || fallback).trim() || fallback,
40
+ sanitizeDisplayName: (value, fallback) => String(value || fallback).trim() || fallback,
41
+ toPosixPath: (value) => value.replace(/\\/g, '/'),
42
+ inferMimeTypeFromPath: (value) => value.endsWith('.md') ? 'text/markdown' : 'application/octet-stream',
43
+ inferAssetKind: (value, mimeType) => (value.endsWith('.md') || mimeType === 'text/markdown' ? 'document' : 'file'),
44
+ buildCanonicalWorkspaceAssetRelativePath: () => ({
45
+ assetId: 'asset-test-note',
46
+ relativePath: 'workspaces/workspace-1/assets/documents/asset-test-note.md'
47
+ })
48
+ });
49
+ const asset = await helpers.ensureCanonicalTaskAttachment(relativePath, task.id, 'note.md', '2026-04-27T01:00:00.000Z', 'attachment', { originalFilename: 'note.md', logicalName: 'note.md' });
50
+ expect(putObject).toHaveBeenCalledWith('workspaces/workspace-1/assets/documents/asset-test-note.md', Buffer.from('# Stored in object storage\n', 'utf8'), 'text/markdown');
51
+ expect(asset?.storageProvider).toBe('r2');
52
+ expect(core.getWorkspaceAssetStore().get(asset.assetId, 'workspace-1')?.storageProvider).toBe('r2');
53
+ expect(core.getWorkspaceAssetStore().listLinksForTask(task.id, 'workspace-1')).toEqual(expect.arrayContaining([
54
+ expect.objectContaining({
55
+ assetId: asset?.assetId,
56
+ targetType: 'task',
57
+ role: 'attachment',
58
+ deletedAt: null,
59
+ })
60
+ ]));
61
+ }
62
+ finally {
63
+ core.close();
64
+ cleanup();
65
+ }
66
+ });
67
+ });
@@ -31,6 +31,7 @@ export interface CloudMcpClientIntegration {
31
31
  serverConfig?: unknown;
32
32
  renderedText?: string;
33
33
  }
34
+ export declare function buildLocalAiProfileEnv(clientId: McpClientId): Record<string, string>;
34
35
  export declare const MCP_CLIENT_OPTIONS: McpClientOption[];
35
36
  export declare function sanitizeMcpId(value: string, fallback: string): string;
36
37
  export declare function buildCloudMcpServerId(workspaceName?: string | null, projectName?: string | null): string;
@@ -1,4 +1,29 @@
1
1
  import { buildAiProfileBootstrapHeaders } from './aiProfileBootstrap.js';
2
+ const LOCAL_AI_PROFILE_DEFAULTS = {
3
+ general: { name: 'Taskforce MCP Agent', icon: 'Bot', role: 'AI collaborator', provider: 'MCP client', model: 'Local MCP', surfaceType: 'agent' },
4
+ cursor: { name: 'Cursor', icon: 'Bot', role: 'IDE coding assistant', provider: 'Cursor', model: 'Cursor', surfaceType: 'ide' },
5
+ cline: { name: 'Cline', icon: 'Bot', role: 'IDE coding assistant', provider: 'Cline', model: 'Cline', surfaceType: 'ide' },
6
+ vscode: { name: 'VS Code Copilot', icon: 'Bot', role: 'IDE coding assistant', provider: 'Microsoft', model: 'Copilot', surfaceType: 'ide' },
7
+ windsurf: { name: 'Windsurf', icon: 'Bot', role: 'IDE coding assistant', provider: 'Windsurf', model: 'Windsurf', surfaceType: 'ide' },
8
+ antigravity: { name: 'Antigravity', icon: 'Bot', role: 'Coding agent', provider: 'Google', model: 'Antigravity', surfaceType: 'coding_tool' },
9
+ openclaw: { name: 'OpenClaw', icon: 'Bot', role: 'AI agent', provider: 'OpenClaw', model: 'OpenClaw', surfaceType: 'agent' },
10
+ codex: { name: 'Codex', icon: 'Bot', role: 'Coding agent', provider: 'OpenAI', model: 'Codex', surfaceType: 'cli' },
11
+ 'claude-code': { name: 'Claude Code', icon: 'Bot', role: 'Coding agent', provider: 'Anthropic', model: 'Claude Code', surfaceType: 'cli' },
12
+ 'gemini-cli': { name: 'Gemini CLI', icon: 'Bot', role: 'Coding agent', provider: 'Google', model: 'Gemini CLI', surfaceType: 'cli' },
13
+ 'claude-desktop': { name: 'Claude Desktop', icon: 'Bot', role: 'Desktop AI collaborator', provider: 'Anthropic', model: 'Claude Desktop', surfaceType: 'chat_app' },
14
+ 'chatgpt-desktop': { name: 'ChatGPT Desktop', icon: 'Bot', role: 'Desktop AI collaborator', provider: 'OpenAI', model: 'ChatGPT Desktop', surfaceType: 'chat_app' },
15
+ };
16
+ export function buildLocalAiProfileEnv(clientId) {
17
+ const defaults = LOCAL_AI_PROFILE_DEFAULTS[clientId] || LOCAL_AI_PROFILE_DEFAULTS.general;
18
+ return {
19
+ TASKFORCE_AI_NAME: defaults.name,
20
+ TASKFORCE_AI_ICON: defaults.icon,
21
+ TASKFORCE_AI_ROLE: defaults.role,
22
+ TASKFORCE_AI_PROVIDER: defaults.provider,
23
+ TASKFORCE_AI_MODEL: defaults.model,
24
+ TASKFORCE_AI_SURFACE_TYPE: defaults.surfaceType,
25
+ };
26
+ }
2
27
  export const MCP_CLIENT_OPTIONS = [
3
28
  { id: 'general', label: 'General' },
4
29
  { id: 'antigravity', label: 'Antigravity' },