@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.
- package/README.md +0 -2
- package/dist/Taskforce.module.css +317 -27
- package/dist/TaskforceCore.js +1 -0
- package/dist/TaskforceCore.test.js +178 -0
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.js +15 -3
- package/dist/components/features/AgentsModule.d.ts +5 -1
- package/dist/components/features/AgentsModule.js +142 -39
- package/dist/components/features/AgentsModule.test.js +260 -11
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +3 -1
- package/dist/components/features/DocumentViewer.js +6 -5
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +20 -3
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +6 -2
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +14 -64
- package/dist/components/task/TaskKanban.js +0 -8
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.js +9 -1
- package/dist/components/views/PlanComparisonPage.test.js +19 -0
- package/dist/components/views/PlansPage.js +1 -1
- package/dist/components/views/StandaloneLayout.js +256 -69
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
- package/dist/components/views/panels/PlanningDrawer.js +259 -51
- package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
- package/dist/config/envSchema.js +1 -0
- package/dist/core/AiProfileService.d.ts +7 -0
- package/dist/core/AiProfileService.js +460 -35
- package/dist/core/AiProfiles.test.js +648 -2
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +58 -13
- package/dist/core/GlobalSettingsService.js +12 -0
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanningEntities.test.js +366 -4
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +190 -26
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +775 -71
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
- package/dist/hooks/sync/orchestratorShared.js +61 -1
- package/dist/hooks/sync/recovery.js +8 -0
- package/dist/hooks/sync/recovery.test.js +5 -5
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +36 -9
- package/dist/hooks/sync/transfers.test.js +534 -2
- package/dist/hooks/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.js +50 -5
- package/dist/hooks/useTaskforce.d.ts +85 -50
- package/dist/hooks/useTaskforce.js +44 -14
- package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
- package/dist/hooks/useWorkspaceSyncController.js +7 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +113 -7
- package/dist/mcp/runtime.d.ts +1 -0
- package/dist/mcp/runtime.js +969 -122
- package/dist/mcp/runtime.test.js +1083 -11
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.js +21 -0
- package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
- package/dist/server/index.js +4 -0
- package/dist/server/index.test.js +6 -1
- package/dist/server/routes/admin.js +187 -9
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +11 -0
- package/dist/server/routes/auth.js +189 -27
- package/dist/server/routes/authSupport.d.ts +4 -0
- package/dist/server/routes/authSupport.js +12 -1
- package/dist/server/routes/billing.js +11 -0
- package/dist/server/routes/billing.test.js +3 -0
- package/dist/server/routes/documents.js +10 -0
- package/dist/server/routes/shared.js +0 -10
- package/dist/server/routes/sync.integration.test.js +221 -0
- package/dist/server/routes/sync.js +13 -3
- package/dist/server/routes/syncSnapshotRoutes.js +2 -0
- package/dist/server/routes/tasks.js +12 -9
- package/dist/server/routes.js +87 -8
- package/dist/server/routes.test.js +890 -22
- package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- package/dist/storage/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- package/dist/sync/collaborationSyncPayload.js +2 -0
- package/dist/sync/collaborationSyncPayload.test.js +1 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +5 -1
- package/dist/sync/contentSyncState.js +231 -37
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +4 -4
- package/dist/sync/syncApplyHandlers.js +89 -31
- package/dist/sync/syncApplyHandlers.test.js +184 -4
- package/dist/sync/syncResourceAdapters.d.ts +2 -0
- package/dist/sync/syncResourceAdapters.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +13 -1
- package/dist/sync/syncService.test.js +39 -0
- package/dist/sync/workspacePullFeed.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +129 -70
- package/dist/sync/workspacePullFeed.test.js +49 -0
- package/dist/sync/workspaceSyncModel.d.ts +28 -6
- package/dist/sync/workspaceSyncModel.js +159 -45
- package/dist/sync/workspaceSyncModel.test.js +269 -3
- package/dist/types.d.ts +33 -2
- package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
- package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
- package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
- package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
- package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
- package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
- package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
- package/dist/ui/assets/index-607WPo_X.js +4 -0
- package/dist/ui/assets/index-CYyodXsg.css +1 -0
- package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +6 -0
- package/dist/utils/assignees.js +8 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- package/dist/utils/taskActivity.d.ts +7 -1
- package/dist/utils/taskActivity.js +63 -1
- package/dist/utils/taskActivity.test.js +23 -1
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- package/package.json +1 -1
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
- package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
- package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
- package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
- 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,
|
|
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' },
|
|
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' },
|
|
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' },
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
197
|
-
|
|
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
|
-
|
|
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' },
|