@taskforcehq/taskforce 0.3.170 → 0.3.301
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 +71 -21
- package/dist/Taskforce.module.css +3394 -5981
- package/dist/TaskforceCore.CategoryFallback.test.js +5 -2
- package/dist/TaskforceCore.filter-persistence.test.js +402 -0
- package/dist/TaskforceCore.js +388 -71
- package/dist/TaskforceCore.routing.test.js +311 -0
- package/dist/TaskforceCore.test.js +3344 -615
- package/dist/TaskforceWidget.js +3 -2
- package/dist/__tests__/localizationCatalogs.test.js +48 -31
- package/dist/__tests__/workflowSimulation.test.js +2 -2
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.d.ts +60 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.js +324 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.test.js +278 -0
- package/dist/annotatedAttachments/payload.d.ts +2 -0
- package/dist/annotatedAttachments/payload.js +54 -0
- package/dist/annotatedAttachments/payload.test.js +67 -0
- package/dist/annotatedAttachments/service.d.ts +49 -0
- package/dist/annotatedAttachments/service.js +121 -0
- package/dist/annotatedAttachments/types.d.ts +86 -0
- package/dist/cli.js +74 -25
- package/dist/compat/deploymentCompat.d.ts +12 -0
- package/dist/compat/deploymentCompat.js +43 -0
- package/dist/compat/workspaceSyncCompat.d.ts +6 -0
- package/dist/compat/workspaceSyncCompat.js +79 -0
- package/dist/compat/workspaceSyncCompat.test.js +87 -0
- package/dist/components/features/AgentsModule.d.ts +5 -0
- package/dist/components/features/AgentsModule.js +131 -0
- package/dist/components/features/AgentsModule.test.js +102 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +24 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +1942 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +2831 -0
- package/dist/components/features/DocumentGenerateModal.js +8 -16
- package/dist/components/features/DocumentIndex.d.ts +3 -4
- package/dist/components/features/DocumentIndex.js +22 -10
- package/dist/components/features/DocumentViewer.d.ts +6 -2
- package/dist/components/features/DocumentViewer.js +1000 -18
- package/dist/components/features/DocumentWorkspace.d.ts +40 -2
- package/dist/components/features/DocumentWorkspace.js +96 -16
- package/dist/components/features/DocumentWorkspace.test.js +1202 -0
- package/dist/components/features/InitiativesModule.d.ts +22 -0
- package/dist/components/features/InitiativesModule.js +33 -0
- package/dist/components/features/TaskSettings.d.ts +41 -32
- package/dist/components/features/TaskSettings.js +798 -313
- package/dist/components/features/TaskSettings.test.js +1221 -47
- package/dist/components/features/WorkflowsModule.d.ts +27 -0
- package/dist/components/features/WorkflowsModule.js +21 -0
- package/dist/components/features/documentWorkspaceFilters.d.ts +10 -0
- package/dist/components/features/documentWorkspaceFilters.js +11 -0
- package/dist/components/features/settings/CategoryManager.d.ts +2 -0
- package/dist/components/features/settings/CategoryManager.js +15 -8
- package/dist/components/features/settings/PriorityManager.d.ts +18 -0
- package/dist/components/features/settings/PriorityManager.js +51 -0
- package/dist/components/features/settings/TaxonomyEditor.d.ts +2 -1
- package/dist/components/features/settings/TaxonomyEditor.js +41 -18
- package/dist/components/features/settings/TaxonomyLibraryManager.d.ts +26 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.js +301 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.test.js +141 -0
- package/dist/components/features/settings/TaxonomyManager.d.ts +35 -12
- package/dist/components/features/settings/TaxonomyManager.js +59 -27
- package/dist/components/features/settings/TypeManager.d.ts +11 -0
- package/dist/components/features/settings/TypeManager.js +48 -11
- package/dist/components/features/settings/{WorkflowEditorPrototype.d.ts → WorkflowEditor.d.ts} +2 -2
- package/dist/components/features/settings/{WorkflowEditorPrototype.js → WorkflowEditor.js} +3 -3
- package/dist/components/task/TaskActionHeader.d.ts +12 -2
- package/dist/components/task/TaskActionHeader.js +64 -8
- package/dist/components/task/TaskActionHeader.test.js +73 -0
- package/dist/components/task/TaskCard.d.ts +14 -16
- package/dist/components/task/TaskCard.js +144 -174
- package/dist/components/task/TaskCard.test.js +416 -64
- package/dist/components/task/TaskContextUpload.d.ts +6 -5
- package/dist/components/task/TaskContextUpload.js +199 -64
- package/dist/components/task/TaskContextUpload.test.js +331 -14
- package/dist/components/task/TaskForm.d.ts +23 -29
- package/dist/components/task/TaskForm.js +348 -169
- package/dist/components/task/TaskForm.test.js +419 -23
- package/dist/components/task/TaskKanban.d.ts +20 -21
- package/dist/components/task/TaskKanban.js +176 -316
- package/dist/components/task/TaskKanban.planning-drop.test.js +93 -0
- package/dist/components/task/TaskList.d.ts +22 -11
- package/dist/components/task/TaskList.js +98 -12
- package/dist/components/task/TaskList.test.js +206 -7
- package/dist/components/task/TaskReferenceBadge.d.ts +12 -0
- package/dist/components/task/TaskReferenceBadge.js +5 -0
- package/dist/components/task/TaskStatusActions.d.ts +12 -0
- package/dist/components/task/TaskStatusActions.js +18 -0
- package/dist/components/task/TaskStatusActions.test.js +45 -0
- package/dist/components/ui/ImageReferenceBadge.d.ts +12 -0
- package/dist/components/ui/ImageReferenceBadge.js +5 -0
- package/dist/components/ui/LevelSelector.d.ts +2 -1
- package/dist/components/ui/LevelSelector.js +2 -2
- package/dist/components/ui/Markdown.d.ts +1 -2
- package/dist/components/ui/Markdown.js +2 -13
- package/dist/components/ui/Modal.d.ts +3 -2
- package/dist/components/ui/Modal.js +4 -3
- package/dist/components/ui/MultiSelectFilter.d.ts +3 -1
- package/dist/components/ui/MultiSelectFilter.js +2 -2
- package/dist/components/ui/ReferenceBadgeButton.d.ts +13 -0
- package/dist/components/ui/ReferenceBadgeButton.js +6 -0
- package/dist/components/ui/TaxonomyDropdown.d.ts +7 -1
- package/dist/components/ui/TaxonomyDropdown.js +58 -23
- package/dist/components/ui/TopNoticeLayer.d.ts +7 -0
- package/dist/components/ui/TopNoticeLayer.js +14 -0
- package/dist/components/ui/TopNoticeLayer.test.js +25 -0
- package/dist/components/views/AppShellHeader.d.ts +13 -0
- package/dist/components/views/AppShellHeader.js +19 -0
- package/dist/components/views/AppShellHeader.test.js +21 -0
- package/dist/components/views/PlanComparisonPage.d.ts +73 -0
- package/dist/components/views/PlanComparisonPage.js +196 -0
- package/dist/components/views/PlanComparisonPage.test.js +251 -0
- package/dist/components/views/PlansPage.d.ts +14 -0
- package/dist/components/views/PlansPage.js +355 -0
- package/dist/components/views/StandaloneLayout.d.ts +2 -2
- package/dist/components/views/StandaloneLayout.js +2227 -1188
- package/dist/components/views/WidgetView.d.ts +2 -2
- package/dist/components/views/WidgetView.js +149 -43
- package/dist/components/views/chrome/WorkspaceChromeSections.d.ts +57 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.js +38 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.test.js +54 -0
- package/dist/components/views/chrome/workspaceChrome.d.ts +31 -0
- package/dist/components/views/chrome/workspaceChrome.js +108 -0
- package/dist/components/views/chrome/workspaceChrome.test.js +52 -0
- package/dist/components/views/panels/DocumentFilterToolbar.d.ts +10 -0
- package/dist/components/views/panels/DocumentFilterToolbar.js +7 -0
- package/dist/components/views/panels/DocumentFilterToolbar.test.js +19 -0
- package/dist/components/views/panels/FilterToolbar.d.ts +68 -0
- package/dist/components/views/panels/FilterToolbar.js +21 -0
- package/dist/components/views/panels/FilterToolbar.test.d.ts +1 -0
- package/dist/components/views/panels/FilterToolbar.test.js +45 -0
- package/dist/components/views/panels/FilterToolbarControls.d.ts +47 -0
- package/dist/components/views/panels/FilterToolbarControls.js +61 -0
- package/dist/components/views/panels/PlanningDrawer.d.ts +115 -0
- package/dist/components/views/panels/PlanningDrawer.js +220 -0
- package/dist/components/views/panels/ScheduleSidebar.d.ts +52 -0
- package/dist/components/views/panels/ScheduleSidebar.js +127 -0
- package/dist/components/views/panels/scheduleUtils.d.ts +14 -0
- package/dist/components/views/panels/scheduleUtils.js +79 -0
- package/dist/components/views/teamManagementAccess.d.ts +6 -0
- package/dist/components/views/teamManagementAccess.js +18 -0
- package/dist/components/views/teamManagementAccess.test.d.ts +1 -0
- package/dist/components/views/teamManagementAccess.test.js +44 -0
- package/dist/config/clientDeployment.d.ts +2 -0
- package/dist/config/clientDeployment.js +13 -0
- package/dist/config/cloudEnvironment.d.ts +15 -0
- package/dist/config/cloudEnvironment.js +60 -0
- package/dist/config/deployment.d.ts +20 -0
- package/dist/config/deployment.js +67 -0
- package/dist/config/deployment.test.d.ts +1 -0
- package/dist/config/deployment.test.js +88 -0
- package/dist/config/envSchema.js +36 -8
- package/dist/config/envSchema.test.js +28 -0
- package/dist/core/AiProfileService.d.ts +66 -0
- package/dist/core/AiProfileService.js +320 -0
- package/dist/core/AiProfileService.test.d.ts +1 -0
- package/dist/core/AiProfileService.test.js +164 -0
- package/dist/core/AiProfiles.test.d.ts +1 -0
- package/dist/core/AiProfiles.test.js +449 -0
- package/dist/core/AttachmentLinkService.d.ts +15 -0
- package/dist/core/AttachmentLinkService.js +157 -0
- package/dist/core/AuthTokenService.d.ts +44 -0
- package/dist/core/AuthTokenService.js +207 -0
- package/dist/core/CreatedBy.test.js +134 -6
- package/dist/core/DeletedTaskLifecycleService.d.ts +33 -0
- package/dist/core/DeletedTaskLifecycleService.js +158 -0
- package/dist/core/DeletedTaskTrash.test.d.ts +1 -0
- package/dist/core/DeletedTaskTrash.test.js +104 -0
- package/dist/core/EntitlementsPolicy.test.js +78 -5
- package/dist/core/GlobalSettingsService.d.ts +16 -0
- package/dist/core/GlobalSettingsService.js +370 -0
- package/dist/core/JsonBackupRuntime.test.d.ts +1 -0
- package/dist/core/JsonBackupRuntime.test.js +155 -0
- package/dist/core/McpTokenService.d.ts +118 -0
- package/dist/core/McpTokenService.js +921 -0
- package/dist/core/PlanEntitlementService.d.ts +201 -0
- package/dist/core/PlanEntitlementService.js +1442 -0
- package/dist/core/PlanVersionPolicy.test.d.ts +1 -0
- package/dist/core/PlanVersionPolicy.test.js +178 -0
- package/dist/core/PlanningEntities.test.d.ts +1 -0
- package/dist/core/PlanningEntities.test.js +95 -0
- package/dist/core/ScheduleFields.test.js +15 -19
- package/dist/core/SignupMonetizationSettings.test.d.ts +1 -0
- package/dist/core/SignupMonetizationSettings.test.js +159 -0
- package/dist/core/Stats.test.d.ts +1 -0
- package/dist/core/Stats.test.js +36 -0
- package/dist/core/SyncReconciliationService.d.ts +93 -0
- package/dist/core/SyncReconciliationService.js +406 -0
- package/dist/core/SystemAdmin.test.js +578 -13
- package/dist/core/TaskActivityService.d.ts +21 -0
- package/dist/core/TaskActivityService.js +130 -0
- package/dist/core/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/TaskAttachmentsCanonical.test.js +306 -0
- package/dist/core/TaskAuditTimeline.test.d.ts +1 -0
- package/dist/core/TaskAuditTimeline.test.js +132 -0
- package/dist/core/TaskTaxonomyValidation.test.js +338 -15
- package/dist/core/Taskforce.d.ts +930 -183
- package/dist/core/Taskforce.ids.test.js +252 -0
- package/dist/core/Taskforce.js +3848 -2532
- package/dist/core/Taskforce.mcpAuth.test.d.ts +1 -0
- package/dist/core/Taskforce.mcpAuth.test.js +343 -0
- package/dist/core/Taskforce.realtimeMutation.test.js +57 -2
- package/dist/core/TaxonomySettingsService.d.ts +18 -0
- package/dist/core/TaxonomySettingsService.js +162 -0
- package/dist/core/WorkspaceDelete.test.js +32 -13
- package/dist/core/WorkspaceLifecycleService.d.ts +11 -0
- package/dist/core/WorkspaceLifecycleService.js +88 -0
- package/dist/core/WorkspacePermissions.test.js +95 -0
- package/dist/core/WorkspacePlanMode.test.js +114 -1
- package/dist/core/WorkspacePreferenceService.d.ts +41 -0
- package/dist/core/WorkspacePreferenceService.js +246 -0
- package/dist/core/WorkspaceSyncSettings.test.d.ts +1 -0
- package/dist/core/WorkspaceSyncSettings.test.js +65 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.js +212 -0
- package/dist/core/shared.d.ts +36 -0
- package/dist/core/shared.js +128 -0
- package/dist/core/types.d.ts +614 -0
- package/dist/core/types.js +27 -0
- package/dist/documentReviews/DocumentReviewCommentStore.d.ts +56 -0
- package/dist/documentReviews/DocumentReviewCommentStore.js +230 -0
- package/dist/documentReviews/DocumentReviewSessionStore.d.ts +52 -0
- package/dist/documentReviews/DocumentReviewSessionStore.js +193 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.d.ts +1 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.js +127 -0
- package/dist/documentReviews/service.d.ts +68 -0
- package/dist/documentReviews/service.js +210 -0
- package/dist/documentReviews/types.d.ts +58 -0
- package/dist/documentReviews/types.js +1 -0
- package/dist/hooks/auth/useAuthGuardPolicy.d.ts +2 -0
- package/dist/hooks/auth/useAuthGuardPolicy.js +1 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.d.ts +15 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.js +37 -0
- package/dist/hooks/tasks/useTaskActions.d.ts +3 -0
- package/dist/hooks/tasks/useTaskActions.js +2 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.d.ts +65 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.js +235 -0
- package/dist/hooks/tasks/useTaskFilters.d.ts +2 -0
- package/dist/hooks/tasks/useTaskFilters.js +2 -0
- package/dist/hooks/tasks/useTaskFormActions.d.ts +58 -0
- package/dist/hooks/tasks/useTaskFormActions.js +365 -0
- package/dist/hooks/tasks/useTaskRelationships.d.ts +16 -0
- package/dist/hooks/tasks/useTaskRelationships.js +64 -0
- package/dist/hooks/ui/useResourceExport.d.ts +19 -0
- package/dist/hooks/ui/useResourceExport.js +84 -0
- package/dist/hooks/ui/useSettingsModel.d.ts +203 -0
- package/dist/hooks/ui/useSettingsModel.js +125 -0
- package/dist/hooks/ui/useSettingsPersistence.d.ts +36 -0
- package/dist/hooks/ui/useSettingsPersistence.js +254 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.d.ts +119 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.js +536 -0
- package/dist/hooks/ui/useUiNotice.d.ts +1 -0
- package/dist/hooks/ui/useUiNotice.js +1 -0
- package/dist/hooks/ui/useWorkflowTemplates.d.ts +37 -0
- package/dist/hooks/ui/useWorkflowTemplates.js +111 -0
- package/dist/hooks/ui/useZenMode.d.ts +5 -0
- package/dist/hooks/ui/useZenMode.js +32 -0
- package/dist/hooks/useFilterSort.d.ts +60 -0
- package/dist/hooks/useFilterSort.js +362 -0
- package/dist/hooks/useFilterSort.test.d.ts +1 -0
- package/dist/hooks/useFilterSort.test.js +333 -0
- package/dist/hooks/useRealtimeSync.d.ts +1 -0
- package/dist/hooks/useRealtimeSync.js +24 -9
- package/dist/hooks/useRealtimeSync.test.js +28 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +34 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +65 -103
- package/dist/hooks/useSyncOrchestrator.js +1538 -670
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +700 -174
- package/dist/hooks/useTaskData.d.ts +35 -0
- package/dist/hooks/useTaskData.js +243 -0
- package/dist/hooks/useTaskData.test.d.ts +1 -0
- package/dist/hooks/useTaskData.test.js +180 -0
- package/dist/hooks/useTaskMutations.d.ts +46 -0
- package/dist/hooks/useTaskMutations.js +472 -0
- package/dist/hooks/useTaskMutations.test.d.ts +1 -0
- package/dist/hooks/useTaskMutations.test.js +80 -0
- package/dist/hooks/useTaskforce.contract.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.contract.test.js +7 -0
- package/dist/hooks/useTaskforce.d.ts +260 -295
- package/dist/hooks/useTaskforce.js +1954 -2623
- package/dist/hooks/useTaskforce.priority-filters.test.js +6 -11
- package/dist/hooks/useTaskforce.sync-behavior.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +1120 -0
- package/dist/hooks/useUiNotice.d.ts +16 -0
- package/dist/hooks/useUiNotice.js +49 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +23 -46
- package/dist/hooks/useWorkspaceSyncController.js +280 -461
- package/dist/hooks/useWorkspaceSyncController.test.js +106 -199
- package/dist/hooks/workspace/useTaskforceApiRouting.d.ts +8 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.js +91 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.d.ts +1 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.js +60 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.js +1 -0
- package/dist/hooks/workspace/workspaceLocalState.d.ts +30 -0
- package/dist/hooks/workspace/workspaceLocalState.js +101 -0
- package/dist/localization/locales/en-US.d.ts +1 -8
- package/dist/localization/locales/en-US.js +4 -11
- package/dist/localization/locales/es-419.js +2 -9
- package/dist/localization/locales/pt-BR.js +2 -9
- package/dist/localization/localizationCatalogs.test.d.ts +1 -0
- package/dist/localization/localizationCatalogs.test.js +37 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +9 -15
- package/dist/mcp/clientIntegrations.d.ts +33 -0
- package/dist/mcp/clientIntegrations.js +199 -0
- package/dist/mcp/clientIntegrations.test.d.ts +1 -0
- package/dist/mcp/clientIntegrations.test.js +110 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/optionResolution.test.js +6 -2
- package/dist/mcp/runtime.d.ts +120 -0
- package/dist/mcp/runtime.js +5668 -0
- package/dist/mcp/runtime.test.d.ts +1 -0
- package/dist/mcp/runtime.test.js +3299 -0
- package/dist/mcp/server.js +2 -1793
- package/dist/mcp/structuredCommentValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredCommentValidation.test.js +1 -1
- package/dist/mcp/structuredDocValidation.js +1 -2
- package/dist/mcp/structuredDocValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredDocValidation.test.js +9 -4
- package/dist/mcp/toolRegistry.d.ts +570 -0
- package/dist/mcp/toolRegistry.js +90 -0
- package/dist/mcp/toolRegistry.test.d.ts +1 -0
- package/dist/mcp/toolRegistry.test.js +30 -0
- package/dist/migrations/billingSchemaParity.test.js +111 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +5 -0
- package/dist/migrations/taskSchemaMigrations.js +681 -39
- package/dist/migrations/taskSchemaMigrations.test.d.ts +1 -0
- package/dist/migrations/taskSchemaMigrations.test.js +210 -0
- package/dist/plugins/vite.js +13 -7
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/favicon.ico +0 -0
- package/dist/public/archive/phoenix/site.webmanifest +1 -0
- package/dist/public/favicon-96x96.png +0 -0
- package/dist/public/favicon.ico +0 -0
- package/dist/public/favicon.svg +17 -0
- package/dist/public/site.webmanifest +21 -1
- package/dist/public/web-app-manifest-192x192.png +0 -0
- package/dist/public/web-app-manifest-512x512.png +0 -0
- package/dist/releaseVersion.test.d.ts +1 -0
- package/dist/{__tests__/releaseVersion.test.js → releaseVersion.test.js} +1 -1
- package/dist/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/dist/resources/templates/workflows/collaborate.yaml +11 -5
- package/dist/resources/templates/workflows/do.yaml +8 -2
- package/dist/resources/templates/workflows/evaluate.yaml +13 -7
- package/dist/resources/templates/workflows/execute.yaml +40 -23
- package/dist/resources/templates/workflows/plan.yaml +15 -8
- package/dist/resources/templates/workflows/review.yaml +14 -8
- package/dist/runtime/appGateDefinitions.d.ts +32 -0
- package/dist/runtime/appGateDefinitions.js +32 -0
- package/dist/runtime/appGates.d.ts +29 -0
- package/dist/runtime/appGates.js +53 -0
- package/dist/runtime/appGates.test.d.ts +1 -0
- package/dist/runtime/appGates.test.js +42 -0
- package/dist/runtime/migrationFlags.d.ts +1 -0
- package/dist/runtime/migrationFlags.js +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.d.ts +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +473 -0
- package/dist/server/auth.js +14 -2
- package/dist/server/auth.test.js +21 -1
- package/dist/server/cors.js +11 -5
- package/dist/server/cors.test.js +6 -0
- package/dist/server/documentReviewRoutes.test.d.ts +1 -0
- package/dist/server/documentReviewRoutes.test.js +157 -0
- package/dist/server/index.cookieProxy.test.js +46 -1
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +168 -41
- package/dist/server/index.rateLimit.test.js +6 -0
- package/dist/server/index.test.js +133 -2
- package/dist/server/mockStripe.d.ts +72 -0
- package/dist/server/mockStripe.js +357 -0
- package/dist/server/realtimeSyncWs.js +4 -0
- package/dist/server/routes/admin.d.ts +114 -0
- package/dist/server/routes/admin.js +2226 -0
- package/dist/server/routes/annotatedAttachments.d.ts +24 -0
- package/dist/server/routes/annotatedAttachments.js +235 -0
- package/dist/server/routes/auth.d.ts +61 -0
- package/dist/server/routes/auth.js +1296 -0
- package/dist/server/{routes.billing.d.ts → routes/billing.d.ts} +3 -2
- package/dist/server/routes/billing.js +2001 -0
- package/dist/server/routes/billing.test.d.ts +1 -0
- package/dist/server/routes/billing.test.js +1631 -0
- package/dist/server/routes/documentReviews.d.ts +11 -0
- package/dist/server/routes/documentReviews.js +191 -0
- package/dist/server/routes/documents.d.ts +118 -0
- package/dist/server/routes/documents.js +1113 -0
- package/dist/server/routes/resources.d.ts +25 -0
- package/dist/server/routes/resources.js +225 -0
- package/dist/server/routes/shared.d.ts +35 -0
- package/dist/server/routes/shared.js +305 -0
- package/dist/server/routes/shared.test.d.ts +1 -0
- package/dist/server/routes/shared.test.js +32 -0
- package/dist/server/{routes.sync.d.ts → routes/sync.d.ts} +5 -5
- package/dist/server/routes/sync.integration.test.d.ts +1 -0
- package/dist/server/routes/sync.integration.test.js +2003 -0
- package/dist/server/routes/sync.js +3143 -0
- package/dist/server/routes/tasks.d.ts +72 -0
- package/dist/server/routes/tasks.js +1252 -0
- package/dist/server/routes/workspaces.d.ts +32 -0
- package/dist/server/routes/workspaces.js +307 -0
- package/dist/server/routes.d.ts +8 -9
- package/dist/server/routes.js +968 -4092
- package/dist/server/routes.test.js +9043 -2415
- package/dist/server/runtimeMode.d.ts +1 -1
- package/dist/server/runtimeMode.js +3 -18
- package/dist/server/runtimeMode.test.js +3 -3
- package/dist/server/securityHeaders.d.ts +1 -1
- package/dist/server/securityHeaders.js +65 -6
- package/dist/server/securityHeaders.test.js +40 -2
- package/dist/services/templateEngine.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templateEngine.test.js +1 -1
- package/dist/services/templates.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templates.test.js +1 -1
- package/dist/services/workflowDocumentFixtures.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowDocumentFixtures.test.js +4 -4
- package/dist/services/workflowExportMatrix.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowExportMatrix.test.js +1 -1
- package/dist/services/workflowExportSnapshots.test.d.ts +1 -0
- package/dist/services/workflowGeneration.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.js +113 -0
- package/dist/services/workflowV2Policy.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowV2Policy.test.js +2 -2
- package/dist/shared/aiProfileSurfaceType.d.ts +9 -0
- package/dist/shared/aiProfileSurfaceType.js +49 -0
- package/dist/shared/customTaxonomyLibrary.d.ts +11 -0
- package/dist/shared/customTaxonomyLibrary.js +276 -0
- package/dist/shared/customTaxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/customTaxonomyLibrary.test.js +20 -0
- package/dist/shared/taxonomyCatalog.d.ts +19 -0
- package/dist/shared/taxonomyCatalog.js +36 -0
- package/dist/shared/taxonomyDefaults.d.ts +29 -0
- package/dist/shared/taxonomyDefaults.js +26 -0
- package/dist/shared/taxonomyLibrary.d.ts +15 -0
- package/dist/shared/taxonomyLibrary.js +98 -0
- package/dist/shared/taxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/taxonomyLibrary.test.js +26 -0
- package/dist/shared/taxonomyState.d.ts +36 -0
- package/dist/shared/taxonomyState.js +73 -0
- package/dist/shared/taxonomyState.test.d.ts +1 -0
- package/dist/shared/taxonomyState.test.js +71 -0
- package/dist/shared/workflowDefaults.d.ts +9 -0
- package/dist/shared/workflowDefaults.js +11 -0
- package/dist/storage/documentIntegrity.d.ts +2 -0
- package/dist/storage/documentIntegrity.js +34 -17
- package/dist/storage/documentPurge.d.ts +2 -1
- package/dist/storage/documentPurge.js +35 -8
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/objectStorage.d.ts +1 -1
- package/dist/storage/objectStorageClient.d.ts +6 -4
- package/dist/storage/objectStorageClient.js +77 -29
- package/dist/storage/objectStorageClient.test.d.ts +1 -0
- package/dist/storage/objectStorageClient.test.js +57 -0
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.d.ts +1 -0
- package/dist/storage/objectStorageConfig.test.js +38 -0
- package/dist/storage/postgresAdapter.d.ts +5 -0
- package/dist/storage/postgresAdapter.js +2 -2
- package/dist/storage/postgresAdapter.test.d.ts +1 -0
- package/dist/storage/postgresAdapter.test.js +29 -0
- package/dist/storage/taskAssetStore.d.ts +7 -4
- package/dist/storage/taskAssetStore.js +65 -60
- package/dist/storage/workspaceAssetStore.d.ts +122 -0
- package/dist/storage/workspaceAssetStore.js +496 -0
- package/dist/storage/workspaceAssetStore.test.d.ts +1 -0
- package/dist/storage/workspaceAssetStore.test.js +501 -0
- package/dist/sync/cloudSyncApi.d.ts +33 -2
- package/dist/sync/cloudSyncApi.js +37 -15
- package/dist/sync/cloudSyncApi.test.js +60 -3
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.js +3 -0
- package/dist/sync/syncService.d.ts +25 -0
- package/dist/sync/syncService.js +184 -16
- package/dist/sync/syncService.test.js +289 -25
- package/dist/sync/workspaceRepair.d.ts +114 -0
- package/dist/sync/workspaceRepair.js +566 -0
- package/dist/sync/workspaceSyncModel.d.ts +230 -2
- package/dist/sync/workspaceSyncModel.js +456 -17
- package/dist/sync/workspaceSyncModel.test.js +642 -0
- package/dist/sync/workspaceSyncState.d.ts +12 -0
- package/dist/sync/workspaceSyncState.js +12 -0
- package/dist/sync/workspaceSyncSurface.d.ts +33 -0
- package/dist/sync/workspaceSyncSurface.js +74 -0
- package/dist/types.d.ts +143 -18
- package/dist/types.js +9 -24
- package/dist/ui/assets/AgentsModule-Bdq3T1Ts.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-jKUVo-PJ.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-DuQa-uIC.js +250 -0
- package/dist/ui/assets/InitiativesModule-C8Pi2VTk.js +1 -0
- package/dist/ui/assets/PlansPage-B6BDECSi.js +1 -0
- package/dist/ui/assets/PlansPage-BlVC_lRq.css +1 -0
- package/dist/ui/assets/TaskSettings-BwB9NtmJ.css +1 -0
- package/dist/ui/assets/TaskSettings-DUJgRTPU.js +8 -0
- package/dist/ui/assets/WorkflowsModule-b6TLuHwJ.js +5 -0
- package/dist/ui/assets/index-BiiF1cLQ.css +1 -0
- package/dist/ui/assets/index-xBWLPOEb.js +6 -0
- package/dist/ui/assets/vendor-dnd-DRzYolkg.js +5 -0
- package/dist/ui/assets/{vendor-icons-ZFG3SGwo.js → vendor-icons-QZyhEwgT.js} +1 -1
- package/dist/ui/assets/{vendor-markdown-CKYkQHfj.js → vendor-markdown-BUxTU7dS.js} +1 -1
- package/dist/ui/assets/vendor-react-CKJs5o3c.js +61 -0
- package/dist/ui/assets/vendor-router-BbWMxlnO.js +3 -0
- package/dist/ui/branding/logos/logo.png +0 -0
- package/dist/ui/branding/logos/logo_dark.svg +27 -0
- package/dist/ui/branding/logos/logo_favicon.svg +35 -0
- package/dist/ui/branding/logos/logo_light.svg +27 -0
- package/dist/ui/favicon/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/favicon.ico +0 -0
- package/dist/ui/favicon/archive/phoenix/site.webmanifest +1 -0
- package/dist/ui/favicon/favicon-96x96.png +0 -0
- package/dist/ui/favicon/favicon.ico +0 -0
- package/dist/ui/favicon/favicon.svg +17 -0
- package/dist/ui/favicon/site.webmanifest +21 -1
- package/dist/ui/favicon/web-app-manifest-192x192.png +0 -0
- package/dist/ui/favicon/web-app-manifest-512x512.png +0 -0
- package/dist/ui/index.html +10 -7
- package/dist/ui/product/screen_01.png +0 -0
- package/dist/ui/product/screen_02.png +0 -0
- package/dist/ui/product/screen_03.png +0 -0
- package/dist/utils/annotatedAttachments.d.ts +20 -0
- package/dist/utils/annotatedAttachments.js +74 -0
- package/dist/utils/annotatedAttachments.test.d.ts +1 -0
- package/dist/utils/annotatedAttachments.test.js +108 -0
- package/dist/utils/assignees.d.ts +26 -0
- package/dist/utils/assignees.js +132 -0
- package/dist/utils/canonicalAssetKind.d.ts +16 -0
- package/dist/utils/canonicalAssetKind.js +39 -0
- package/dist/utils/canonicalAssetKind.test.d.ts +1 -0
- package/dist/utils/canonicalAssetKind.test.js +24 -0
- package/dist/utils/coalescedRefresh.d.ts +5 -0
- package/dist/utils/coalescedRefresh.js +16 -0
- package/dist/utils/coalescedRefresh.test.d.ts +1 -0
- package/dist/utils/coalescedRefresh.test.js +30 -0
- package/dist/utils/constants.d.ts +16 -3
- package/dist/utils/constants.icons.test.js +2 -1
- package/dist/utils/constants.js +70 -13
- package/dist/utils/contextAssetEvents.d.ts +7 -0
- package/dist/utils/contextAssetEvents.js +6 -0
- package/dist/utils/contextFiles.d.ts +14 -3
- package/dist/utils/contextFiles.js +40 -8
- package/dist/utils/documentManagerApi.d.ts +6 -0
- package/dist/utils/documentManagerApi.js +19 -0
- package/dist/utils/documentNames.d.ts +24 -0
- package/dist/utils/documentNames.js +91 -0
- package/dist/utils/documentReferences.d.ts +9 -0
- package/dist/utils/documentReferences.js +11 -0
- package/dist/utils/entityReferences.d.ts +9 -0
- package/dist/utils/entityReferences.js +32 -0
- package/dist/utils/imageReferences.d.ts +9 -0
- package/dist/utils/imageReferences.js +11 -0
- package/dist/utils/planParser.d.ts +2 -2
- package/dist/utils/planParser.js +11 -11
- package/dist/utils/planParser.test.d.ts +1 -0
- package/dist/{__tests__ → utils}/planParser.test.js +22 -22
- package/dist/utils/planningReferences.d.ts +17 -0
- package/dist/utils/planningReferences.js +30 -0
- package/dist/utils/planningReferences.test.d.ts +1 -0
- package/dist/utils/planningReferences.test.js +17 -0
- package/dist/utils/taskActivity.d.ts +9 -0
- package/dist/utils/taskActivity.js +147 -0
- package/dist/utils/taskActivity.test.d.ts +1 -0
- package/dist/utils/taskActivity.test.js +34 -0
- package/dist/utils/taskDraftDefaults.d.ts +21 -0
- package/dist/utils/taskDraftDefaults.js +40 -0
- package/dist/utils/taskDraftDefaults.test.d.ts +1 -0
- package/dist/utils/taskDraftDefaults.test.js +34 -0
- package/dist/utils/taskNormalization.d.ts +25 -0
- package/dist/utils/taskNormalization.js +161 -0
- package/dist/utils/taskNormalization.test.d.ts +1 -0
- package/dist/utils/taskNormalization.test.js +158 -0
- package/dist/utils/taskReferences.d.ts +5 -0
- package/dist/utils/taskReferences.js +11 -0
- package/dist/utils/taskforceApiClient.d.ts +2 -0
- package/dist/utils/taskforceApiClient.js +101 -3
- package/dist/utils/taskforceApiClient.test.d.ts +1 -0
- package/dist/utils/taskforceApiClient.test.js +44 -0
- package/dist/utils/taxonomyFilterOptions.d.ts +2 -0
- package/dist/utils/taxonomyFilterOptions.js +17 -0
- package/dist/utils/taxonomyFormDefaults.d.ts +3 -0
- package/dist/utils/taxonomyFormDefaults.js +28 -0
- package/dist/utils/taxonomyFormDefaults.test.d.ts +1 -0
- package/dist/utils/taxonomyFormDefaults.test.js +63 -0
- package/dist/utils/taxonomyLifecycle.d.ts +3 -0
- package/dist/utils/taxonomyLifecycle.js +17 -0
- package/dist/utils/taxonomySorting.d.ts +7 -0
- package/dist/utils/taxonomySorting.js +74 -0
- package/dist/utils/theme.d.ts +15 -0
- package/dist/utils/theme.js +23 -0
- package/dist/utils/uiNotice.d.ts +7 -0
- package/dist/utils/uiNotice.js +6 -0
- package/dist/utils/uiNotice.test.d.ts +1 -0
- package/dist/utils/uiNotice.test.js +18 -0
- package/dist/utils/workspaceIdentity.d.ts +14 -0
- package/dist/utils/workspaceIdentity.js +21 -0
- package/dist/utils/workspaceIdentity.test.js +30 -1
- package/dist/utils/workspaceSyncPresentation.d.ts +20 -0
- package/dist/utils/workspaceSyncPresentation.js +21 -0
- package/dist/utils/workspaceSyncPresentation.test.d.ts +1 -0
- package/dist/utils/workspaceSyncPresentation.test.js +36 -0
- package/package.json +30 -23
- package/scripts/bootstrap-playwright-auth.mjs +155 -0
- package/scripts/check-compat-boundaries.mjs +41 -0
- package/scripts/check-env.ts +9 -2
- package/scripts/debug-login.mjs +32 -0
- package/scripts/deploy-prod.sh +41 -0
- package/scripts/deploy-staging.sh +23 -0
- package/scripts/migrate-planning-model.ts +233 -0
- package/scripts/migrate-task-data-assets.mjs +408 -0
- package/scripts/playwright-auth.sh +11 -0
- package/scripts/run-dev-proxy.sh +2 -0
- package/scripts/update-homebrew-formula.sh +2 -2
- package/scripts/with-esbuild.sh +11 -0
- package/src/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/src/resources/templates/workflows/collaborate.yaml +11 -5
- package/src/resources/templates/workflows/do.yaml +8 -2
- package/src/resources/templates/workflows/evaluate.yaml +13 -7
- package/src/resources/templates/workflows/execute.yaml +40 -23
- package/src/resources/templates/workflows/plan.yaml +15 -8
- package/src/resources/templates/workflows/review.yaml +14 -8
- package/dist/TaskforceScreenshot.d.ts +0 -11
- package/dist/TaskforceScreenshot.js +0 -113
- package/dist/compat/priorityCompat.d.ts +0 -2
- package/dist/compat/priorityCompat.js +0 -24
- package/dist/components/features/settings/TaxonomyManager 2.d.ts +0 -52
- package/dist/components/features/settings/TaxonomyManager 2.js +0 -75
- package/dist/components/task/TaskKanban 2.d.ts +0 -76
- package/dist/components/task/TaskKanban 2.js +0 -1004
- package/dist/components/task/TaskScreenshotSection.d.ts +0 -15
- package/dist/components/task/TaskScreenshotSection.js +0 -22
- package/dist/components/views/StandaloneLayout 2.d.ts +0 -8
- package/dist/components/views/StandaloneLayout 2.js +0 -1660
- package/dist/core/InitiativeTemplates.test.js +0 -50
- package/dist/core/ParentLifecycle.test.js +0 -98
- package/dist/migrations/clientMigrations.d.ts +0 -9
- package/dist/migrations/clientMigrations.js +0 -74
- package/dist/migrations/flags.d.ts +0 -6
- package/dist/migrations/flags.js +0 -8
- package/dist/migrations/taskMigrations.d.ts +0 -71
- package/dist/migrations/taskMigrations.js +0 -236
- package/dist/migrations/taskMigrations.test.js +0 -34
- package/dist/server/routes.billing.js +0 -991
- package/dist/server/routes.billing.test.js +0 -387
- package/dist/server/routes.sync.integration.test.js +0 -470
- package/dist/server/routes.sync.js +0 -1141
- package/dist/soak/syncSoakHarness.d.ts +0 -6
- package/dist/soak/syncSoakHarness.js +0 -10
- package/dist/ui/assets/index-0W5vyCpn.css +0 -1
- package/dist/ui/assets/index-MCZuNNfn.js +0 -23
- package/dist/ui/assets/logo_white_01-0Vx-iIIv.png +0 -0
- package/dist/ui/assets/vendor-dnd-BI1K77C1.js +0 -5
- package/dist/ui/assets/vendor-react-DdIDsh7f.js +0 -9
- package/dist/ui/assets/vendor-router-BtiNU7Dl.js +0 -3
- package/dist/utils/taskHierarchy.d.ts +0 -4
- package/dist/utils/taskHierarchy.js +0 -69
- package/dist/utils/taskHierarchy.test.js +0 -38
- package/dist/utils/taskParenting.d.ts +0 -3
- package/dist/utils/taskParenting.js +0 -24
- package/dist/utils/taskParenting.test.js +0 -21
- package/dist/utils/taskRelations.d.ts +0 -6
- package/dist/utils/taskRelations.js +0 -24
- package/dist/utils/taskRelations.test.js +0 -34
- package/scripts/check-compat-boundaries.js +0 -48
- /package/dist/{__tests__/planParser.test.d.ts → TaskforceCore.filter-persistence.test.d.ts} +0 -0
- /package/dist/{__tests__/releaseVersion.test.d.ts → TaskforceCore.routing.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredCommentValidation.test.d.ts → annotatedAttachments/AnnotatedAttachmentSessionStore.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredDocValidation.test.d.ts → annotatedAttachments/payload.test.d.ts} +0 -0
- /package/dist/{__tests__/templateEngine.test.d.ts → annotatedAttachments/types.js} +0 -0
- /package/dist/{__tests__/templates.test.d.ts → compat/workspaceSyncCompat.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowDocumentFixtures.test.d.ts → components/features/AgentsModule.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportMatrix.test.d.ts → components/features/AnnotatedAttachmentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportSnapshots.test.d.ts → components/features/DocumentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowGeneration.test.d.ts → components/features/settings/TaxonomyLibraryManager.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowV2Policy.test.d.ts → components/task/TaskActionHeader.test.d.ts} +0 -0
- /package/dist/{core/InitiativeTemplates.test.d.ts → components/task/TaskKanban.planning-drop.test.d.ts} +0 -0
- /package/dist/{core/ParentLifecycle.test.d.ts → components/task/TaskStatusActions.test.d.ts} +0 -0
- /package/dist/{migrations/taskMigrations.test.d.ts → components/ui/TopNoticeLayer.test.d.ts} +0 -0
- /package/dist/{server/routes.billing.test.d.ts → components/views/AppShellHeader.test.d.ts} +0 -0
- /package/dist/{server/routes.sync.integration.test.d.ts → components/views/PlanComparisonPage.test.d.ts} +0 -0
- /package/dist/{utils/taskHierarchy.test.d.ts → components/views/chrome/WorkspaceChromeSections.test.d.ts} +0 -0
- /package/dist/{utils/taskParenting.test.d.ts → components/views/chrome/workspaceChrome.test.d.ts} +0 -0
- /package/dist/{utils/taskRelations.test.d.ts → components/views/panels/DocumentFilterToolbar.test.d.ts} +0 -0
- /package/dist/public/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/public/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/public/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/public/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/public/{favicon.png → archive/phoenix/favicon.png} +0 -0
- /package/dist/{__tests__ → services}/workflowExportSnapshots.test.js +0 -0
- /package/dist/{__tests__ → services}/workflowGeneration.test.js +0 -0
- /package/dist/ui/favicon/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/ui/favicon/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/ui/favicon/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/ui/favicon/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/ui/favicon/{favicon.png → archive/phoenix/favicon.png} +0 -0
|
@@ -0,0 +1,2003 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Readable } from 'stream';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { TaskforceCore } from '../../core/Taskforce';
|
|
7
|
+
import { createRoutes, matchRoute } from '../routes';
|
|
8
|
+
import { createSessionTokenCookie } from '../auth';
|
|
9
|
+
import { WorkspaceAssetStore } from '../../storage/workspaceAssetStore';
|
|
10
|
+
function createMockReq(method, url, body, headers) {
|
|
11
|
+
const req = new Readable();
|
|
12
|
+
req._read = () => { };
|
|
13
|
+
req.push(body ? JSON.stringify(body) : null);
|
|
14
|
+
req.push(null);
|
|
15
|
+
req.method = method;
|
|
16
|
+
req.url = url;
|
|
17
|
+
req.headers = headers || {};
|
|
18
|
+
return req;
|
|
19
|
+
}
|
|
20
|
+
function createMockRes() {
|
|
21
|
+
return {
|
|
22
|
+
writeHead: vi.fn(),
|
|
23
|
+
end: vi.fn()
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe('Server Routes Workspace Sync Integration', () => {
|
|
27
|
+
const tempDirs = [];
|
|
28
|
+
const originalHome = process.env.HOME;
|
|
29
|
+
const workspaceId = 'workspace-local-active';
|
|
30
|
+
const authConfig = {
|
|
31
|
+
runtimeMode: 'cloud',
|
|
32
|
+
enabled: true,
|
|
33
|
+
requiredForApi: true,
|
|
34
|
+
excludedPaths: ['/api/taskforce/health', '/api/taskforce/version', '/api/taskforce/auth'],
|
|
35
|
+
defaultRole: 'member',
|
|
36
|
+
defaultWorkspaceId: '',
|
|
37
|
+
sessionCookieName: 'taskforce_session',
|
|
38
|
+
sessionSecret: 'integration-test-secret',
|
|
39
|
+
sessionTtlSeconds: 60 * 60,
|
|
40
|
+
sessionCookieSameSite: 'None',
|
|
41
|
+
sessionCookieSecure: true
|
|
42
|
+
};
|
|
43
|
+
const createAuthCookie = (overrideWorkspaceId) => createSessionTokenCookie('session-sync-integration', authConfig, {
|
|
44
|
+
role: 'owner',
|
|
45
|
+
workspaceId: overrideWorkspaceId || workspaceId,
|
|
46
|
+
userId: 'integration-user'
|
|
47
|
+
});
|
|
48
|
+
const readJsonBody = (res) => JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
49
|
+
const createIsolatedProjectRoot = (prefix) => {
|
|
50
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
51
|
+
tempDirs.push(projectRoot);
|
|
52
|
+
process.env.HOME = projectRoot;
|
|
53
|
+
return projectRoot;
|
|
54
|
+
};
|
|
55
|
+
const buildSoftwareTaxonomyState = () => ({
|
|
56
|
+
categories: [
|
|
57
|
+
{ value: 'default', label: 'General', icon: 'Inbox' },
|
|
58
|
+
{ value: 'product', label: 'Product', icon: 'Layers', color: 'blue-500' }
|
|
59
|
+
],
|
|
60
|
+
types: [
|
|
61
|
+
{ value: 'feature', label: 'Feature', icon: 'Star', color: 'teal-500', status: 'active' },
|
|
62
|
+
{ value: 'bug', label: 'Bug', icon: 'Bug', color: 'red-500', status: 'active' },
|
|
63
|
+
{ value: 'chore', label: 'Chore', icon: 'Zap', color: 'amber-500', status: 'active' },
|
|
64
|
+
{ value: 'refactor', label: 'Refactor', icon: 'Code', color: 'blue-500', status: 'active' },
|
|
65
|
+
{ value: 'documentation', label: 'Docs', icon: 'Book', color: 'sky-500', status: 'active' }
|
|
66
|
+
],
|
|
67
|
+
priorities: [
|
|
68
|
+
{ value: 1, label: 'Low', color: 'amber-200', icon: 'ArrowDown' },
|
|
69
|
+
{ value: 2, label: 'Medium', color: 'yellow-500', icon: 'Minus' },
|
|
70
|
+
{ value: 3, label: 'High', color: 'orange-500', icon: 'ArrowUp' },
|
|
71
|
+
{ value: 4, label: 'Critical', color: 'red-500', icon: 'AlertTriangle' }
|
|
72
|
+
],
|
|
73
|
+
taxonomies: []
|
|
74
|
+
});
|
|
75
|
+
const seedAuthenticatedWorkspace = (core, options) => {
|
|
76
|
+
const userId = String(options?.userId || 'integration-user');
|
|
77
|
+
const targetWorkspaceId = String(options?.workspaceId || workspaceId);
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter' });
|
|
80
|
+
core.db.prepare(`
|
|
81
|
+
INSERT OR IGNORE INTO billing_price_mappings (
|
|
82
|
+
tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
|
|
83
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
84
|
+
`).run('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, null, now, now);
|
|
85
|
+
core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
|
|
86
|
+
core.db.prepare('INSERT OR IGNORE INTO users (id, tenant_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(userId, 'default', `${userId}@test.com`, now, now);
|
|
87
|
+
core.updateAccountEntitlement({
|
|
88
|
+
accountId: userId,
|
|
89
|
+
planVersionId: 'starter-v1',
|
|
90
|
+
actorUserId: 'system',
|
|
91
|
+
actorRole: 'system_admin'
|
|
92
|
+
});
|
|
93
|
+
core.createWorkspaceForUser({
|
|
94
|
+
userId,
|
|
95
|
+
name: options?.name || 'Local Active Workspace',
|
|
96
|
+
role: 'owner',
|
|
97
|
+
preferredWorkspaceId: targetWorkspaceId
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
process.env.HOME = originalHome;
|
|
102
|
+
while (tempDirs.length > 0) {
|
|
103
|
+
const dir = tempDirs.pop();
|
|
104
|
+
if (!dir)
|
|
105
|
+
continue;
|
|
106
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
it('persists cloud-pulled tasks into local sqlite via apply-local route', async () => {
|
|
110
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-');
|
|
111
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
112
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
113
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
114
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
115
|
+
expect(match).toBeTruthy();
|
|
116
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
117
|
+
changes: [
|
|
118
|
+
{
|
|
119
|
+
op: 'upsert',
|
|
120
|
+
archived: false,
|
|
121
|
+
task: {
|
|
122
|
+
id: 'task-cloud-1',
|
|
123
|
+
title: 'Task from cloud',
|
|
124
|
+
status: 'in-progress',
|
|
125
|
+
canceledReason: null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
130
|
+
const res = createMockRes();
|
|
131
|
+
await match?.handler(req, res, {});
|
|
132
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
133
|
+
// Reload from disk to assert persistence, not just in-memory state.
|
|
134
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
135
|
+
const persisted = reloadedCore.listTasks(workspaceId);
|
|
136
|
+
const task = persisted.tasks.find((item) => item.id === 'task-cloud-1');
|
|
137
|
+
const defaultWorkspaceTasks = reloadedCore.listTasks('default').tasks;
|
|
138
|
+
expect(task).toBeTruthy();
|
|
139
|
+
expect(task?.title).toBe('Task from cloud');
|
|
140
|
+
expect(task?.status).toBe('in-progress');
|
|
141
|
+
expect(defaultWorkspaceTasks.find((item) => item.id === 'task-cloud-1')).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
it('persists synced comments via apply-local route', async () => {
|
|
144
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-comments-');
|
|
145
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
146
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
147
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
148
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
149
|
+
expect(match).toBeTruthy();
|
|
150
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
151
|
+
changes: [
|
|
152
|
+
{
|
|
153
|
+
op: 'upsert',
|
|
154
|
+
archived: false,
|
|
155
|
+
task: {
|
|
156
|
+
id: 'task-cloud-comments-1',
|
|
157
|
+
title: 'Task with synced comments',
|
|
158
|
+
status: 'in-progress',
|
|
159
|
+
updatedAt: '2026-03-16T12:00:00.000Z',
|
|
160
|
+
comments: [
|
|
161
|
+
{
|
|
162
|
+
id: 'comment-cloud-1',
|
|
163
|
+
author: 'user',
|
|
164
|
+
text: 'Cloud comment',
|
|
165
|
+
timestamp: '2026-03-16T12:00:00.000Z'
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
172
|
+
const res = createMockRes();
|
|
173
|
+
await match?.handler(req, res, {});
|
|
174
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
175
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
176
|
+
const persisted = reloadedCore.getTask('task-cloud-comments-1', workspaceId);
|
|
177
|
+
expect(persisted?.comments).toEqual([
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
id: 'comment-cloud-1',
|
|
180
|
+
author: 'user',
|
|
181
|
+
text: 'Cloud comment',
|
|
182
|
+
timestamp: '2026-03-16T12:00:00.000Z'
|
|
183
|
+
})
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
it('persists unattached annotated attachment sessions via apply-local route', async () => {
|
|
187
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-annotated-unattached-');
|
|
188
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
189
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
190
|
+
const assetStore = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
|
|
191
|
+
assetStore.upsertAsset({
|
|
192
|
+
assetId: 'asset-image-sync-1',
|
|
193
|
+
workspaceId,
|
|
194
|
+
kind: 'image',
|
|
195
|
+
originalFilename: 'synced-image.png',
|
|
196
|
+
mimeType: 'image/png',
|
|
197
|
+
storageProvider: 'local',
|
|
198
|
+
storageKey: 'workspaces/workspace-local-active/assets/images/asset-image-sync-1-synced-image.png',
|
|
199
|
+
contentSha256: 'seed-hash-sync-image',
|
|
200
|
+
sizeBytes: 42,
|
|
201
|
+
createdAt: '2026-03-30T20:00:00.000Z',
|
|
202
|
+
updatedAt: '2026-03-30T20:00:00.000Z'
|
|
203
|
+
});
|
|
204
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
205
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
206
|
+
expect(match).toBeTruthy();
|
|
207
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
208
|
+
changes: [
|
|
209
|
+
{
|
|
210
|
+
op: 'annotated-attachment-session-upsert',
|
|
211
|
+
session: {
|
|
212
|
+
id: 'session-unattached-apply-1',
|
|
213
|
+
workspaceId,
|
|
214
|
+
taskId: '',
|
|
215
|
+
baseImageAssetId: 'asset-image-sync-1',
|
|
216
|
+
title: 'Synced loose notes',
|
|
217
|
+
globalInstruction: 'Review this image',
|
|
218
|
+
annotations: [
|
|
219
|
+
{ id: 'annotation-1', kind: 'pin', order: 0, instruction: 'Look here', x: 0.2, y: 0.3 }
|
|
220
|
+
],
|
|
221
|
+
createdByActorId: null,
|
|
222
|
+
updatedByActorId: null,
|
|
223
|
+
createdAt: '2026-03-30T20:00:00.000Z',
|
|
224
|
+
updatedAt: '2026-03-30T20:05:00.000Z',
|
|
225
|
+
deletedAt: null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
230
|
+
const res = createMockRes();
|
|
231
|
+
await match?.handler(req, res, {});
|
|
232
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
233
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
234
|
+
expect(reloadedCore.listAnnotatedAttachmentSessions({
|
|
235
|
+
workspaceId,
|
|
236
|
+
taskId: '',
|
|
237
|
+
imageAssetId: 'asset-image-sync-1'
|
|
238
|
+
})).toEqual([
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
id: 'session-unattached-apply-1',
|
|
241
|
+
taskId: '',
|
|
242
|
+
baseImageAssetId: 'asset-image-sync-1',
|
|
243
|
+
title: 'Synced loose notes'
|
|
244
|
+
})
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
it('returns 500 when annotated attachment sync persistence fails during apply-local', async () => {
|
|
248
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-annotated-failure-');
|
|
249
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
250
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
251
|
+
vi.spyOn(core, 'upsertAnnotatedAttachmentSessionForSync').mockImplementation(() => {
|
|
252
|
+
throw new Error('annotated sync failure');
|
|
253
|
+
});
|
|
254
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
255
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
256
|
+
expect(match).toBeTruthy();
|
|
257
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
258
|
+
changes: [
|
|
259
|
+
{
|
|
260
|
+
op: 'annotated-attachment-session-upsert',
|
|
261
|
+
session: {
|
|
262
|
+
id: 'session-fail-1',
|
|
263
|
+
workspaceId,
|
|
264
|
+
taskId: '',
|
|
265
|
+
baseImageAssetId: 'asset-image-sync-1',
|
|
266
|
+
title: 'Broken notes',
|
|
267
|
+
globalInstruction: null,
|
|
268
|
+
annotations: [],
|
|
269
|
+
createdByActorId: null,
|
|
270
|
+
updatedByActorId: null,
|
|
271
|
+
createdAt: '2026-03-30T20:00:00.000Z',
|
|
272
|
+
updatedAt: '2026-03-30T20:05:00.000Z',
|
|
273
|
+
deletedAt: null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
]
|
|
277
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
278
|
+
const res = createMockRes();
|
|
279
|
+
await match?.handler(req, res, {});
|
|
280
|
+
expect(res.writeHead).toHaveBeenCalledWith(500, expect.any(Object));
|
|
281
|
+
expect(readJsonBody(res)).toEqual(expect.objectContaining({
|
|
282
|
+
error: expect.stringContaining('Failed to persist annotated attachment session session-fail-1 during sync apply')
|
|
283
|
+
}));
|
|
284
|
+
});
|
|
285
|
+
it('persists synced ai profiles via apply-local route', async () => {
|
|
286
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-ai-profiles-');
|
|
287
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
288
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
289
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
290
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
291
|
+
expect(match).toBeTruthy();
|
|
292
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
293
|
+
changes: [
|
|
294
|
+
{
|
|
295
|
+
op: 'ai-profile-upsert',
|
|
296
|
+
profile: {
|
|
297
|
+
id: 'ai-profile-codex',
|
|
298
|
+
workspaceId,
|
|
299
|
+
profileToken: 'tfp_codex_sync',
|
|
300
|
+
name: 'Codex',
|
|
301
|
+
username: 'codex',
|
|
302
|
+
icon: 'Bot',
|
|
303
|
+
color: '#0ea5e9',
|
|
304
|
+
surfaceType: 'coding_tool',
|
|
305
|
+
createdAt: '2026-03-22T10:00:00.000Z',
|
|
306
|
+
updatedAt: '2026-03-22T10:05:00.000Z',
|
|
307
|
+
lastActiveAt: '2026-03-22T10:05:00.000Z'
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
]
|
|
311
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
312
|
+
const res = createMockRes();
|
|
313
|
+
await match?.handler(req, res, {});
|
|
314
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
315
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
316
|
+
expect(reloadedCore.listAiProfiles(workspaceId)).toEqual([
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
id: 'ai-profile-codex',
|
|
319
|
+
name: 'Codex',
|
|
320
|
+
username: 'codex',
|
|
321
|
+
surfaceType: 'coding_tool'
|
|
322
|
+
})
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
it('rejects synced ai profiles with an invalid surfaceType via apply-local route', async () => {
|
|
326
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-ai-profiles-invalid-surface-');
|
|
327
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
328
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
329
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
330
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
331
|
+
expect(match).toBeTruthy();
|
|
332
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
333
|
+
changes: [
|
|
334
|
+
{
|
|
335
|
+
op: 'ai-profile-upsert',
|
|
336
|
+
profile: {
|
|
337
|
+
id: 'ai-profile-invalid',
|
|
338
|
+
workspaceId,
|
|
339
|
+
profileToken: 'tfp_invalid_surface',
|
|
340
|
+
name: 'OpenClaw Agent',
|
|
341
|
+
username: 'openclaw-agent',
|
|
342
|
+
icon: 'Bot',
|
|
343
|
+
color: '#64748b',
|
|
344
|
+
surfaceType: 'desktop_app',
|
|
345
|
+
createdAt: '2026-03-22T10:00:00.000Z',
|
|
346
|
+
updatedAt: '2026-03-22T10:05:00.000Z',
|
|
347
|
+
lastActiveAt: '2026-03-22T10:05:00.000Z'
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
]
|
|
351
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
352
|
+
const res = createMockRes();
|
|
353
|
+
await match?.handler(req, res, {});
|
|
354
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
355
|
+
expect(readJsonBody(res)).toEqual(expect.objectContaining({
|
|
356
|
+
code: 'AI_PROFILE_SURFACE_TYPE_INVALID',
|
|
357
|
+
error: expect.stringContaining('surfaceType')
|
|
358
|
+
}));
|
|
359
|
+
});
|
|
360
|
+
it('applies tasks whose assignee resolves after ai profile sync', async () => {
|
|
361
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-ai-assignee-');
|
|
362
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
363
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
364
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
365
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
366
|
+
expect(match).toBeTruthy();
|
|
367
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
368
|
+
changes: [
|
|
369
|
+
{
|
|
370
|
+
op: 'ai-profile-upsert',
|
|
371
|
+
profile: {
|
|
372
|
+
id: 'ai-profile-codex',
|
|
373
|
+
workspaceId,
|
|
374
|
+
profileToken: 'tfp_codex_sync',
|
|
375
|
+
name: 'Codex',
|
|
376
|
+
username: 'codex',
|
|
377
|
+
icon: 'Bot',
|
|
378
|
+
color: '#0ea5e9',
|
|
379
|
+
surfaceType: 'coding_tool',
|
|
380
|
+
createdAt: '2026-03-22T10:00:00.000Z',
|
|
381
|
+
updatedAt: '2026-03-22T10:05:00.000Z',
|
|
382
|
+
lastActiveAt: '2026-03-22T10:05:00.000Z'
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
op: 'upsert',
|
|
387
|
+
archived: false,
|
|
388
|
+
task: {
|
|
389
|
+
id: 'task-ai-assignee-1',
|
|
390
|
+
title: 'Task assigned to synced AI',
|
|
391
|
+
status: 'task',
|
|
392
|
+
assignee: 'ai-profile-codex'
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
]
|
|
396
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
397
|
+
const res = createMockRes();
|
|
398
|
+
await match?.handler(req, res, {});
|
|
399
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
400
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
401
|
+
const task = reloadedCore.getTask('task-ai-assignee-1', workspaceId);
|
|
402
|
+
expect(task?.assignee).toBe('ai-profile-codex');
|
|
403
|
+
expect(task?.assigneeActor).toEqual(expect.objectContaining({
|
|
404
|
+
id: 'ai-profile-codex',
|
|
405
|
+
kind: 'ai',
|
|
406
|
+
label: 'Codex'
|
|
407
|
+
}));
|
|
408
|
+
});
|
|
409
|
+
it('applies tasks whose assignee resolves to a workspace member', async () => {
|
|
410
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-human-assignee-');
|
|
411
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
412
|
+
seedAuthenticatedWorkspace(core, { workspaceId, userId: 'member-1', name: 'Local Active Workspace' });
|
|
413
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
414
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
415
|
+
expect(match).toBeTruthy();
|
|
416
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
417
|
+
changes: [
|
|
418
|
+
{
|
|
419
|
+
op: 'upsert',
|
|
420
|
+
archived: false,
|
|
421
|
+
task: {
|
|
422
|
+
id: 'task-human-assignee-1',
|
|
423
|
+
title: 'Task assigned to workspace member',
|
|
424
|
+
status: 'task',
|
|
425
|
+
assignee: 'member-1'
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
430
|
+
const res = createMockRes();
|
|
431
|
+
await match?.handler(req, res, {});
|
|
432
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
433
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
434
|
+
const task = reloadedCore.getTask('task-human-assignee-1', workspaceId);
|
|
435
|
+
expect(task?.assignee).toBe('member-1');
|
|
436
|
+
expect(task?.assigneeActor).toEqual(expect.objectContaining({
|
|
437
|
+
id: 'member-1',
|
|
438
|
+
kind: 'human'
|
|
439
|
+
}));
|
|
440
|
+
});
|
|
441
|
+
it('preserves unresolved human assignees during apply-local sync', async () => {
|
|
442
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-unresolved-human-assignee-');
|
|
443
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
444
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
445
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
446
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
447
|
+
expect(match).toBeTruthy();
|
|
448
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
449
|
+
changes: [
|
|
450
|
+
{
|
|
451
|
+
op: 'upsert',
|
|
452
|
+
archived: false,
|
|
453
|
+
task: {
|
|
454
|
+
id: 'task-unresolved-human-assignee-1',
|
|
455
|
+
title: 'Task assigned to remote human',
|
|
456
|
+
status: 'task',
|
|
457
|
+
assignee: 'user-019c91d9-c143-771d-b0bf-36e318cfb353'
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
462
|
+
const res = createMockRes();
|
|
463
|
+
await match?.handler(req, res, {});
|
|
464
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
465
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
466
|
+
const task = reloadedCore.getTask('task-unresolved-human-assignee-1', workspaceId);
|
|
467
|
+
expect(task?.assignee).toBe('user-019c91d9-c143-771d-b0bf-36e318cfb353');
|
|
468
|
+
expect(task?.assigneeActor).toEqual(expect.objectContaining({
|
|
469
|
+
id: 'user-019c91d9-c143-771d-b0bf-36e318cfb353',
|
|
470
|
+
kind: 'human',
|
|
471
|
+
meta: expect.objectContaining({ unresolved: true })
|
|
472
|
+
}));
|
|
473
|
+
});
|
|
474
|
+
it('still rejects unresolved ai assignees during apply-local sync', async () => {
|
|
475
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-unresolved-ai-assignee-');
|
|
476
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
477
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
478
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
479
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
480
|
+
expect(match).toBeTruthy();
|
|
481
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
482
|
+
changes: [
|
|
483
|
+
{
|
|
484
|
+
op: 'upsert',
|
|
485
|
+
archived: false,
|
|
486
|
+
task: {
|
|
487
|
+
id: 'task-unresolved-ai-assignee-1',
|
|
488
|
+
title: 'Task assigned to missing AI profile',
|
|
489
|
+
status: 'task',
|
|
490
|
+
assignee: 'ai-profile-missing'
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
495
|
+
const res = createMockRes();
|
|
496
|
+
await match?.handler(req, res, {});
|
|
497
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
498
|
+
expect(res.end).toHaveBeenCalledWith(expect.stringContaining('references unresolved assignee ai-profile-missing'));
|
|
499
|
+
});
|
|
500
|
+
it('persists archived cloud-pulled tasks into local sqlite archive via apply-local route', async () => {
|
|
501
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-archived-');
|
|
502
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
503
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
504
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
505
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
506
|
+
expect(match).toBeTruthy();
|
|
507
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
508
|
+
changes: [
|
|
509
|
+
{
|
|
510
|
+
op: 'upsert',
|
|
511
|
+
archived: true,
|
|
512
|
+
task: {
|
|
513
|
+
id: 'task-cloud-archived-1',
|
|
514
|
+
title: 'Archived from cloud',
|
|
515
|
+
status: 'done',
|
|
516
|
+
isArchived: true
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
]
|
|
520
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
521
|
+
const res = createMockRes();
|
|
522
|
+
await match?.handler(req, res, {});
|
|
523
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
524
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
525
|
+
const persistedArchive = reloadedCore.listArchive(workspaceId);
|
|
526
|
+
const archivedTask = persistedArchive.archived.find((item) => item.id === 'task-cloud-archived-1');
|
|
527
|
+
const defaultWorkspaceArchive = reloadedCore.listArchive('default').archived;
|
|
528
|
+
expect(archivedTask).toBeTruthy();
|
|
529
|
+
expect(archivedTask?.title).toBe('Archived from cloud');
|
|
530
|
+
expect(archivedTask?.isArchived).toBe(true);
|
|
531
|
+
expect(defaultWorkspaceArchive.find((item) => item.id === 'task-cloud-archived-1')).toBeUndefined();
|
|
532
|
+
});
|
|
533
|
+
it('persists archived task workstream links into local sqlite archive via apply-local route', async () => {
|
|
534
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-archived-workstream-');
|
|
535
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
536
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
537
|
+
core.createInitiative({
|
|
538
|
+
id: 'initiative-archived-sync-1',
|
|
539
|
+
title: 'Archived synced initiative',
|
|
540
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
541
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
542
|
+
}, workspaceId);
|
|
543
|
+
core.createWorkstream({
|
|
544
|
+
id: 'workstream-archived-sync-1',
|
|
545
|
+
initiativeId: 'initiative-archived-sync-1',
|
|
546
|
+
title: 'Archived synced workstream',
|
|
547
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
548
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
549
|
+
}, workspaceId);
|
|
550
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
551
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
552
|
+
expect(match).toBeTruthy();
|
|
553
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
554
|
+
changes: [
|
|
555
|
+
{
|
|
556
|
+
op: 'upsert',
|
|
557
|
+
archived: true,
|
|
558
|
+
task: {
|
|
559
|
+
id: 'task-cloud-archived-linked-1',
|
|
560
|
+
title: 'Archived linked task',
|
|
561
|
+
status: 'done',
|
|
562
|
+
workstreamId: 'workstream-archived-sync-1',
|
|
563
|
+
isArchived: true,
|
|
564
|
+
updatedAt: '2026-03-16T00:27:08.000Z'
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
]
|
|
568
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
569
|
+
const res = createMockRes();
|
|
570
|
+
await match?.handler(req, res, {});
|
|
571
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
572
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
573
|
+
const persistedArchive = reloadedCore.listArchive(workspaceId);
|
|
574
|
+
const archivedTask = persistedArchive.archived.find((item) => item.id === 'task-cloud-archived-linked-1');
|
|
575
|
+
expect(archivedTask).toBeTruthy();
|
|
576
|
+
expect(archivedTask?.isArchived).toBe(true);
|
|
577
|
+
expect(archivedTask?.workstreamId).toBe('workstream-archived-sync-1');
|
|
578
|
+
});
|
|
579
|
+
it('applies multiple terminal sync updates in one batch', async () => {
|
|
580
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-terminal-status-');
|
|
581
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
582
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
583
|
+
core.createTask({
|
|
584
|
+
id: 'task-terminal-1',
|
|
585
|
+
title: 'Terminal task 1',
|
|
586
|
+
status: 'task',
|
|
587
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
588
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
589
|
+
}, {
|
|
590
|
+
workspaceId,
|
|
591
|
+
allowClientProvidedId: true
|
|
592
|
+
});
|
|
593
|
+
core.createTask({
|
|
594
|
+
id: 'task-terminal-2',
|
|
595
|
+
title: 'Terminal task 2',
|
|
596
|
+
status: 'task',
|
|
597
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
598
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
599
|
+
}, {
|
|
600
|
+
workspaceId,
|
|
601
|
+
allowClientProvidedId: true
|
|
602
|
+
});
|
|
603
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
604
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
605
|
+
expect(match).toBeTruthy();
|
|
606
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
607
|
+
changes: [
|
|
608
|
+
{
|
|
609
|
+
op: 'upsert',
|
|
610
|
+
archived: false,
|
|
611
|
+
task: {
|
|
612
|
+
id: 'task-terminal-1',
|
|
613
|
+
title: 'Terminal task 1',
|
|
614
|
+
status: 'done',
|
|
615
|
+
updatedAt: '2026-03-16T00:27:08.000Z'
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
op: 'upsert',
|
|
620
|
+
archived: false,
|
|
621
|
+
task: {
|
|
622
|
+
id: 'task-terminal-2',
|
|
623
|
+
title: 'Terminal task 2',
|
|
624
|
+
status: 'done',
|
|
625
|
+
updatedAt: '2026-03-16T00:27:08.000Z'
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
]
|
|
629
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
630
|
+
const res = createMockRes();
|
|
631
|
+
await match?.handler(req, res, {});
|
|
632
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
633
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
634
|
+
const first = reloadedCore.getTask('task-terminal-1', workspaceId);
|
|
635
|
+
const second = reloadedCore.getTask('task-terminal-2', workspaceId);
|
|
636
|
+
expect(first?.status).toBe('done');
|
|
637
|
+
expect(second?.status).toBe('done');
|
|
638
|
+
});
|
|
639
|
+
it('preserves task workstream links when applying a synced snapshot', async () => {
|
|
640
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-workstream-link-');
|
|
641
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
642
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
643
|
+
core.createInitiative({
|
|
644
|
+
id: 'initiative-sync-1',
|
|
645
|
+
title: 'Synced initiative',
|
|
646
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
647
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
648
|
+
}, workspaceId);
|
|
649
|
+
core.createWorkstream({
|
|
650
|
+
id: 'workstream-sync-1',
|
|
651
|
+
initiativeId: 'initiative-sync-1',
|
|
652
|
+
title: 'Synced workstream',
|
|
653
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
654
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
655
|
+
}, workspaceId);
|
|
656
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
657
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
658
|
+
expect(match).toBeTruthy();
|
|
659
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
660
|
+
changes: [
|
|
661
|
+
{
|
|
662
|
+
op: 'upsert',
|
|
663
|
+
archived: false,
|
|
664
|
+
task: {
|
|
665
|
+
id: 'task-workstream-linked-1',
|
|
666
|
+
title: 'Linked task',
|
|
667
|
+
status: 'task',
|
|
668
|
+
workstreamId: 'workstream-sync-1',
|
|
669
|
+
updatedAt: '2026-03-16T00:27:08.000Z'
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
]
|
|
673
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
674
|
+
const res = createMockRes();
|
|
675
|
+
await match?.handler(req, res, {});
|
|
676
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
677
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
678
|
+
const task = reloadedCore.getTask('task-workstream-linked-1', workspaceId);
|
|
679
|
+
expect(task?.workstreamId).toBe('workstream-sync-1');
|
|
680
|
+
});
|
|
681
|
+
it('persists checklist items when applying a synced snapshot locally', async () => {
|
|
682
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-checklist-apply-');
|
|
683
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
684
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
685
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
686
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
687
|
+
expect(match).toBeTruthy();
|
|
688
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
689
|
+
changes: [
|
|
690
|
+
{
|
|
691
|
+
op: 'upsert',
|
|
692
|
+
archived: false,
|
|
693
|
+
task: {
|
|
694
|
+
id: 'task-checklist-sync-1',
|
|
695
|
+
title: 'Checklist sync task',
|
|
696
|
+
status: 'task',
|
|
697
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
698
|
+
checklistItems: [
|
|
699
|
+
{
|
|
700
|
+
id: 'checklist-sync-1',
|
|
701
|
+
title: 'First synced item',
|
|
702
|
+
isCompleted: false,
|
|
703
|
+
order: 0,
|
|
704
|
+
createdAt: '2026-03-16T00:00:00.000Z',
|
|
705
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
id: 'checklist-sync-2',
|
|
709
|
+
title: 'Second synced item',
|
|
710
|
+
isCompleted: true,
|
|
711
|
+
order: 1,
|
|
712
|
+
createdAt: '2026-03-16T00:00:00.000Z',
|
|
713
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
714
|
+
}
|
|
715
|
+
]
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
]
|
|
719
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
720
|
+
const res = createMockRes();
|
|
721
|
+
await match?.handler(req, res, {});
|
|
722
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
723
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
724
|
+
const task = reloadedCore.getTask('task-checklist-sync-1', workspaceId);
|
|
725
|
+
expect(task?.checklistItems?.map((item) => item.title)).toEqual([
|
|
726
|
+
'First synced item',
|
|
727
|
+
'Second synced item',
|
|
728
|
+
]);
|
|
729
|
+
expect(task?.checklistItems?.map((item) => item.isCompleted)).toEqual([false, true]);
|
|
730
|
+
});
|
|
731
|
+
it('serves synced workstream links back through the live cloud tasks route after push', async () => {
|
|
732
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-cloud-workstream-route-');
|
|
733
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
734
|
+
seedAuthenticatedWorkspace(core);
|
|
735
|
+
core.createInitiative({
|
|
736
|
+
id: 'initiative-cloud-1',
|
|
737
|
+
title: 'Cloud initiative',
|
|
738
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
739
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
740
|
+
}, workspaceId);
|
|
741
|
+
core.createWorkstream({
|
|
742
|
+
id: 'workstream-cloud-1',
|
|
743
|
+
initiativeId: 'initiative-cloud-1',
|
|
744
|
+
title: 'Cloud workstream',
|
|
745
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
746
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
747
|
+
}, workspaceId);
|
|
748
|
+
const routes = createRoutes(core, { authConfig });
|
|
749
|
+
const cookie = createAuthCookie(workspaceId);
|
|
750
|
+
const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
751
|
+
expect(pushMatch).toBeTruthy();
|
|
752
|
+
const pushReq = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
753
|
+
workspaceId,
|
|
754
|
+
idempotencyKey: 'push-cloud-workstream-link-1',
|
|
755
|
+
changes: [
|
|
756
|
+
{
|
|
757
|
+
op: 'upsert',
|
|
758
|
+
archived: false,
|
|
759
|
+
task: {
|
|
760
|
+
id: 'task-cloud-workstream-1',
|
|
761
|
+
title: 'Cloud linked task',
|
|
762
|
+
status: 'task',
|
|
763
|
+
workstreamId: 'workstream-cloud-1',
|
|
764
|
+
updatedAt: '2026-03-16T00:27:08.000Z'
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
]
|
|
768
|
+
}, { cookie });
|
|
769
|
+
const pushRes = createMockRes();
|
|
770
|
+
await pushMatch?.handler(pushReq, pushRes, {});
|
|
771
|
+
expect(pushRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
772
|
+
const tasksMatch = matchRoute('GET', '/api/taskforce/tasks', routes);
|
|
773
|
+
expect(tasksMatch).toBeTruthy();
|
|
774
|
+
const tasksReq = createMockReq('GET', `/api/taskforce/tasks?workspaceId=${workspaceId}`, undefined, { cookie });
|
|
775
|
+
const tasksRes = createMockRes();
|
|
776
|
+
await tasksMatch?.handler(tasksReq, tasksRes, {});
|
|
777
|
+
expect(tasksRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
778
|
+
const payload = readJsonBody(tasksRes);
|
|
779
|
+
const task = Array.isArray(payload?.tasks)
|
|
780
|
+
? payload.tasks.find((entry) => entry?.id === 'task-cloud-workstream-1')
|
|
781
|
+
: null;
|
|
782
|
+
expect(task).toBeTruthy();
|
|
783
|
+
expect(task?.workstreamId).toBe('workstream-cloud-1');
|
|
784
|
+
});
|
|
785
|
+
it('prefers incoming workstream links when timestamps tie but task relationships differ', async () => {
|
|
786
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-workstream-tie-');
|
|
787
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
788
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
789
|
+
core.createInitiative({
|
|
790
|
+
id: 'initiative-sync-2',
|
|
791
|
+
title: 'Tie-break initiative',
|
|
792
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
793
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
794
|
+
}, workspaceId);
|
|
795
|
+
core.createWorkstream({
|
|
796
|
+
id: 'workstream-sync-2',
|
|
797
|
+
initiativeId: 'initiative-sync-2',
|
|
798
|
+
title: 'Tie-break workstream',
|
|
799
|
+
createdAt: '2026-03-15T00:00:00.000Z',
|
|
800
|
+
updatedAt: '2026-03-15T00:00:00.000Z'
|
|
801
|
+
}, workspaceId);
|
|
802
|
+
core.createTask({
|
|
803
|
+
id: 'task-workstream-linked-2',
|
|
804
|
+
title: 'Linked task',
|
|
805
|
+
status: 'task',
|
|
806
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
807
|
+
createdAt: '2026-03-16T00:00:00.000Z'
|
|
808
|
+
}, {
|
|
809
|
+
workspaceId,
|
|
810
|
+
allowClientProvidedId: true,
|
|
811
|
+
syncUpdatedAt: '2026-03-16T00:27:08.000Z',
|
|
812
|
+
});
|
|
813
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
814
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
815
|
+
expect(match).toBeTruthy();
|
|
816
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
817
|
+
changes: [
|
|
818
|
+
{
|
|
819
|
+
op: 'upsert',
|
|
820
|
+
archived: false,
|
|
821
|
+
task: {
|
|
822
|
+
id: 'task-workstream-linked-2',
|
|
823
|
+
title: 'Linked task',
|
|
824
|
+
status: 'task',
|
|
825
|
+
workstreamId: 'workstream-sync-2',
|
|
826
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
827
|
+
createdAt: '2026-03-16T00:00:00.000Z'
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
]
|
|
831
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
832
|
+
const res = createMockRes();
|
|
833
|
+
await match?.handler(req, res, {});
|
|
834
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
835
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
836
|
+
const task = reloadedCore.getTask('task-workstream-linked-2', workspaceId);
|
|
837
|
+
expect(task?.workstreamId).toBe('workstream-sync-2');
|
|
838
|
+
});
|
|
839
|
+
it('prefers incoming checklist changes when timestamps tie but checklist content differs', async () => {
|
|
840
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-checklist-tie-');
|
|
841
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
842
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
843
|
+
core.createTask({
|
|
844
|
+
id: 'task-checklist-sync-2',
|
|
845
|
+
title: 'Checklist tie-break task',
|
|
846
|
+
status: 'task',
|
|
847
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
848
|
+
createdAt: '2026-03-16T00:00:00.000Z',
|
|
849
|
+
checklistItems: [
|
|
850
|
+
{
|
|
851
|
+
id: 'checklist-existing-1',
|
|
852
|
+
taskId: 'task-checklist-sync-2',
|
|
853
|
+
title: 'Old local item',
|
|
854
|
+
isCompleted: false,
|
|
855
|
+
order: 0,
|
|
856
|
+
createdAt: '2026-03-16T00:00:00.000Z',
|
|
857
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
858
|
+
}
|
|
859
|
+
]
|
|
860
|
+
}, { workspaceId, allowClientProvidedId: true });
|
|
861
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
862
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
863
|
+
expect(match).toBeTruthy();
|
|
864
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
865
|
+
changes: [
|
|
866
|
+
{
|
|
867
|
+
op: 'upsert',
|
|
868
|
+
archived: false,
|
|
869
|
+
task: {
|
|
870
|
+
id: 'task-checklist-sync-2',
|
|
871
|
+
title: 'Checklist tie-break task',
|
|
872
|
+
status: 'task',
|
|
873
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
874
|
+
checklistItems: [
|
|
875
|
+
{
|
|
876
|
+
id: 'checklist-incoming-1',
|
|
877
|
+
title: 'Incoming synced item',
|
|
878
|
+
isCompleted: true,
|
|
879
|
+
order: 0,
|
|
880
|
+
createdAt: '2026-03-16T00:00:00.000Z',
|
|
881
|
+
updatedAt: '2026-03-16T00:27:08.000Z',
|
|
882
|
+
}
|
|
883
|
+
]
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
]
|
|
887
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
888
|
+
const res = createMockRes();
|
|
889
|
+
await match?.handler(req, res, {});
|
|
890
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
891
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
892
|
+
const task = reloadedCore.getTask('task-checklist-sync-2', workspaceId);
|
|
893
|
+
expect(task?.checklistItems?.map((item) => item.title)).toEqual(['Incoming synced item']);
|
|
894
|
+
expect(task?.checklistItems?.map((item) => item.isCompleted)).toEqual([true]);
|
|
895
|
+
});
|
|
896
|
+
it('does not let an older pulled snapshot revert a newer local in-progress update', async () => {
|
|
897
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-newer-local-status-');
|
|
898
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
899
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
900
|
+
core.createTask({
|
|
901
|
+
id: 'task-status-1',
|
|
902
|
+
title: 'Task status',
|
|
903
|
+
status: 'task',
|
|
904
|
+
createdAt: '2026-03-16T00:00:00.000Z'
|
|
905
|
+
}, {
|
|
906
|
+
workspaceId,
|
|
907
|
+
allowClientProvidedId: true
|
|
908
|
+
});
|
|
909
|
+
const locallyUpdated = core.updateTask('task-status-1', {
|
|
910
|
+
status: 'in-progress'
|
|
911
|
+
}, workspaceId, { persistBackup: false });
|
|
912
|
+
expect(locallyUpdated?.status).toBe('in-progress');
|
|
913
|
+
expect(String(locallyUpdated?.updatedAt || '')).not.toBe('');
|
|
914
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
915
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
916
|
+
expect(match).toBeTruthy();
|
|
917
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
918
|
+
changes: [
|
|
919
|
+
{
|
|
920
|
+
op: 'upsert',
|
|
921
|
+
archived: false,
|
|
922
|
+
task: {
|
|
923
|
+
id: 'task-status-1',
|
|
924
|
+
title: 'Task status',
|
|
925
|
+
status: 'task',
|
|
926
|
+
updatedAt: '2026-03-16T00:00:01.000Z'
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
]
|
|
930
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
931
|
+
const res = createMockRes();
|
|
932
|
+
await match?.handler(req, res, {});
|
|
933
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
934
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
935
|
+
const persisted = reloadedCore.getTask('task-status-1', workspaceId);
|
|
936
|
+
expect(persisted?.status).toBe('in-progress');
|
|
937
|
+
expect(Date.parse(String(persisted?.updatedAt || ''))).toBeGreaterThan(Date.parse('2026-03-16T00:00:01.000Z'));
|
|
938
|
+
});
|
|
939
|
+
it('applies cloud delete operations to local sqlite across reload via apply-local route', async () => {
|
|
940
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-delete-');
|
|
941
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
942
|
+
seedCore.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
943
|
+
seedCore.updateTaxonomyState(buildSoftwareTaxonomyState(), workspaceId);
|
|
944
|
+
const seeded = seedCore.createTask({
|
|
945
|
+
title: 'Will be deleted by cloud sync',
|
|
946
|
+
category: 'Default',
|
|
947
|
+
type: 'feature',
|
|
948
|
+
priority: 2,
|
|
949
|
+
status: 'task'
|
|
950
|
+
}, { workspaceId });
|
|
951
|
+
expect(seeded.id).toBeTruthy();
|
|
952
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
953
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
954
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
955
|
+
expect(match).toBeTruthy();
|
|
956
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
957
|
+
changes: [
|
|
958
|
+
{ op: 'delete', taskId: seeded.id }
|
|
959
|
+
]
|
|
960
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
961
|
+
const res = createMockRes();
|
|
962
|
+
await match?.handler(req, res, {});
|
|
963
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
964
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
965
|
+
const persisted = reloadedCore.listTasks(workspaceId);
|
|
966
|
+
const deletedTask = persisted.tasks.find((item) => item.id === seeded.id);
|
|
967
|
+
expect(deletedTask).toBeUndefined();
|
|
968
|
+
});
|
|
969
|
+
it('keeps task deleted when apply-local receives same-task upsert and delete in one batch', async () => {
|
|
970
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-delete-wins-');
|
|
971
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
972
|
+
seedCore.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
973
|
+
seedCore.updateTaxonomyState(buildSoftwareTaxonomyState(), workspaceId);
|
|
974
|
+
const seeded = seedCore.createTask({
|
|
975
|
+
title: 'Seed task for delete-wins',
|
|
976
|
+
category: 'Default',
|
|
977
|
+
type: 'feature',
|
|
978
|
+
priority: 2,
|
|
979
|
+
status: 'task'
|
|
980
|
+
}, { workspaceId });
|
|
981
|
+
expect(seeded.id).toBeTruthy();
|
|
982
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
983
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
984
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
985
|
+
expect(match).toBeTruthy();
|
|
986
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
987
|
+
changes: [
|
|
988
|
+
{
|
|
989
|
+
op: 'upsert',
|
|
990
|
+
archived: false,
|
|
991
|
+
task: {
|
|
992
|
+
id: seeded.id,
|
|
993
|
+
title: 'Stale cloud upsert'
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
{ op: 'delete', taskId: seeded.id }
|
|
997
|
+
]
|
|
998
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
999
|
+
const res = createMockRes();
|
|
1000
|
+
await match?.handler(req, res, {});
|
|
1001
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1002
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1003
|
+
const persisted = reloadedCore.listTasks(workspaceId);
|
|
1004
|
+
const shouldRemainDeleted = persisted.tasks.find((item) => item.id === seeded.id);
|
|
1005
|
+
expect(shouldRemainDeleted).toBeUndefined();
|
|
1006
|
+
});
|
|
1007
|
+
it('applies document-delete operations by deleting file and clearing watermark index', async () => {
|
|
1008
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-doc-delete-');
|
|
1009
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1010
|
+
seedCore.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1011
|
+
const syncRoot = seedCore.getPaths().basePath;
|
|
1012
|
+
const docsDir = path.join(syncRoot, 'docs');
|
|
1013
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
1014
|
+
const relativeDocPath = 'docs/sync-delete.md';
|
|
1015
|
+
const absoluteDocPath = path.join(syncRoot, relativeDocPath);
|
|
1016
|
+
fs.writeFileSync(absoluteDocPath, '# sync delete\n', 'utf8');
|
|
1017
|
+
seedCore.upsertDocumentWatermark(workspaceId, relativeDocPath, 'seed-hash', '2026-02-23T00:00:00.000Z');
|
|
1018
|
+
expect(seedCore.getDocumentWatermark(workspaceId, relativeDocPath)).toBeTruthy();
|
|
1019
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1020
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1021
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1022
|
+
expect(match).toBeTruthy();
|
|
1023
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1024
|
+
changes: [
|
|
1025
|
+
{ op: 'document-delete', path: relativeDocPath }
|
|
1026
|
+
]
|
|
1027
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1028
|
+
const res = createMockRes();
|
|
1029
|
+
await match?.handler(req, res, {});
|
|
1030
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1031
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1032
|
+
expect(fs.existsSync(absoluteDocPath)).toBe(false);
|
|
1033
|
+
expect(reloadedCore.getDocumentWatermark(workspaceId, relativeDocPath)).toBeNull();
|
|
1034
|
+
});
|
|
1035
|
+
it('persists canonical markdown asset metadata across reload via apply-local route', async () => {
|
|
1036
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-doc-asset-');
|
|
1037
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1038
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1039
|
+
core.createTask({
|
|
1040
|
+
id: 'task-doc-asset-1',
|
|
1041
|
+
title: 'Task with synced markdown asset',
|
|
1042
|
+
status: 'task'
|
|
1043
|
+
}, {
|
|
1044
|
+
workspaceId,
|
|
1045
|
+
allowClientProvidedId: true
|
|
1046
|
+
});
|
|
1047
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1048
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1049
|
+
expect(match).toBeTruthy();
|
|
1050
|
+
const relativeDocPath = 'docs/phase1-sync.md';
|
|
1051
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1052
|
+
changes: [
|
|
1053
|
+
{
|
|
1054
|
+
op: 'document-upsert',
|
|
1055
|
+
path: relativeDocPath,
|
|
1056
|
+
updatedAt: '2026-03-17T12:00:00.000Z',
|
|
1057
|
+
content: '# canonical markdown\n',
|
|
1058
|
+
assetId: 'asset-doc-sync-1',
|
|
1059
|
+
documentId: 'doc-sync-1',
|
|
1060
|
+
version: 3,
|
|
1061
|
+
taskId: 'task-doc-asset-1',
|
|
1062
|
+
originalFilename: 'phase1-sync.md',
|
|
1063
|
+
linkRole: 'attachment'
|
|
1064
|
+
}
|
|
1065
|
+
]
|
|
1066
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1067
|
+
const res = createMockRes();
|
|
1068
|
+
await match?.handler(req, res, {});
|
|
1069
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1070
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1071
|
+
const absoluteDocPath = path.join(reloadedCore.getPaths().basePath, relativeDocPath);
|
|
1072
|
+
const store = new WorkspaceAssetStore(reloadedCore.getDatabaseAdapter(), reloadedCore.getTenantId());
|
|
1073
|
+
const asset = store.get('asset-doc-sync-1', workspaceId);
|
|
1074
|
+
const links = store.listLinksForAsset('asset-doc-sync-1', workspaceId);
|
|
1075
|
+
expect(fs.readFileSync(absoluteDocPath, 'utf8')).toBe('# canonical markdown\n');
|
|
1076
|
+
expect(reloadedCore.getDocumentWatermark(workspaceId, relativeDocPath)).toEqual({
|
|
1077
|
+
updatedAt: '2026-03-17T12:00:00.000Z',
|
|
1078
|
+
contentHash: expect.any(String)
|
|
1079
|
+
});
|
|
1080
|
+
expect(asset).toEqual(expect.objectContaining({
|
|
1081
|
+
assetId: 'asset-doc-sync-1',
|
|
1082
|
+
workspaceId,
|
|
1083
|
+
kind: 'document',
|
|
1084
|
+
storageProvider: 'local',
|
|
1085
|
+
storageKey: relativeDocPath,
|
|
1086
|
+
documentId: 'doc-sync-1',
|
|
1087
|
+
version: 3,
|
|
1088
|
+
originalFilename: 'phase1-sync.md',
|
|
1089
|
+
mimeType: 'text/markdown',
|
|
1090
|
+
deletedAt: null
|
|
1091
|
+
}));
|
|
1092
|
+
expect(links).toEqual([
|
|
1093
|
+
expect.objectContaining({
|
|
1094
|
+
assetId: 'asset-doc-sync-1',
|
|
1095
|
+
workspaceId,
|
|
1096
|
+
taskId: 'task-doc-asset-1',
|
|
1097
|
+
role: 'attachment',
|
|
1098
|
+
deletedAt: null
|
|
1099
|
+
})
|
|
1100
|
+
]);
|
|
1101
|
+
});
|
|
1102
|
+
it('retries document asset reference allocation during apply-local when persistence hits a collision', async () => {
|
|
1103
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-doc-asset-collision-');
|
|
1104
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1105
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1106
|
+
core.createTask({
|
|
1107
|
+
id: 'task-doc-asset-collision-1',
|
|
1108
|
+
title: 'Task with collided synced markdown asset',
|
|
1109
|
+
status: 'task'
|
|
1110
|
+
}, {
|
|
1111
|
+
workspaceId,
|
|
1112
|
+
allowClientProvidedId: true
|
|
1113
|
+
});
|
|
1114
|
+
const db = core.getDatabaseAdapter();
|
|
1115
|
+
const seededStore = new WorkspaceAssetStore(db, core.getTenantId());
|
|
1116
|
+
seededStore.upsertAsset({
|
|
1117
|
+
assetId: 'asset-doc-existing-collision',
|
|
1118
|
+
workspaceId,
|
|
1119
|
+
kind: 'document',
|
|
1120
|
+
originalFilename: 'existing.md',
|
|
1121
|
+
mimeType: 'text/markdown',
|
|
1122
|
+
storageProvider: 'local',
|
|
1123
|
+
storageKey: 'docs/existing-collision.md',
|
|
1124
|
+
contentSha256: 'sha-existing-collision',
|
|
1125
|
+
sizeBytes: 10,
|
|
1126
|
+
referenceNumber: 7,
|
|
1127
|
+
createdAt: '2026-03-17T11:55:00.000Z',
|
|
1128
|
+
updatedAt: '2026-03-17T11:55:00.000Z'
|
|
1129
|
+
});
|
|
1130
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1131
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1132
|
+
expect(match).toBeTruthy();
|
|
1133
|
+
const relativeDocPath = 'docs/phase1-sync-collision.md';
|
|
1134
|
+
const originalPrepare = db.prepare.bind(db);
|
|
1135
|
+
let forcedCollision = false;
|
|
1136
|
+
vi.spyOn(db, 'prepare').mockImplementation((...args) => {
|
|
1137
|
+
const [sql] = args;
|
|
1138
|
+
const statement = originalPrepare(...args);
|
|
1139
|
+
if (typeof sql !== 'string') {
|
|
1140
|
+
return statement;
|
|
1141
|
+
}
|
|
1142
|
+
if (!sql.includes('INSERT INTO workspace_assets')) {
|
|
1143
|
+
return statement;
|
|
1144
|
+
}
|
|
1145
|
+
return {
|
|
1146
|
+
...statement,
|
|
1147
|
+
run: (...args) => {
|
|
1148
|
+
const storageKeyArg = args[8];
|
|
1149
|
+
const referenceNumberArg = args[12];
|
|
1150
|
+
if (!forcedCollision
|
|
1151
|
+
&& storageKeyArg === relativeDocPath
|
|
1152
|
+
&& referenceNumberArg === 8) {
|
|
1153
|
+
forcedCollision = true;
|
|
1154
|
+
throw new Error('UNIQUE constraint failed: workspace_assets.tenant_id, workspace_assets.workspace_id, workspace_assets.kind, workspace_assets.reference_number');
|
|
1155
|
+
}
|
|
1156
|
+
return statement.run(...args);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
});
|
|
1160
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1161
|
+
changes: [
|
|
1162
|
+
{
|
|
1163
|
+
op: 'document-upsert',
|
|
1164
|
+
path: relativeDocPath,
|
|
1165
|
+
updatedAt: '2026-03-17T12:00:00.000Z',
|
|
1166
|
+
content: '# canonical markdown after collision\n',
|
|
1167
|
+
assetId: 'asset-doc-sync-collision-1',
|
|
1168
|
+
documentId: 'doc-sync-collision-1',
|
|
1169
|
+
referenceNumber: 8,
|
|
1170
|
+
version: 3,
|
|
1171
|
+
taskId: 'task-doc-asset-collision-1',
|
|
1172
|
+
originalFilename: 'phase1-sync-collision.md',
|
|
1173
|
+
linkRole: 'attachment'
|
|
1174
|
+
}
|
|
1175
|
+
]
|
|
1176
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1177
|
+
const res = createMockRes();
|
|
1178
|
+
await match?.handler(req, res, {});
|
|
1179
|
+
expect(forcedCollision).toBe(true);
|
|
1180
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1181
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1182
|
+
const absoluteDocPath = path.join(reloadedCore.getPaths().basePath, relativeDocPath);
|
|
1183
|
+
const store = new WorkspaceAssetStore(reloadedCore.getDatabaseAdapter(), reloadedCore.getTenantId());
|
|
1184
|
+
const asset = store.get('asset-doc-sync-collision-1', workspaceId);
|
|
1185
|
+
const links = store.listLinksForAsset('asset-doc-sync-collision-1', workspaceId);
|
|
1186
|
+
const syncEvents = reloadedCore.listSyncEvents(workspaceId, 20);
|
|
1187
|
+
expect(fs.readFileSync(absoluteDocPath, 'utf8')).toBe('# canonical markdown after collision\n');
|
|
1188
|
+
expect(asset).toEqual(expect.objectContaining({
|
|
1189
|
+
assetId: 'asset-doc-sync-collision-1',
|
|
1190
|
+
workspaceId,
|
|
1191
|
+
kind: 'document',
|
|
1192
|
+
storageKey: relativeDocPath,
|
|
1193
|
+
documentId: 'doc-sync-collision-1',
|
|
1194
|
+
version: 3,
|
|
1195
|
+
referenceNumber: 9,
|
|
1196
|
+
deletedAt: null
|
|
1197
|
+
}));
|
|
1198
|
+
expect(links).toEqual([
|
|
1199
|
+
expect.objectContaining({
|
|
1200
|
+
assetId: 'asset-doc-sync-collision-1',
|
|
1201
|
+
workspaceId,
|
|
1202
|
+
taskId: 'task-doc-asset-collision-1',
|
|
1203
|
+
role: 'attachment',
|
|
1204
|
+
deletedAt: null
|
|
1205
|
+
})
|
|
1206
|
+
]);
|
|
1207
|
+
expect(syncEvents.some((event) => event.errorMessage === 'Failed to persist document watermark after write.')).toBe(false);
|
|
1208
|
+
});
|
|
1209
|
+
it('persists canonical binary assets across reload via apply-local route', async () => {
|
|
1210
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-binary-asset-');
|
|
1211
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1212
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1213
|
+
core.createTask({
|
|
1214
|
+
id: 'task-binary-asset-1',
|
|
1215
|
+
title: 'Task with synced binary asset',
|
|
1216
|
+
status: 'task'
|
|
1217
|
+
}, {
|
|
1218
|
+
workspaceId,
|
|
1219
|
+
allowClientProvidedId: true
|
|
1220
|
+
});
|
|
1221
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1222
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1223
|
+
expect(match).toBeTruthy();
|
|
1224
|
+
const relativeAssetPath = 'workspaces/workspace-local-active/assets/images/asset-binary-sync-1-synced-image.png';
|
|
1225
|
+
const assetBuffer = Buffer.from('binary-image-content');
|
|
1226
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1227
|
+
changes: [
|
|
1228
|
+
{
|
|
1229
|
+
op: 'asset-upsert',
|
|
1230
|
+
path: relativeAssetPath,
|
|
1231
|
+
updatedAt: '2026-03-17T12:05:00.000Z',
|
|
1232
|
+
contentBase64: assetBuffer.toString('base64'),
|
|
1233
|
+
assetId: 'asset-binary-sync-1',
|
|
1234
|
+
kind: 'image',
|
|
1235
|
+
mimeType: 'image/png',
|
|
1236
|
+
taskId: 'task-binary-asset-1',
|
|
1237
|
+
originalFilename: 'synced-image.png',
|
|
1238
|
+
linkRole: 'image'
|
|
1239
|
+
}
|
|
1240
|
+
]
|
|
1241
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1242
|
+
const res = createMockRes();
|
|
1243
|
+
await match?.handler(req, res, {});
|
|
1244
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1245
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1246
|
+
const absoluteAssetPath = path.join(reloadedCore.getPaths().basePath, relativeAssetPath);
|
|
1247
|
+
const store = new WorkspaceAssetStore(reloadedCore.getDatabaseAdapter(), reloadedCore.getTenantId());
|
|
1248
|
+
const asset = store.get('asset-binary-sync-1', workspaceId);
|
|
1249
|
+
const links = store.listLinksForAsset('asset-binary-sync-1', workspaceId);
|
|
1250
|
+
expect(fs.readFileSync(absoluteAssetPath)).toEqual(assetBuffer);
|
|
1251
|
+
expect(asset).toEqual(expect.objectContaining({
|
|
1252
|
+
assetId: 'asset-binary-sync-1',
|
|
1253
|
+
workspaceId,
|
|
1254
|
+
kind: 'image',
|
|
1255
|
+
storageProvider: 'local',
|
|
1256
|
+
storageKey: relativeAssetPath,
|
|
1257
|
+
originalFilename: 'synced-image.png',
|
|
1258
|
+
mimeType: 'image/png',
|
|
1259
|
+
deletedAt: null
|
|
1260
|
+
}));
|
|
1261
|
+
expect(links).toEqual([
|
|
1262
|
+
expect.objectContaining({
|
|
1263
|
+
assetId: 'asset-binary-sync-1',
|
|
1264
|
+
workspaceId,
|
|
1265
|
+
taskId: 'task-binary-asset-1',
|
|
1266
|
+
role: 'image',
|
|
1267
|
+
deletedAt: null
|
|
1268
|
+
})
|
|
1269
|
+
]);
|
|
1270
|
+
});
|
|
1271
|
+
it('round-trips cloud attachment links for canonical markdown and file assets into local apply', async () => {
|
|
1272
|
+
const cloudProjectRoot = createIsolatedProjectRoot('taskforce-sync-cloud-link-roundtrip-cloud-');
|
|
1273
|
+
const localProjectRoot = createIsolatedProjectRoot('taskforce-sync-cloud-link-roundtrip-local-');
|
|
1274
|
+
const cloudCore = new TaskforceCore({ projectRoot: cloudProjectRoot, storagePath: '.taskforce' });
|
|
1275
|
+
seedAuthenticatedWorkspace(cloudCore);
|
|
1276
|
+
cloudCore.createTask({
|
|
1277
|
+
id: 'task-cloud-link-roundtrip-1',
|
|
1278
|
+
title: 'Cloud attachment source',
|
|
1279
|
+
status: 'task'
|
|
1280
|
+
}, {
|
|
1281
|
+
workspaceId,
|
|
1282
|
+
allowClientProvidedId: true
|
|
1283
|
+
});
|
|
1284
|
+
const cloudBasePath = cloudCore.getPaths().basePath;
|
|
1285
|
+
const cloudStore = new WorkspaceAssetStore(cloudCore.getDatabaseAdapter(), cloudCore.getTenantId());
|
|
1286
|
+
const markdownPath = `workspaces/${workspaceId}/assets/documents/asset-cloud-link-roundtrip-plan.md`;
|
|
1287
|
+
const filePath = `workspaces/${workspaceId}/assets/files/asset-cloud-link-roundtrip-notes.txt`;
|
|
1288
|
+
fs.mkdirSync(path.dirname(path.join(cloudBasePath, markdownPath)), { recursive: true });
|
|
1289
|
+
fs.mkdirSync(path.dirname(path.join(cloudBasePath, filePath)), { recursive: true });
|
|
1290
|
+
fs.writeFileSync(path.join(cloudBasePath, markdownPath), '# synced from cloud\n', 'utf8');
|
|
1291
|
+
fs.writeFileSync(path.join(cloudBasePath, filePath), 'cloud text attachment', 'utf8');
|
|
1292
|
+
cloudStore.upsertAsset({
|
|
1293
|
+
assetId: 'asset-cloud-link-roundtrip-doc-1',
|
|
1294
|
+
workspaceId,
|
|
1295
|
+
kind: 'document',
|
|
1296
|
+
originalFilename: 'plan.md',
|
|
1297
|
+
mimeType: 'text/markdown',
|
|
1298
|
+
storageProvider: 'local',
|
|
1299
|
+
storageKey: markdownPath,
|
|
1300
|
+
contentSha256: 'doc-roundtrip-hash',
|
|
1301
|
+
sizeBytes: '# synced from cloud\n'.length,
|
|
1302
|
+
documentId: 'doc-cloud-link-roundtrip-1',
|
|
1303
|
+
version: 1,
|
|
1304
|
+
isLatest: true,
|
|
1305
|
+
createdAt: '2026-03-18T09:00:00.000Z',
|
|
1306
|
+
updatedAt: '2026-03-18T09:00:00.000Z'
|
|
1307
|
+
});
|
|
1308
|
+
cloudStore.upsertLink({
|
|
1309
|
+
workspaceId,
|
|
1310
|
+
assetId: 'asset-cloud-link-roundtrip-doc-1',
|
|
1311
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1312
|
+
role: 'attachment',
|
|
1313
|
+
updatedAt: '2026-03-18T10:00:00.000Z'
|
|
1314
|
+
});
|
|
1315
|
+
cloudStore.upsertAsset({
|
|
1316
|
+
assetId: 'asset-cloud-link-roundtrip-file-1',
|
|
1317
|
+
workspaceId,
|
|
1318
|
+
kind: 'file',
|
|
1319
|
+
originalFilename: 'notes.txt',
|
|
1320
|
+
mimeType: 'text/plain',
|
|
1321
|
+
storageProvider: 'local',
|
|
1322
|
+
storageKey: filePath,
|
|
1323
|
+
contentSha256: 'file-roundtrip-hash',
|
|
1324
|
+
sizeBytes: 'cloud text attachment'.length,
|
|
1325
|
+
isLatest: true,
|
|
1326
|
+
createdAt: '2026-03-18T09:00:00.000Z',
|
|
1327
|
+
updatedAt: '2026-03-18T09:00:00.000Z'
|
|
1328
|
+
});
|
|
1329
|
+
cloudStore.upsertLink({
|
|
1330
|
+
workspaceId,
|
|
1331
|
+
assetId: 'asset-cloud-link-roundtrip-file-1',
|
|
1332
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1333
|
+
role: 'attachment',
|
|
1334
|
+
updatedAt: '2026-03-18T10:00:01.000Z'
|
|
1335
|
+
});
|
|
1336
|
+
const cloudRoutes = createRoutes(cloudCore, { authConfig });
|
|
1337
|
+
const pullMatch = matchRoute('GET', '/api/taskforce/sync/workspace/pull', cloudRoutes);
|
|
1338
|
+
expect(pullMatch).toBeTruthy();
|
|
1339
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1340
|
+
const pullReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-local-active&limit=50&cursor=eyJ1IjoiMjAyNi0wMy0xOFQwOTozMDowMC4wMDBaIiwiaWQiOiJzZWVkIn0', undefined, { cookie });
|
|
1341
|
+
const pullRes = createMockRes();
|
|
1342
|
+
await pullMatch?.handler(pullReq, pullRes, {});
|
|
1343
|
+
expect(pullRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1344
|
+
const pullBody = readJsonBody(pullRes);
|
|
1345
|
+
const pulledChanges = Array.isArray(pullBody?.changes) ? pullBody.changes : [];
|
|
1346
|
+
expect(pulledChanges).toEqual(expect.arrayContaining([
|
|
1347
|
+
expect.objectContaining({
|
|
1348
|
+
op: 'document-upsert',
|
|
1349
|
+
path: markdownPath,
|
|
1350
|
+
assetId: 'asset-cloud-link-roundtrip-doc-1',
|
|
1351
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1352
|
+
originalFilename: 'plan.md',
|
|
1353
|
+
linkRole: 'attachment'
|
|
1354
|
+
}),
|
|
1355
|
+
expect.objectContaining({
|
|
1356
|
+
op: 'asset-upsert',
|
|
1357
|
+
path: filePath,
|
|
1358
|
+
assetId: 'asset-cloud-link-roundtrip-file-1',
|
|
1359
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1360
|
+
originalFilename: 'notes.txt',
|
|
1361
|
+
linkRole: 'attachment'
|
|
1362
|
+
})
|
|
1363
|
+
]));
|
|
1364
|
+
const localCore = new TaskforceCore({ projectRoot: localProjectRoot, storagePath: '.taskforce' });
|
|
1365
|
+
localCore.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1366
|
+
localCore.createTask({
|
|
1367
|
+
id: 'task-cloud-link-roundtrip-1',
|
|
1368
|
+
title: 'Local attachment target',
|
|
1369
|
+
status: 'task'
|
|
1370
|
+
}, {
|
|
1371
|
+
workspaceId,
|
|
1372
|
+
allowClientProvidedId: true
|
|
1373
|
+
});
|
|
1374
|
+
const localRoutes = createRoutes(localCore, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1375
|
+
const applyMatch = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', localRoutes);
|
|
1376
|
+
expect(applyMatch).toBeTruthy();
|
|
1377
|
+
const applyReq = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', { changes: pulledChanges }, { 'x-taskforce-workspace-id': workspaceId });
|
|
1378
|
+
const applyRes = createMockRes();
|
|
1379
|
+
await applyMatch?.handler(applyReq, applyRes, {});
|
|
1380
|
+
expect(applyRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1381
|
+
const reloadedLocalCore = new TaskforceCore({ projectRoot: localProjectRoot, storagePath: '.taskforce' });
|
|
1382
|
+
const reloadedStore = new WorkspaceAssetStore(reloadedLocalCore.getDatabaseAdapter(), reloadedLocalCore.getTenantId());
|
|
1383
|
+
expect(fs.readFileSync(path.join(reloadedLocalCore.getPaths().basePath, markdownPath), 'utf8')).toBe('# synced from cloud\n');
|
|
1384
|
+
expect(fs.readFileSync(path.join(reloadedLocalCore.getPaths().basePath, filePath), 'utf8')).toBe('cloud text attachment');
|
|
1385
|
+
expect(reloadedStore.listLinksForAsset('asset-cloud-link-roundtrip-doc-1', workspaceId)).toEqual([
|
|
1386
|
+
expect.objectContaining({
|
|
1387
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1388
|
+
role: 'attachment',
|
|
1389
|
+
deletedAt: null
|
|
1390
|
+
})
|
|
1391
|
+
]);
|
|
1392
|
+
expect(reloadedStore.listLinksForAsset('asset-cloud-link-roundtrip-file-1', workspaceId)).toEqual([
|
|
1393
|
+
expect.objectContaining({
|
|
1394
|
+
taskId: 'task-cloud-link-roundtrip-1',
|
|
1395
|
+
role: 'attachment',
|
|
1396
|
+
deletedAt: null
|
|
1397
|
+
})
|
|
1398
|
+
]);
|
|
1399
|
+
});
|
|
1400
|
+
it('propagates document-delete through authenticated push and omits deleted doc from pull payload', async () => {
|
|
1401
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-push-pull-doc-delete-');
|
|
1402
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1403
|
+
seedAuthenticatedWorkspace(seedCore);
|
|
1404
|
+
const syncRoot = seedCore.getPaths().basePath;
|
|
1405
|
+
const docsDir = path.join(syncRoot, 'docs');
|
|
1406
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
1407
|
+
const relativeDocPath = 'docs/sync-delete-push.md';
|
|
1408
|
+
const absoluteDocPath = path.join(syncRoot, relativeDocPath);
|
|
1409
|
+
fs.writeFileSync(absoluteDocPath, '# from local\n', 'utf8');
|
|
1410
|
+
seedCore.upsertDocumentWatermark(workspaceId, relativeDocPath, 'seed-hash', '2026-02-23T00:00:00.000Z');
|
|
1411
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1412
|
+
const routes = createRoutes(core, { authConfig });
|
|
1413
|
+
const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
1414
|
+
const pullMatch = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
1415
|
+
expect(pushMatch).toBeTruthy();
|
|
1416
|
+
expect(pullMatch).toBeTruthy();
|
|
1417
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1418
|
+
const pushReq = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
1419
|
+
workspaceId,
|
|
1420
|
+
idempotencyKey: 'doc-delete-integration-1',
|
|
1421
|
+
changes: [
|
|
1422
|
+
{ op: 'document-delete', path: relativeDocPath }
|
|
1423
|
+
]
|
|
1424
|
+
}, { cookie });
|
|
1425
|
+
const pushRes = createMockRes();
|
|
1426
|
+
await pushMatch?.handler(pushReq, pushRes, {});
|
|
1427
|
+
expect(pushRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1428
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1429
|
+
expect(fs.existsSync(absoluteDocPath)).toBe(false);
|
|
1430
|
+
expect(reloadedCore.getDocumentWatermark(workspaceId, relativeDocPath)).toBeNull();
|
|
1431
|
+
const pullReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-local-active&limit=200', undefined, { cookie });
|
|
1432
|
+
const pullRes = createMockRes();
|
|
1433
|
+
await pullMatch?.handler(pullReq, pullRes, {});
|
|
1434
|
+
expect(pullRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1435
|
+
const pullBody = readJsonBody(pullRes);
|
|
1436
|
+
const documentUpserts = (Array.isArray(pullBody?.changes) ? pullBody.changes : [])
|
|
1437
|
+
.filter((change) => String(change?.op || '').toLowerCase() === 'document-upsert');
|
|
1438
|
+
expect(documentUpserts.find((change) => change.path === relativeDocPath)).toBeUndefined();
|
|
1439
|
+
});
|
|
1440
|
+
it('round-trips task comments through authenticated push and pull payloads', async () => {
|
|
1441
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-push-pull-comments-');
|
|
1442
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1443
|
+
seedAuthenticatedWorkspace(seedCore);
|
|
1444
|
+
const created = seedCore.createTask({
|
|
1445
|
+
title: 'Comment sync task',
|
|
1446
|
+
status: 'task'
|
|
1447
|
+
}, { workspaceId });
|
|
1448
|
+
seedCore.addComment(created.id, 'Synced activity comment', 'user', workspaceId);
|
|
1449
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1450
|
+
const routes = createRoutes(core, { authConfig });
|
|
1451
|
+
const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
1452
|
+
const pullMatch = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
1453
|
+
expect(pushMatch).toBeTruthy();
|
|
1454
|
+
expect(pullMatch).toBeTruthy();
|
|
1455
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1456
|
+
const currentTask = core.getTask(created.id, workspaceId);
|
|
1457
|
+
expect(currentTask?.comments).toHaveLength(1);
|
|
1458
|
+
expect(currentTask?.comments?.[0]?.submittedAuthor).toBe('user');
|
|
1459
|
+
const pushReq = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
1460
|
+
workspaceId,
|
|
1461
|
+
idempotencyKey: 'comment-roundtrip-1',
|
|
1462
|
+
changes: [
|
|
1463
|
+
{
|
|
1464
|
+
op: 'upsert',
|
|
1465
|
+
archived: false,
|
|
1466
|
+
task: currentTask
|
|
1467
|
+
}
|
|
1468
|
+
]
|
|
1469
|
+
}, { cookie });
|
|
1470
|
+
const pushRes = createMockRes();
|
|
1471
|
+
await pushMatch?.handler(pushReq, pushRes, {});
|
|
1472
|
+
expect(pushRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1473
|
+
const pullReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-local-active&limit=200', undefined, { cookie });
|
|
1474
|
+
const pullRes = createMockRes();
|
|
1475
|
+
await pullMatch?.handler(pullReq, pullRes, {});
|
|
1476
|
+
expect(pullRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1477
|
+
const pullBody = readJsonBody(pullRes);
|
|
1478
|
+
const upsert = (Array.isArray(pullBody?.changes) ? pullBody.changes : [])
|
|
1479
|
+
.find((change) => change?.op === 'upsert' && change?.task?.id === created.id);
|
|
1480
|
+
expect(upsert?.task?.comments).toEqual([
|
|
1481
|
+
expect.objectContaining({
|
|
1482
|
+
id: expect.any(String),
|
|
1483
|
+
author: 'user',
|
|
1484
|
+
submittedAuthor: 'user',
|
|
1485
|
+
text: 'Synced activity comment',
|
|
1486
|
+
timestamp: expect.any(String)
|
|
1487
|
+
})
|
|
1488
|
+
]);
|
|
1489
|
+
});
|
|
1490
|
+
it('keeps document deleted when push batch includes upsert and delete for same path', async () => {
|
|
1491
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-push-doc-delete-wins-');
|
|
1492
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1493
|
+
seedAuthenticatedWorkspace(seedCore);
|
|
1494
|
+
const syncRoot = seedCore.getPaths().basePath;
|
|
1495
|
+
const docsDir = path.join(syncRoot, 'docs');
|
|
1496
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
1497
|
+
const relativeDocPath = 'docs/sync-delete-wins.md';
|
|
1498
|
+
const absoluteDocPath = path.join(syncRoot, relativeDocPath);
|
|
1499
|
+
fs.writeFileSync(absoluteDocPath, '# old\n', 'utf8');
|
|
1500
|
+
seedCore.upsertDocumentWatermark(workspaceId, relativeDocPath, 'seed-hash', '2026-02-23T00:00:00.000Z');
|
|
1501
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1502
|
+
const routes = createRoutes(core, { authConfig });
|
|
1503
|
+
const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
1504
|
+
expect(pushMatch).toBeTruthy();
|
|
1505
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1506
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
1507
|
+
workspaceId,
|
|
1508
|
+
idempotencyKey: 'doc-delete-integration-2',
|
|
1509
|
+
changes: [
|
|
1510
|
+
{
|
|
1511
|
+
op: 'document-upsert',
|
|
1512
|
+
path: relativeDocPath,
|
|
1513
|
+
updatedAt: '2026-02-23T10:00:00.000Z',
|
|
1514
|
+
content: '# stale upsert'
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
op: 'document-delete',
|
|
1518
|
+
path: relativeDocPath
|
|
1519
|
+
}
|
|
1520
|
+
]
|
|
1521
|
+
}, { cookie });
|
|
1522
|
+
const res = createMockRes();
|
|
1523
|
+
await pushMatch?.handler(req, res, {});
|
|
1524
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1525
|
+
expect(fs.existsSync(absoluteDocPath)).toBe(false);
|
|
1526
|
+
expect(core.getDocumentWatermark(workspaceId, relativeDocPath)).toBeNull();
|
|
1527
|
+
});
|
|
1528
|
+
it('propagates binary asset deletes through authenticated push and persists canonical tombstones', async () => {
|
|
1529
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-push-pull-asset-delete-');
|
|
1530
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1531
|
+
seedAuthenticatedWorkspace(seedCore);
|
|
1532
|
+
seedCore.createTask({
|
|
1533
|
+
id: 'task-asset-delete-1',
|
|
1534
|
+
title: 'Asset delete task',
|
|
1535
|
+
status: 'task'
|
|
1536
|
+
}, {
|
|
1537
|
+
workspaceId,
|
|
1538
|
+
allowClientProvidedId: true
|
|
1539
|
+
});
|
|
1540
|
+
const relativeAssetPath = 'workspaces/workspace-local-active/assets/files/asset-delete-auth-1-delete-me.bin';
|
|
1541
|
+
const absoluteAssetPath = path.join(seedCore.getPaths().basePath, relativeAssetPath);
|
|
1542
|
+
fs.mkdirSync(path.dirname(absoluteAssetPath), { recursive: true });
|
|
1543
|
+
fs.writeFileSync(absoluteAssetPath, Buffer.from('delete-me'));
|
|
1544
|
+
const seededStore = new WorkspaceAssetStore(seedCore.getDatabaseAdapter(), seedCore.getTenantId());
|
|
1545
|
+
seededStore.upsertAsset({
|
|
1546
|
+
assetId: 'asset-delete-auth-1',
|
|
1547
|
+
workspaceId,
|
|
1548
|
+
kind: 'file',
|
|
1549
|
+
originalFilename: 'delete-me.bin',
|
|
1550
|
+
mimeType: 'application/octet-stream',
|
|
1551
|
+
storageProvider: 'local',
|
|
1552
|
+
storageKey: relativeAssetPath,
|
|
1553
|
+
contentSha256: 'seed-hash',
|
|
1554
|
+
sizeBytes: 9,
|
|
1555
|
+
createdAt: '2026-03-17T11:00:00.000Z',
|
|
1556
|
+
updatedAt: '2026-03-17T11:00:00.000Z'
|
|
1557
|
+
});
|
|
1558
|
+
seededStore.upsertLink({
|
|
1559
|
+
workspaceId,
|
|
1560
|
+
assetId: 'asset-delete-auth-1',
|
|
1561
|
+
taskId: 'task-asset-delete-1',
|
|
1562
|
+
role: 'attachment',
|
|
1563
|
+
updatedAt: '2026-03-17T11:00:00.000Z'
|
|
1564
|
+
});
|
|
1565
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1566
|
+
const routes = createRoutes(core, { authConfig });
|
|
1567
|
+
const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
|
|
1568
|
+
const pullMatch = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
|
|
1569
|
+
expect(pushMatch).toBeTruthy();
|
|
1570
|
+
expect(pullMatch).toBeTruthy();
|
|
1571
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1572
|
+
const pushReq = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
|
|
1573
|
+
workspaceId,
|
|
1574
|
+
idempotencyKey: 'asset-delete-integration-1',
|
|
1575
|
+
changes: [
|
|
1576
|
+
{
|
|
1577
|
+
op: 'asset-delete',
|
|
1578
|
+
path: relativeAssetPath,
|
|
1579
|
+
assetId: 'asset-delete-auth-1'
|
|
1580
|
+
}
|
|
1581
|
+
]
|
|
1582
|
+
}, { cookie });
|
|
1583
|
+
const pushRes = createMockRes();
|
|
1584
|
+
await pushMatch?.handler(pushReq, pushRes, {});
|
|
1585
|
+
expect(pushRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1586
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1587
|
+
const reloadedStore = new WorkspaceAssetStore(reloadedCore.getDatabaseAdapter(), reloadedCore.getTenantId());
|
|
1588
|
+
const deletedAsset = reloadedStore.get('asset-delete-auth-1', workspaceId);
|
|
1589
|
+
const deletedLinks = reloadedStore.listLinksForAsset('asset-delete-auth-1', workspaceId, { includeDeleted: true });
|
|
1590
|
+
expect(fs.existsSync(absoluteAssetPath)).toBe(false);
|
|
1591
|
+
expect(deletedAsset?.deletedAt).toEqual(expect.any(String));
|
|
1592
|
+
expect(deletedLinks).toEqual([
|
|
1593
|
+
expect.objectContaining({
|
|
1594
|
+
assetId: 'asset-delete-auth-1',
|
|
1595
|
+
workspaceId,
|
|
1596
|
+
taskId: 'task-asset-delete-1',
|
|
1597
|
+
role: 'attachment',
|
|
1598
|
+
deletedAt: expect.any(String)
|
|
1599
|
+
})
|
|
1600
|
+
]);
|
|
1601
|
+
const pullReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-local-active&limit=200', undefined, { cookie });
|
|
1602
|
+
const pullRes = createMockRes();
|
|
1603
|
+
await pullMatch?.handler(pullReq, pullRes, {});
|
|
1604
|
+
expect(pullRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1605
|
+
const pullBody = readJsonBody(pullRes);
|
|
1606
|
+
const assetUpserts = (Array.isArray(pullBody?.changes) ? pullBody.changes : [])
|
|
1607
|
+
.filter((change) => String(change?.op || '').toLowerCase() === 'asset-upsert');
|
|
1608
|
+
const assetDeletes = (Array.isArray(pullBody?.changes) ? pullBody.changes : [])
|
|
1609
|
+
.filter((change) => String(change?.op || '').toLowerCase() === 'asset-delete');
|
|
1610
|
+
expect(assetUpserts.find((change) => change.path === relativeAssetPath)).toBeUndefined();
|
|
1611
|
+
expect(assetDeletes).toContainEqual(expect.objectContaining({
|
|
1612
|
+
op: 'asset-delete',
|
|
1613
|
+
path: relativeAssetPath,
|
|
1614
|
+
assetId: 'asset-delete-auth-1'
|
|
1615
|
+
}));
|
|
1616
|
+
});
|
|
1617
|
+
it('includes unattached annotated attachment sessions in the authenticated sync snapshot', async () => {
|
|
1618
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-annotated-snapshot-');
|
|
1619
|
+
const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1620
|
+
seedAuthenticatedWorkspace(seedCore);
|
|
1621
|
+
const seededStore = new WorkspaceAssetStore(seedCore.getDatabaseAdapter(), seedCore.getTenantId());
|
|
1622
|
+
seededStore.upsertAsset({
|
|
1623
|
+
assetId: 'asset-image-unattached-1',
|
|
1624
|
+
workspaceId,
|
|
1625
|
+
kind: 'image',
|
|
1626
|
+
originalFilename: 'unattached.png',
|
|
1627
|
+
mimeType: 'image/png',
|
|
1628
|
+
storageProvider: 'local',
|
|
1629
|
+
storageKey: 'workspaces/workspace-local-active/assets/images/asset-image-unattached-1-unattached.png',
|
|
1630
|
+
contentSha256: 'seed-hash-unattached',
|
|
1631
|
+
sizeBytes: 42,
|
|
1632
|
+
createdAt: '2026-03-30T20:00:00.000Z',
|
|
1633
|
+
updatedAt: '2026-03-30T20:00:00.000Z'
|
|
1634
|
+
});
|
|
1635
|
+
seedCore.createAnnotatedAttachmentSession({
|
|
1636
|
+
workspaceId,
|
|
1637
|
+
baseImageAssetId: 'asset-image-unattached-1',
|
|
1638
|
+
title: 'Loose image notes',
|
|
1639
|
+
annotations: [
|
|
1640
|
+
{ id: 'annotation-1', kind: 'pin', order: 0, instruction: 'Check this', x: 0.2, y: 0.3 },
|
|
1641
|
+
],
|
|
1642
|
+
});
|
|
1643
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1644
|
+
const routes = createRoutes(core, { authConfig });
|
|
1645
|
+
const snapshotMatch = matchRoute('GET', '/api/taskforce/sync/workspace/annotated-attachments', routes);
|
|
1646
|
+
expect(snapshotMatch).toBeTruthy();
|
|
1647
|
+
const cookie = createAuthCookie(workspaceId);
|
|
1648
|
+
const req = createMockReq('GET', '/api/taskforce/sync/workspace/annotated-attachments?workspaceId=workspace-local-active', undefined, { cookie });
|
|
1649
|
+
const res = createMockRes();
|
|
1650
|
+
await snapshotMatch?.handler(req, res, {});
|
|
1651
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1652
|
+
expect(readJsonBody(res).sessions).toEqual(expect.arrayContaining([
|
|
1653
|
+
expect.objectContaining({
|
|
1654
|
+
baseImageAssetId: 'asset-image-unattached-1',
|
|
1655
|
+
taskId: '',
|
|
1656
|
+
title: 'Loose image notes'
|
|
1657
|
+
})
|
|
1658
|
+
]));
|
|
1659
|
+
});
|
|
1660
|
+
it('returns 401 on apply-local when auth is enabled but absent', async () => {
|
|
1661
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-auth-');
|
|
1662
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1663
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1664
|
+
// Enable auth this time as we are testing it
|
|
1665
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: true } });
|
|
1666
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1667
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', { changes: [] }, { 'x-taskforce-workspace-id': workspaceId }
|
|
1668
|
+
// No auth cookie or session
|
|
1669
|
+
);
|
|
1670
|
+
const res = createMockRes();
|
|
1671
|
+
await match?.handler(req, res, {});
|
|
1672
|
+
expect(res.writeHead).toHaveBeenCalledWith(401, expect.any(Object));
|
|
1673
|
+
});
|
|
1674
|
+
it('round-trips sync event details through the sync events API', async () => {
|
|
1675
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-events-details-');
|
|
1676
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1677
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1678
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1679
|
+
const postMatch = matchRoute('POST', '/api/taskforce/sync/events', routes);
|
|
1680
|
+
expect(postMatch).toBeTruthy();
|
|
1681
|
+
const postReq = createMockReq('POST', '/api/taskforce/sync/events', {
|
|
1682
|
+
workspaceId,
|
|
1683
|
+
eventType: 'apply',
|
|
1684
|
+
status: 'error',
|
|
1685
|
+
errorMessage: 'Detected asset reference number mismatch during normal sync.',
|
|
1686
|
+
details: {
|
|
1687
|
+
path: 'workspaces/workspace-local-active/assets/images/hero.png',
|
|
1688
|
+
existingReferenceNumber: 12,
|
|
1689
|
+
incomingReferenceNumber: 18
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
const postRes = createMockRes();
|
|
1693
|
+
await postMatch?.handler(postReq, postRes, {});
|
|
1694
|
+
expect(postRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1695
|
+
const getMatch = matchRoute('GET', '/api/taskforce/sync/events', routes);
|
|
1696
|
+
expect(getMatch).toBeTruthy();
|
|
1697
|
+
const getReq = createMockReq('GET', `/api/taskforce/sync/events?workspace_id=${workspaceId}&limit=5`);
|
|
1698
|
+
const getRes = createMockRes();
|
|
1699
|
+
await getMatch?.handler(getReq, getRes, {});
|
|
1700
|
+
expect(getRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1701
|
+
const payload = JSON.parse(String(getRes.end.mock.calls[0]?.[0] || '{}'));
|
|
1702
|
+
expect(payload.events).toEqual(expect.arrayContaining([
|
|
1703
|
+
expect.objectContaining({
|
|
1704
|
+
eventType: 'apply',
|
|
1705
|
+
status: 'error',
|
|
1706
|
+
errorMessage: 'Detected asset reference number mismatch during normal sync.',
|
|
1707
|
+
details: expect.objectContaining({
|
|
1708
|
+
path: 'workspaces/workspace-local-active/assets/images/hero.png',
|
|
1709
|
+
existingReferenceNumber: 12,
|
|
1710
|
+
incomingReferenceNumber: 18
|
|
1711
|
+
})
|
|
1712
|
+
})
|
|
1713
|
+
]));
|
|
1714
|
+
});
|
|
1715
|
+
it('returns 400 on apply-local when workspaceId is null/empty/default', async () => {
|
|
1716
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-wsmissing-');
|
|
1717
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1718
|
+
// Use false for auth to isolate workspaceId testing
|
|
1719
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1720
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1721
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', { changes: [] }
|
|
1722
|
+
// Omit custom headers, and the body does not have workspaceId
|
|
1723
|
+
);
|
|
1724
|
+
const res = createMockRes();
|
|
1725
|
+
await match?.handler(req, res, {});
|
|
1726
|
+
expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
|
|
1727
|
+
});
|
|
1728
|
+
it('keeps repeated document scans correct and invalidates cache when file content changes', async () => {
|
|
1729
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-doc-cache-');
|
|
1730
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1731
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1732
|
+
const syncRoot = core.getPaths().basePath;
|
|
1733
|
+
const docsDir = path.join(syncRoot, 'docs');
|
|
1734
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
1735
|
+
const relativeDocPath = 'docs/cache-target.md';
|
|
1736
|
+
const absoluteDocPath = path.join(syncRoot, relativeDocPath);
|
|
1737
|
+
fs.writeFileSync(absoluteDocPath, '# cache test\n', 'utf8');
|
|
1738
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1739
|
+
const match = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
|
|
1740
|
+
expect(match).toBeTruthy();
|
|
1741
|
+
const req1 = createMockReq('GET', `/api/taskforce/sync/workspace/documents?workspaceId=${workspaceId}`, undefined, { 'x-taskforce-workspace-id': workspaceId });
|
|
1742
|
+
const res1 = createMockRes();
|
|
1743
|
+
await match?.handler(req1, res1, {});
|
|
1744
|
+
expect(res1.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1745
|
+
const body1 = JSON.parse(String(res1.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
1746
|
+
const req2 = createMockReq('GET', `/api/taskforce/sync/workspace/documents?workspaceId=${workspaceId}`, undefined, { 'x-taskforce-workspace-id': workspaceId });
|
|
1747
|
+
const res2 = createMockRes();
|
|
1748
|
+
await match?.handler(req2, res2, {});
|
|
1749
|
+
expect(res2.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1750
|
+
const body2 = JSON.parse(String(res2.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
1751
|
+
const firstDoc1 = (Array.isArray(body1?.documents) ? body1.documents : []).find((doc) => doc.path === relativeDocPath);
|
|
1752
|
+
const firstDoc2 = (Array.isArray(body2?.documents) ? body2.documents : []).find((doc) => doc.path === relativeDocPath);
|
|
1753
|
+
expect(firstDoc1?.content).toBe('# cache test\n');
|
|
1754
|
+
expect(firstDoc2?.content).toBe('# cache test\n');
|
|
1755
|
+
expect(firstDoc2?.updatedAt).toBe(firstDoc1?.updatedAt);
|
|
1756
|
+
fs.writeFileSync(absoluteDocPath, '# cache test changed\n', 'utf8');
|
|
1757
|
+
const req3 = createMockReq('GET', `/api/taskforce/sync/workspace/documents?workspaceId=${workspaceId}`, undefined, { 'x-taskforce-workspace-id': workspaceId });
|
|
1758
|
+
const res3 = createMockRes();
|
|
1759
|
+
await match?.handler(req3, res3, {});
|
|
1760
|
+
expect(res3.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1761
|
+
const body3 = JSON.parse(String(res3.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
1762
|
+
const firstDoc3 = (Array.isArray(body3?.documents) ? body3.documents : []).find((doc) => doc.path === relativeDocPath);
|
|
1763
|
+
expect(firstDoc3?.content).toBe('# cache test changed\n');
|
|
1764
|
+
});
|
|
1765
|
+
it('includes locally uploaded canonical markdown attachments in the workspace document snapshot', async () => {
|
|
1766
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-canonical-doc-');
|
|
1767
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1768
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1769
|
+
const task = core.createTask({ title: 'Task with markdown attachment' }, { workspaceId });
|
|
1770
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1771
|
+
const uploadMatch = matchRoute('POST', '/api/taskforce/context-upload', routes);
|
|
1772
|
+
expect(uploadMatch).toBeTruthy();
|
|
1773
|
+
const uploadReq = createMockReq('POST', '/api/taskforce/context-upload', {
|
|
1774
|
+
file: `data:text/markdown;base64,${Buffer.from('# local attachment\n', 'utf8').toString('base64')}`,
|
|
1775
|
+
originalName: 'local-notes.md',
|
|
1776
|
+
taskId: task.id
|
|
1777
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1778
|
+
const uploadRes = createMockRes();
|
|
1779
|
+
await uploadMatch?.handler(uploadReq, uploadRes, {});
|
|
1780
|
+
expect(uploadRes.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
|
|
1781
|
+
const uploadBody = JSON.parse(String(uploadRes.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
1782
|
+
expect(String(uploadBody?.fsPath || '')).toContain(`workspaces/${workspaceId}/assets/documents/`);
|
|
1783
|
+
const documentsMatch = matchRoute('GET', '/api/taskforce/sync/workspace/documents', routes);
|
|
1784
|
+
expect(documentsMatch).toBeTruthy();
|
|
1785
|
+
const req = createMockReq('GET', `/api/taskforce/sync/workspace/documents?workspaceId=${encodeURIComponent(workspaceId)}`, undefined, { 'x-taskforce-workspace-id': workspaceId });
|
|
1786
|
+
const res = createMockRes();
|
|
1787
|
+
await documentsMatch?.handler(req, res, {});
|
|
1788
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1789
|
+
const body = JSON.parse(String(res.end.mock.calls.at(-1)?.[0] || '{}'));
|
|
1790
|
+
const relativeDocPath = String(uploadBody?.fsPath || '');
|
|
1791
|
+
const doc = (Array.isArray(body?.documents) ? body.documents : []).find((entry) => entry.path === relativeDocPath);
|
|
1792
|
+
expect(doc).toBeTruthy();
|
|
1793
|
+
expect(doc.content).toBe('# local attachment\n');
|
|
1794
|
+
});
|
|
1795
|
+
it('converges task conflicts deterministically for equal timestamps regardless of apply order', async () => {
|
|
1796
|
+
const applySequence = async (prefix, titlesInOrder) => {
|
|
1797
|
+
const projectRoot = createIsolatedProjectRoot(prefix);
|
|
1798
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1799
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1800
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1801
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1802
|
+
expect(match).toBeTruthy();
|
|
1803
|
+
for (const title of titlesInOrder) {
|
|
1804
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1805
|
+
changes: [{
|
|
1806
|
+
op: 'upsert',
|
|
1807
|
+
archived: false,
|
|
1808
|
+
task: {
|
|
1809
|
+
id: 'task-concurrent-1',
|
|
1810
|
+
title,
|
|
1811
|
+
status: 'task',
|
|
1812
|
+
createdAt: '2026-02-27T00:00:00.000Z',
|
|
1813
|
+
updatedAt: '2026-02-27T00:00:00.000Z'
|
|
1814
|
+
}
|
|
1815
|
+
}]
|
|
1816
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1817
|
+
const res = createMockRes();
|
|
1818
|
+
await match?.handler(req, res, {});
|
|
1819
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1820
|
+
}
|
|
1821
|
+
return core.getTask('task-concurrent-1', workspaceId)?.title || null;
|
|
1822
|
+
};
|
|
1823
|
+
const orderAB = await applySequence('taskforce-sync-conflict-task-ab-', ['alpha-title', 'zeta-title']);
|
|
1824
|
+
const orderBA = await applySequence('taskforce-sync-conflict-task-ba-', ['zeta-title', 'alpha-title']);
|
|
1825
|
+
expect(orderAB).toBeTruthy();
|
|
1826
|
+
expect(orderBA).toBe(orderAB);
|
|
1827
|
+
});
|
|
1828
|
+
it('converges document metadata conflicts deterministically for equal timestamps regardless of apply order', async () => {
|
|
1829
|
+
const applySequence = async (prefix, contentsInOrder) => {
|
|
1830
|
+
const projectRoot = createIsolatedProjectRoot(prefix);
|
|
1831
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1832
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1833
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1834
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1835
|
+
expect(match).toBeTruthy();
|
|
1836
|
+
const relativeDocPath = 'docs/conflict-doc.md';
|
|
1837
|
+
const absoluteDocPath = path.join(core.getPaths().basePath, relativeDocPath);
|
|
1838
|
+
for (const content of contentsInOrder) {
|
|
1839
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1840
|
+
changes: [{
|
|
1841
|
+
op: 'document-upsert',
|
|
1842
|
+
path: relativeDocPath,
|
|
1843
|
+
updatedAt: '2026-02-27T00:00:00.000Z',
|
|
1844
|
+
content
|
|
1845
|
+
}]
|
|
1846
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1847
|
+
const res = createMockRes();
|
|
1848
|
+
await match?.handler(req, res, {});
|
|
1849
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1850
|
+
}
|
|
1851
|
+
return fs.existsSync(absoluteDocPath) ? fs.readFileSync(absoluteDocPath, 'utf8') : '';
|
|
1852
|
+
};
|
|
1853
|
+
const orderAB = await applySequence('taskforce-sync-conflict-doc-ab-', ['doc-content-alpha', 'doc-content-zeta']);
|
|
1854
|
+
const orderBA = await applySequence('taskforce-sync-conflict-doc-ba-', ['doc-content-zeta', 'doc-content-alpha']);
|
|
1855
|
+
expect(orderAB).toBeTruthy();
|
|
1856
|
+
expect(orderBA).toBe(orderAB);
|
|
1857
|
+
});
|
|
1858
|
+
it('returns 200 with skipped count when updateTask throws during apply-local (regression for Bug 1)', async () => {
|
|
1859
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-updatethrow-');
|
|
1860
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1861
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1862
|
+
// Create the task first so the apply-local path takes the updateTask branch
|
|
1863
|
+
core.createTask({
|
|
1864
|
+
id: 'task-update-throws',
|
|
1865
|
+
title: 'Original title',
|
|
1866
|
+
status: 'task'
|
|
1867
|
+
}, {
|
|
1868
|
+
workspaceId,
|
|
1869
|
+
allowClientProvidedId: true
|
|
1870
|
+
});
|
|
1871
|
+
// Stub updateTask to throw on this specific task id
|
|
1872
|
+
const originalUpdateTask = core.updateTask.bind(core);
|
|
1873
|
+
vi.spyOn(core, 'updateTask').mockImplementationOnce((...args) => {
|
|
1874
|
+
if (args[0] === 'task-update-throws') {
|
|
1875
|
+
throw new Error('Rule violation: cannot update task');
|
|
1876
|
+
}
|
|
1877
|
+
return originalUpdateTask(...args);
|
|
1878
|
+
});
|
|
1879
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1880
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1881
|
+
expect(match).toBeTruthy();
|
|
1882
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1883
|
+
changes: [
|
|
1884
|
+
{
|
|
1885
|
+
op: 'upsert',
|
|
1886
|
+
archived: false,
|
|
1887
|
+
task: {
|
|
1888
|
+
id: 'task-update-throws',
|
|
1889
|
+
title: 'Updated title from cloud',
|
|
1890
|
+
status: 'in-progress'
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
]
|
|
1894
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1895
|
+
const res = createMockRes();
|
|
1896
|
+
await match?.handler(req, res, {});
|
|
1897
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1898
|
+
const body = readJsonBody(res);
|
|
1899
|
+
expect(body.result.skipped).toBeGreaterThanOrEqual(1);
|
|
1900
|
+
});
|
|
1901
|
+
it('preserves seeded first-class taxonomy state when apply-local receives a partial taxonomy payload', async () => {
|
|
1902
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-apply-taxonomy-partial-');
|
|
1903
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1904
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1905
|
+
core.updateTaxonomyState(buildSoftwareTaxonomyState(), workspaceId);
|
|
1906
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1907
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1908
|
+
expect(match).toBeTruthy();
|
|
1909
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1910
|
+
changes: [
|
|
1911
|
+
{
|
|
1912
|
+
op: 'taxonomy-upsert',
|
|
1913
|
+
updatedAt: '2026-03-30T17:05:00.000Z',
|
|
1914
|
+
taxonomyState: {
|
|
1915
|
+
categories: [{ value: 'default', label: 'General', icon: 'Inbox' }],
|
|
1916
|
+
priorities: [
|
|
1917
|
+
{ value: 1, label: 'Low', color: 'teal-500', icon: 'ArrowDown' },
|
|
1918
|
+
{ value: 2, label: 'Medium', color: 'blue-500', icon: 'Minus' },
|
|
1919
|
+
{ value: 3, label: 'High', color: 'amber-500', icon: 'ArrowUp' }
|
|
1920
|
+
],
|
|
1921
|
+
taxonomies: []
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
]
|
|
1925
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1926
|
+
const res = createMockRes();
|
|
1927
|
+
await match?.handler(req, res, {});
|
|
1928
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1929
|
+
const state = core.getTaxonomyState(workspaceId);
|
|
1930
|
+
expect((state.types || []).map((type) => type.value)).toEqual([
|
|
1931
|
+
'feature',
|
|
1932
|
+
'bug',
|
|
1933
|
+
'chore',
|
|
1934
|
+
'refactor',
|
|
1935
|
+
'documentation'
|
|
1936
|
+
]);
|
|
1937
|
+
expect((state.priorities || []).map((priority) => priority.value)).toEqual([1, 2, 3, 4]);
|
|
1938
|
+
});
|
|
1939
|
+
it('preserves seeded first-class taxonomy state when bootstrap-push receives a partial taxonomy payload', async () => {
|
|
1940
|
+
const projectRoot = createIsolatedProjectRoot('taskforce-sync-bootstrap-taxonomy-partial-');
|
|
1941
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1942
|
+
seedAuthenticatedWorkspace(core);
|
|
1943
|
+
core.updateTaxonomyState(buildSoftwareTaxonomyState(), workspaceId);
|
|
1944
|
+
const routes = createRoutes(core, { authConfig });
|
|
1945
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/bootstrap-push', routes);
|
|
1946
|
+
expect(match).toBeTruthy();
|
|
1947
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/bootstrap-push', {
|
|
1948
|
+
workspaceId,
|
|
1949
|
+
tasks: [],
|
|
1950
|
+
archived: [],
|
|
1951
|
+
taxonomyState: {
|
|
1952
|
+
categories: [{ value: 'default', label: 'General', icon: 'Inbox' }],
|
|
1953
|
+
priorities: [
|
|
1954
|
+
{ value: 1, label: 'Low', color: 'teal-500', icon: 'ArrowDown' },
|
|
1955
|
+
{ value: 2, label: 'Medium', color: 'blue-500', icon: 'Minus' },
|
|
1956
|
+
{ value: 3, label: 'High', color: 'amber-500', icon: 'ArrowUp' }
|
|
1957
|
+
],
|
|
1958
|
+
taxonomies: []
|
|
1959
|
+
}
|
|
1960
|
+
}, { cookie: createAuthCookie(workspaceId) });
|
|
1961
|
+
const res = createMockRes();
|
|
1962
|
+
await match?.handler(req, res, {});
|
|
1963
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1964
|
+
const state = core.getTaxonomyState(workspaceId);
|
|
1965
|
+
expect((state.types || []).map((type) => type.value)).toEqual([
|
|
1966
|
+
'feature',
|
|
1967
|
+
'bug',
|
|
1968
|
+
'chore',
|
|
1969
|
+
'refactor',
|
|
1970
|
+
'documentation'
|
|
1971
|
+
]);
|
|
1972
|
+
expect((state.priorities || []).map((priority) => priority.value)).toEqual([1, 2, 3, 4]);
|
|
1973
|
+
});
|
|
1974
|
+
it('converges taxonomy conflicts deterministically for equal timestamps regardless of apply order', async () => {
|
|
1975
|
+
const applySequence = async (prefix, taxonomiesInOrder) => {
|
|
1976
|
+
const projectRoot = createIsolatedProjectRoot(prefix);
|
|
1977
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1978
|
+
core.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
|
|
1979
|
+
const routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
|
|
1980
|
+
const match = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', routes);
|
|
1981
|
+
expect(match).toBeTruthy();
|
|
1982
|
+
for (const taxonomies of taxonomiesInOrder) {
|
|
1983
|
+
const req = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', {
|
|
1984
|
+
changes: [{
|
|
1985
|
+
op: 'taxonomy-upsert',
|
|
1986
|
+
updatedAt: '2026-02-27T00:00:00.000Z',
|
|
1987
|
+
taxonomies
|
|
1988
|
+
}]
|
|
1989
|
+
}, { 'x-taskforce-workspace-id': workspaceId });
|
|
1990
|
+
const res = createMockRes();
|
|
1991
|
+
await match?.handler(req, res, {});
|
|
1992
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
|
|
1993
|
+
}
|
|
1994
|
+
return core.getTaxonomies();
|
|
1995
|
+
};
|
|
1996
|
+
const alphaTaxonomies = [{ id: 'custom', label: 'Alpha', options: [], widgetType: 'select', isSystem: false, multiSelect: false }];
|
|
1997
|
+
const zetaTaxonomies = [{ id: 'custom', label: 'Zeta', options: [], widgetType: 'select', isSystem: false, multiSelect: false }];
|
|
1998
|
+
const orderAB = await applySequence('taskforce-sync-conflict-tax-ab-', [alphaTaxonomies, zetaTaxonomies]);
|
|
1999
|
+
const orderBA = await applySequence('taskforce-sync-conflict-tax-ba-', [zetaTaxonomies, alphaTaxonomies]);
|
|
2000
|
+
expect(Array.isArray(orderAB)).toBe(true);
|
|
2001
|
+
expect(orderBA).toEqual(orderAB);
|
|
2002
|
+
});
|
|
2003
|
+
});
|