@taskforcehq/taskforce 0.3.305 → 0.3.307
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/dist/Taskforce.module.css +603 -107
- package/dist/TaskforceCore.js +216 -43
- package/dist/TaskforceCore.test.js +611 -28
- package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/assets/fonts/Noir_medium.woff2 +0 -0
- package/dist/assets/fonts/Noir_regular.woff2 +0 -0
- package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/compat/workspaceSyncCompat.js +46 -2
- package/dist/compat/workspaceSyncCompat.test.js +38 -1
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
- package/dist/components/context/ContextAttachmentManager.js +561 -0
- package/dist/components/features/AgentsModule.d.ts +14 -1
- package/dist/components/features/AgentsModule.js +366 -18
- package/dist/components/features/AgentsModule.test.js +507 -8
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +7 -1
- package/dist/components/features/DocumentViewer.js +62 -4
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +51 -1
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +24 -29
- package/dist/components/task/TaskCard.test.js +38 -3
- package/dist/components/task/TaskContextUpload.js +4 -526
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +61 -116
- package/dist/components/task/TaskForm.test.js +110 -8
- package/dist/components/task/TaskKanban.d.ts +2 -1
- package/dist/components/task/TaskKanban.js +94 -21
- package/dist/components/task/TaskKanban.test.js +36 -0
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
- package/dist/components/ui/EditableAvatarButton.js +18 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.d.ts +9 -1
- package/dist/components/views/PlanComparisonPage.js +78 -14
- package/dist/components/views/PlanComparisonPage.test.js +257 -0
- package/dist/components/views/PlansPage.js +127 -36
- package/dist/components/views/PlansPage.test.js +294 -12
- package/dist/components/views/StandaloneLayout.js +529 -592
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +25 -5
- package/dist/components/views/panels/PlanningDrawer.js +270 -55
- package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
- package/dist/components/views/panels/PlanningDrawer.test.js +529 -0
- package/dist/components/views/planningScope.d.ts +20 -0
- package/dist/components/views/planningScope.js +25 -0
- package/dist/components/views/planningScope.test.d.ts +1 -0
- package/dist/components/views/planningScope.test.js +60 -0
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
- package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
- package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
- package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
- package/dist/config/envSchema.js +1 -0
- package/dist/core/AiProfileService.d.ts +46 -2
- package/dist/core/AiProfileService.js +1073 -82
- package/dist/core/AiProfileService.test.js +19 -1
- package/dist/core/AiProfiles.test.js +1249 -44
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +110 -23
- package/dist/core/AuthIdentityService.d.ts +107 -0
- package/dist/core/AuthIdentityService.js +284 -0
- package/dist/core/AuthTokenService.d.ts +4 -0
- package/dist/core/AuthTokenService.js +34 -6
- package/dist/core/AuthTokenService.test.js +3 -3
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +127 -6
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanEntitlementService.d.ts +23 -2
- package/dist/core/PlanEntitlementService.js +343 -57
- package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
- package/dist/core/PlanFeatureCatalog.test.js +84 -0
- package/dist/core/PlanVersionPolicy.test.js +35 -2
- package/dist/core/PlanningEntities.test.js +418 -4
- package/dist/core/SignupMonetizationSettings.test.js +199 -27
- package/dist/core/SyncReconciliationService.js +2 -65
- package/dist/core/SystemAdmin.test.js +42 -42
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +415 -40
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +1543 -266
- package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
- package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
- package/dist/core/WorkspaceLifecycleService.js +0 -5
- package/dist/core/WorkspacePermissions.test.js +25 -1
- package/dist/core/WorkspacePlanMode.test.js +117 -16
- package/dist/core/shared.d.ts +1 -1
- package/dist/core/shared.js +5 -1
- package/dist/core/types.d.ts +75 -2
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +28 -2
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- package/dist/hooks/sync/bootstrap.js +2 -0
- package/dist/hooks/sync/controlPlane.d.ts +37 -0
- package/dist/hooks/sync/controlPlane.js +78 -0
- package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
- package/dist/hooks/sync/controlPlane.test.js +121 -0
- package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
- package/dist/hooks/sync/lifecyclePolicy.js +93 -0
- package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
- package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +13 -4
- package/dist/hooks/sync/orchestratorShared.js +129 -48
- package/dist/hooks/sync/orchestratorShared.test.js +50 -1
- package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
- package/dist/hooks/sync/pullLifecycle.js +24 -0
- package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
- package/dist/hooks/sync/pullLifecycle.test.js +80 -0
- package/dist/hooks/sync/recovery.d.ts +16 -2
- package/dist/hooks/sync/recovery.js +179 -53
- package/dist/hooks/sync/recovery.test.d.ts +1 -0
- package/dist/hooks/sync/recovery.test.js +292 -0
- package/dist/hooks/sync/transfers.d.ts +16 -3
- package/dist/hooks/sync/transfers.js +210 -66
- package/dist/hooks/sync/transfers.test.d.ts +1 -0
- package/dist/hooks/sync/transfers.test.js +928 -0
- package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
- package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
- package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
- package/dist/hooks/sync/useSyncStatusActions.js +88 -0
- package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
- package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
- package/dist/hooks/sync/useSyncStatusControls.js +60 -0
- package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
- package/dist/hooks/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
- package/dist/hooks/useSyncOrchestrator.js +409 -183
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
- package/dist/hooks/useTaskData.js +8 -3
- package/dist/hooks/useTaskData.test.js +45 -0
- package/dist/hooks/useTaskforce.d.ts +145 -52
- package/dist/hooks/useTaskforce.js +592 -253
- package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.runtime-routing.test.js +246 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +2468 -201
- package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.js +13 -2
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +328 -40
- package/dist/localization/locales/en-US.d.ts +0 -1
- package/dist/localization/locales/en-US.js +0 -1
- package/dist/localization/locales/es-419.js +0 -1
- package/dist/localization/locales/pt-BR.js +0 -1
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +116 -10
- package/dist/mcp/runtime.d.ts +3 -0
- package/dist/mcp/runtime.js +1099 -185
- package/dist/mcp/runtime.test.js +1491 -140
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
- package/dist/migrations/taskSchemaMigrations.js +192 -61
- package/dist/migrations/taskSchemaMigrations.test.js +15 -0
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/dist/resources/templates/workflows/collaborate.yaml +1 -1
- package/dist/resources/templates/workflows/evaluate.yaml +2 -2
- package/dist/resources/templates/workflows/plan.yaml +1 -1
- package/dist/resources/templates/workflows/review.yaml +2 -2
- package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +69 -10
- package/dist/server/auth/providers/apple.d.ts +33 -0
- package/dist/server/auth/providers/apple.js +82 -0
- package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
- package/dist/server/auth/providers/appleClientSecret.js +65 -0
- package/dist/server/auth/providers/github.d.ts +26 -0
- package/dist/server/auth/providers/github.js +86 -0
- package/dist/server/auth/providers/google.d.ts +21 -0
- package/dist/server/auth/providers/google.js +56 -0
- package/dist/server/auth/providers/types.d.ts +21 -0
- package/dist/server/auth/providers/types.js +1 -0
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.js +6 -3
- package/dist/server/documentReviewRoutes.test.js +36 -3
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +124 -9
- package/dist/server/index.rateLimit.test.js +3 -0
- package/dist/server/index.test.js +111 -3
- package/dist/server/routes/admin.d.ts +3 -0
- package/dist/server/routes/admin.js +459 -28
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +26 -2
- package/dist/server/routes/auth.js +1170 -71
- package/dist/server/routes/authSupport.d.ts +5 -1
- package/dist/server/routes/authSupport.js +13 -2
- package/dist/server/routes/billing.js +372 -35
- package/dist/server/routes/billing.test.js +595 -12
- package/dist/server/routes/documents.d.ts +1 -0
- package/dist/server/routes/documents.js +182 -70
- package/dist/server/routes/shared.d.ts +28 -2
- package/dist/server/routes/shared.js +69 -14
- package/dist/server/routes/sync.integration.test.js +658 -39
- package/dist/server/routes/sync.js +74 -608
- package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
- package/dist/server/routes/syncAuxRoutes.js +91 -0
- package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
- package/dist/server/routes/syncAuxRoutes.test.js +158 -0
- package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
- package/dist/server/routes/syncPullApplyRoutes.js +204 -0
- package/dist/server/routes/syncPushRoutes.d.ts +24 -0
- package/dist/server/routes/syncPushRoutes.js +212 -0
- package/dist/server/routes/syncRouteGuards.d.ts +36 -0
- package/dist/server/routes/syncRouteGuards.js +67 -0
- package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
- package/dist/server/routes/syncRouteGuards.test.js +94 -0
- package/dist/server/routes/syncRouteTypes.d.ts +13 -0
- package/dist/server/routes/syncRouteTypes.js +1 -0
- package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
- package/dist/server/routes/syncSnapshotRoutes.js +182 -0
- package/dist/server/routes/tasks.js +55 -10
- package/dist/server/routes/workspaces.js +36 -5
- package/dist/server/routes.js +147 -11
- package/dist/server/routes.test.js +2704 -183
- package/dist/services/workflowExportSnapshots.test.js +2 -0
- package/dist/shared/aiProfileSeatScope.d.ts +5 -0
- package/dist/shared/aiProfileSeatScope.js +21 -0
- package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- package/dist/shared/passwordPolicy.d.ts +13 -0
- package/dist/shared/passwordPolicy.js +84 -0
- package/dist/shared/passwordPolicy.test.d.ts +1 -0
- package/dist/shared/passwordPolicy.test.js +34 -0
- package/dist/storage/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- package/dist/storage/workspaceAssetStore.d.ts +15 -3
- package/dist/storage/workspaceAssetStore.js +132 -44
- package/dist/storage/workspaceAssetStore.test.js +93 -6
- package/dist/styles/fonts.css +70 -0
- package/dist/sync/collaborationSyncPayload.d.ts +6 -1
- package/dist/sync/collaborationSyncPayload.js +19 -11
- package/dist/sync/collaborationSyncPayload.test.js +14 -1
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +9 -9
- package/dist/sync/contentSyncState.js +248 -107
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +11 -4
- package/dist/sync/syncApplyHandlers.js +115 -42
- package/dist/sync/syncApplyHandlers.test.js +251 -4
- package/dist/sync/syncFieldSemantics.d.ts +12 -0
- package/dist/sync/syncFieldSemantics.js +55 -0
- package/dist/sync/syncResourceAdapters.d.ts +46 -0
- package/dist/sync/syncResourceAdapters.js +79 -0
- package/dist/sync/syncService.d.ts +6 -0
- package/dist/sync/syncService.js +65 -1
- package/dist/sync/syncService.test.js +96 -3
- package/dist/sync/taskSyncChangeKey.d.ts +2 -0
- package/dist/sync/taskSyncChangeKey.js +143 -0
- package/dist/sync/taskSyncPayload.js +3 -8
- package/dist/sync/workspacePullFeed.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +132 -72
- package/dist/sync/workspacePullFeed.test.js +92 -0
- package/dist/sync/workspaceRepair.js +5 -2
- package/dist/sync/workspaceSyncModel.d.ts +73 -6
- package/dist/sync/workspaceSyncModel.js +342 -73
- package/dist/sync/workspaceSyncModel.test.js +559 -14
- package/dist/sync/workspaceSyncState.d.ts +17 -0
- package/dist/sync/workspaceSyncState.js +2 -1
- package/dist/sync/workspaceSyncSurface.js +3 -8
- package/dist/types.d.ts +50 -2
- package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
- package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
- package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-D4XseTwW.js} +1 -1
- package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
- package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
- package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
- package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
- package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
- package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
- package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
- package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-oOA9bcdK.js} +4 -4
- package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
- package/dist/ui/assets/index-607WPo_X.js +4 -0
- package/dist/ui/assets/index-CYyodXsg.css +1 -0
- package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-B32hGuF2.js} +1 -1
- package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/ui/fonts/Noir_medium.otf +0 -0
- package/dist/ui/fonts/Noir_medium.woff2 +0 -0
- package/dist/ui/fonts/Noir_regular.otf +0 -0
- package/dist/ui/fonts/Noir_regular.woff2 +0 -0
- package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/ui/index.html +3 -3
- package/dist/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +8 -0
- package/dist/utils/assignees.js +10 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -0
- package/dist/utils/avatarUpload.d.ts +27 -0
- package/dist/utils/avatarUpload.js +132 -0
- package/dist/utils/avatarUpload.test.d.ts +1 -0
- package/dist/utils/avatarUpload.test.js +40 -0
- package/dist/utils/bootstrapTrace.d.ts +7 -0
- package/dist/utils/bootstrapTrace.js +29 -0
- package/dist/utils/commercialLifecyclePresentation.js +3 -0
- package/dist/utils/contextAssetEvents.d.ts +2 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- package/dist/utils/syncEventDetails.d.ts +3 -0
- package/dist/utils/syncEventDetails.js +64 -0
- package/dist/utils/syncEventDetails.test.d.ts +1 -0
- package/dist/utils/syncEventDetails.test.js +23 -0
- package/dist/utils/syncEventPresentation.d.ts +15 -0
- package/dist/utils/syncEventPresentation.js +129 -0
- package/dist/utils/syncEventPresentation.test.d.ts +1 -0
- package/dist/utils/syncEventPresentation.test.js +64 -0
- package/dist/utils/syncStatusPresentation.d.ts +72 -0
- package/dist/utils/syncStatusPresentation.js +217 -0
- package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
- package/dist/utils/syncStatusPresentation.test.js +164 -0
- package/dist/utils/taskActivity.d.ts +9 -1
- package/dist/utils/taskActivity.js +290 -22
- package/dist/utils/taskActivity.test.js +141 -8
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
- package/dist/utils/workspaceSyncPresentation.js +68 -2
- package/dist/utils/workspaceSyncPresentation.test.js +36 -1
- package/package.json +3 -2
- package/scripts/export-sqlite-to-postgres.mjs +7 -9
- package/scripts/run-e2e-realtime.sh +17 -3
- package/scripts/run-smoke.sh +17 -5
- package/scripts/run-soak.sh +17 -5
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/src/resources/templates/workflows/collaborate.yaml +1 -1
- package/src/resources/templates/workflows/evaluate.yaml +2 -2
- package/src/resources/templates/workflows/plan.yaml +1 -1
- package/src/resources/templates/workflows/review.yaml +2 -2
- package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BG6P_GVW.js +0 -3
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
- package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
- package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +0 -9
- package/dist/ui/assets/index-C2-OXZV7.css +0 -1
- package/dist/ui/assets/index-DcSI5F86.js +0 -6
- package/scripts/migrate-planning-model.ts +0 -233
|
@@ -73,6 +73,9 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
73
73
|
expect(fetched?.assigneeActor?.label).toBe('Codex');
|
|
74
74
|
expect(fetched?.createdByActor?.label).toBe('Codex');
|
|
75
75
|
expect(fetched?.comments?.[0]?.actor?.label).toBe('Codex');
|
|
76
|
+
expect(fetched?.assigneeActor?.meta).not.toHaveProperty('profileToken');
|
|
77
|
+
expect(fetched?.createdByActor?.meta).not.toHaveProperty('profileToken');
|
|
78
|
+
expect(fetched?.comments?.[0]?.actor?.meta).not.toHaveProperty('profileToken');
|
|
76
79
|
});
|
|
77
80
|
it('updates existing ai profile presentation fields when profileToken is presented again', () => {
|
|
78
81
|
const projectRoot = createProjectRoot();
|
|
@@ -98,6 +101,319 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
98
101
|
expect(second.profile.icon).toBe('Sparkles');
|
|
99
102
|
expect(second.profile.color).toBe('#0ea5e9');
|
|
100
103
|
});
|
|
104
|
+
it('updates ai profile avatar urls without changing the registered icon fallback', () => {
|
|
105
|
+
const projectRoot = createProjectRoot();
|
|
106
|
+
createdRoots.push(projectRoot);
|
|
107
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
108
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
109
|
+
const resolved = core.resolveAiProfile({
|
|
110
|
+
workspaceId: 'workspace-1',
|
|
111
|
+
name: 'Codex',
|
|
112
|
+
icon: 'Bot',
|
|
113
|
+
color: '#0ea5e9'
|
|
114
|
+
});
|
|
115
|
+
const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
|
|
116
|
+
const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
|
|
117
|
+
const updated = core.updateAiProfileAvatar({
|
|
118
|
+
workspaceId: 'workspace-1',
|
|
119
|
+
profileId: resolved.profile.id,
|
|
120
|
+
avatarUrl,
|
|
121
|
+
avatarSourceUrl
|
|
122
|
+
});
|
|
123
|
+
expect(updated.avatarUrl).toBe(avatarUrl);
|
|
124
|
+
expect(updated.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
125
|
+
expect(updated.avatarRevision).toBe(1);
|
|
126
|
+
expect(updated.avatarUpdatedAt).toBe(updated.updatedAt);
|
|
127
|
+
expect(updated.icon).toBe('Bot');
|
|
128
|
+
expect(core.listAiProfiles('workspace-1')[0]?.avatarUrl).toBe(avatarUrl);
|
|
129
|
+
expect(core.listAiProfiles('workspace-1')[0]?.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
130
|
+
expect(core.listAiProfiles('workspace-1')[0]?.avatarRevision).toBe(1);
|
|
131
|
+
expect(core.listAiProfiles('workspace-1')[0]?.avatarUpdatedAt).toBe(updated.updatedAt);
|
|
132
|
+
const cleared = core.updateAiProfileAvatar({
|
|
133
|
+
workspaceId: 'workspace-1',
|
|
134
|
+
profileId: resolved.profile.id,
|
|
135
|
+
avatarUrl: null,
|
|
136
|
+
avatarSourceUrl: null
|
|
137
|
+
});
|
|
138
|
+
expect(cleared.avatarUrl).toBeNull();
|
|
139
|
+
expect(cleared.avatarSourceUrl).toBeNull();
|
|
140
|
+
expect(cleared.avatarRevision).toBe(2);
|
|
141
|
+
expect(cleared.avatarUpdatedAt).toBe(cleared.updatedAt);
|
|
142
|
+
});
|
|
143
|
+
it('seeds blank same-name runtime profiles when applying an ai profile avatar', () => {
|
|
144
|
+
const projectRoot = createProjectRoot();
|
|
145
|
+
createdRoots.push(projectRoot);
|
|
146
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
147
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
148
|
+
const local = core.resolveAiProfile({
|
|
149
|
+
workspaceId: 'workspace-1',
|
|
150
|
+
name: 'Codex',
|
|
151
|
+
icon: 'Bot',
|
|
152
|
+
color: '#0ea5e9'
|
|
153
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
154
|
+
const cloud = core.resolveAiProfile({
|
|
155
|
+
workspaceId: 'workspace-1',
|
|
156
|
+
name: 'Codex',
|
|
157
|
+
icon: 'Bot',
|
|
158
|
+
color: '#0ea5e9'
|
|
159
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
160
|
+
const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
|
|
161
|
+
const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
|
|
162
|
+
const updated = core.updateAiProfileAvatar({
|
|
163
|
+
workspaceId: 'workspace-1',
|
|
164
|
+
profileId: local.profile.id,
|
|
165
|
+
avatarUrl,
|
|
166
|
+
avatarSourceUrl
|
|
167
|
+
});
|
|
168
|
+
const profiles = core.listAiProfiles('workspace-1');
|
|
169
|
+
const refreshedLocal = profiles.find((profile) => profile.id === local.profile.id);
|
|
170
|
+
const refreshedCloud = profiles.find((profile) => profile.id === cloud.profile.id);
|
|
171
|
+
expect(refreshedLocal?.avatarUrl).toBe(avatarUrl);
|
|
172
|
+
expect(refreshedCloud?.avatarUrl).toBe(avatarUrl);
|
|
173
|
+
expect(refreshedCloud?.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
174
|
+
expect(refreshedCloud?.avatarRevision).toBe(updated.avatarRevision);
|
|
175
|
+
expect(refreshedCloud?.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
|
|
176
|
+
});
|
|
177
|
+
it('repairs blank same-name runtime profile avatars on token lookup', () => {
|
|
178
|
+
const projectRoot = createProjectRoot();
|
|
179
|
+
createdRoots.push(projectRoot);
|
|
180
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
181
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
182
|
+
const local = core.resolveAiProfile({
|
|
183
|
+
workspaceId: 'workspace-1',
|
|
184
|
+
name: 'Codex',
|
|
185
|
+
icon: 'Bot',
|
|
186
|
+
color: '#0ea5e9'
|
|
187
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
188
|
+
const cloud = core.resolveAiProfile({
|
|
189
|
+
workspaceId: 'workspace-1',
|
|
190
|
+
name: 'Codex',
|
|
191
|
+
icon: 'Bot',
|
|
192
|
+
color: '#0ea5e9'
|
|
193
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
194
|
+
const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
|
|
195
|
+
const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
|
|
196
|
+
const updated = core.updateAiProfileAvatar({
|
|
197
|
+
workspaceId: 'workspace-1',
|
|
198
|
+
profileId: local.profile.id,
|
|
199
|
+
avatarUrl,
|
|
200
|
+
avatarSourceUrl
|
|
201
|
+
});
|
|
202
|
+
core.db.prepare(`
|
|
203
|
+
UPDATE ai_profiles
|
|
204
|
+
SET avatar_url = NULL,
|
|
205
|
+
avatar_source_url = NULL,
|
|
206
|
+
avatar_revision = ?
|
|
207
|
+
WHERE id = ?
|
|
208
|
+
`).run(updated.avatarRevision, cloud.profile.id);
|
|
209
|
+
const lookedUp = core.getAiProfileByToken({
|
|
210
|
+
workspaceId: 'workspace-1',
|
|
211
|
+
profileToken: cloud.profile.profileToken,
|
|
212
|
+
seatScope: 'cloud_metered'
|
|
213
|
+
});
|
|
214
|
+
expect(lookedUp?.id).toBe(cloud.profile.id);
|
|
215
|
+
expect(lookedUp?.avatarUrl).toBe(avatarUrl);
|
|
216
|
+
expect(lookedUp?.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
217
|
+
expect(lookedUp?.avatarRevision).toBe(updated.avatarRevision);
|
|
218
|
+
});
|
|
219
|
+
it('preserves synced ai profile avatars when metadata-only sync payloads omit avatar fields', () => {
|
|
220
|
+
const projectRoot = createProjectRoot();
|
|
221
|
+
createdRoots.push(projectRoot);
|
|
222
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
223
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
224
|
+
const resolved = core.resolveAiProfile({
|
|
225
|
+
workspaceId: 'workspace-1',
|
|
226
|
+
name: 'Codex',
|
|
227
|
+
icon: 'Bot',
|
|
228
|
+
color: '#0ea5e9'
|
|
229
|
+
});
|
|
230
|
+
const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
|
|
231
|
+
const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
|
|
232
|
+
const updated = core.updateAiProfileAvatar({
|
|
233
|
+
workspaceId: 'workspace-1',
|
|
234
|
+
profileId: resolved.profile.id,
|
|
235
|
+
avatarUrl,
|
|
236
|
+
avatarSourceUrl
|
|
237
|
+
});
|
|
238
|
+
const { avatarUrl: _avatarUrl, avatarSourceUrl: _avatarSourceUrl, ...metadataOnlyProfile } = updated;
|
|
239
|
+
const metadataUpdatedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
|
|
240
|
+
const replayed = core.upsertAiProfileForSync({
|
|
241
|
+
...metadataOnlyProfile,
|
|
242
|
+
name: 'Codex Metadata Refresh',
|
|
243
|
+
updatedAt: metadataUpdatedAt,
|
|
244
|
+
lastActiveAt: metadataUpdatedAt
|
|
245
|
+
});
|
|
246
|
+
expect(replayed.name).toBe('Codex Metadata Refresh');
|
|
247
|
+
expect(replayed.avatarUrl).toBe(avatarUrl);
|
|
248
|
+
expect(replayed.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
249
|
+
});
|
|
250
|
+
it('does not clear synced ai profile avatars from legacy stale null sync payloads', () => {
|
|
251
|
+
const projectRoot = createProjectRoot();
|
|
252
|
+
createdRoots.push(projectRoot);
|
|
253
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
254
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
255
|
+
const resolved = core.resolveAiProfile({
|
|
256
|
+
workspaceId: 'workspace-1',
|
|
257
|
+
name: 'Codex',
|
|
258
|
+
icon: 'Bot',
|
|
259
|
+
color: '#0ea5e9'
|
|
260
|
+
});
|
|
261
|
+
const updated = core.updateAiProfileAvatar({
|
|
262
|
+
workspaceId: 'workspace-1',
|
|
263
|
+
profileId: resolved.profile.id,
|
|
264
|
+
avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
|
|
265
|
+
avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
|
|
266
|
+
});
|
|
267
|
+
const clearedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
|
|
268
|
+
const { avatarRevision: _avatarRevision, avatarUpdatedAt: _avatarUpdatedAt, ...legacyUpdated } = updated;
|
|
269
|
+
const cleared = core.upsertAiProfileForSync({
|
|
270
|
+
...legacyUpdated,
|
|
271
|
+
avatarUrl: null,
|
|
272
|
+
avatarSourceUrl: null,
|
|
273
|
+
updatedAt: clearedAt,
|
|
274
|
+
lastActiveAt: clearedAt
|
|
275
|
+
});
|
|
276
|
+
expect(cleared.avatarUrl).toBe(updated.avatarUrl);
|
|
277
|
+
expect(cleared.avatarSourceUrl).toBe(updated.avatarSourceUrl);
|
|
278
|
+
expect(cleared.avatarRevision).toBe(updated.avatarRevision);
|
|
279
|
+
});
|
|
280
|
+
it('does not clear synced ai profile avatars from blank revisions without avatar update timestamps', () => {
|
|
281
|
+
const projectRoot = createProjectRoot();
|
|
282
|
+
createdRoots.push(projectRoot);
|
|
283
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
284
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
285
|
+
const resolved = core.resolveAiProfile({
|
|
286
|
+
workspaceId: 'workspace-1',
|
|
287
|
+
name: 'Codex',
|
|
288
|
+
icon: 'Bot',
|
|
289
|
+
color: '#0ea5e9'
|
|
290
|
+
});
|
|
291
|
+
const updated = core.updateAiProfileAvatar({
|
|
292
|
+
workspaceId: 'workspace-1',
|
|
293
|
+
profileId: resolved.profile.id,
|
|
294
|
+
avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
|
|
295
|
+
avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
|
|
296
|
+
});
|
|
297
|
+
const refreshedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
|
|
298
|
+
const replayed = core.upsertAiProfileForSync({
|
|
299
|
+
...updated,
|
|
300
|
+
avatarUrl: null,
|
|
301
|
+
avatarSourceUrl: null,
|
|
302
|
+
avatarRevision: (updated.avatarRevision ?? 0) + 1,
|
|
303
|
+
avatarUpdatedAt: null,
|
|
304
|
+
updatedAt: refreshedAt,
|
|
305
|
+
lastActiveAt: refreshedAt
|
|
306
|
+
});
|
|
307
|
+
expect(replayed.avatarUrl).toBe(updated.avatarUrl);
|
|
308
|
+
expect(replayed.avatarSourceUrl).toBe(updated.avatarSourceUrl);
|
|
309
|
+
expect(replayed.avatarRevision).toBe(updated.avatarRevision);
|
|
310
|
+
expect(replayed.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
|
|
311
|
+
});
|
|
312
|
+
it('clears synced ai profile avatars when sync payloads send a newer avatar revision', () => {
|
|
313
|
+
const projectRoot = createProjectRoot();
|
|
314
|
+
createdRoots.push(projectRoot);
|
|
315
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
316
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
317
|
+
const resolved = core.resolveAiProfile({
|
|
318
|
+
workspaceId: 'workspace-1',
|
|
319
|
+
name: 'Codex',
|
|
320
|
+
icon: 'Bot',
|
|
321
|
+
color: '#0ea5e9'
|
|
322
|
+
});
|
|
323
|
+
const updated = core.updateAiProfileAvatar({
|
|
324
|
+
workspaceId: 'workspace-1',
|
|
325
|
+
profileId: resolved.profile.id,
|
|
326
|
+
avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
|
|
327
|
+
avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
|
|
328
|
+
});
|
|
329
|
+
const clearedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
|
|
330
|
+
const cleared = core.upsertAiProfileForSync({
|
|
331
|
+
...updated,
|
|
332
|
+
avatarUrl: null,
|
|
333
|
+
avatarSourceUrl: null,
|
|
334
|
+
avatarRevision: (updated.avatarRevision ?? 0) + 1,
|
|
335
|
+
avatarUpdatedAt: clearedAt,
|
|
336
|
+
updatedAt: clearedAt,
|
|
337
|
+
lastActiveAt: clearedAt
|
|
338
|
+
});
|
|
339
|
+
expect(cleared.avatarUrl).toBeNull();
|
|
340
|
+
expect(cleared.avatarSourceUrl).toBeNull();
|
|
341
|
+
expect(cleared.avatarRevision).toBe((updated.avatarRevision ?? 0) + 1);
|
|
342
|
+
expect(cleared.avatarUpdatedAt).toBe(clearedAt);
|
|
343
|
+
});
|
|
344
|
+
it('repairs blank legacy ai profile avatar pointers from workspace asset metadata', () => {
|
|
345
|
+
const projectRoot = createProjectRoot();
|
|
346
|
+
createdRoots.push(projectRoot);
|
|
347
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
348
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
349
|
+
const resolved = core.resolveAiProfile({
|
|
350
|
+
workspaceId: 'workspace-1',
|
|
351
|
+
name: 'Felix Leiter',
|
|
352
|
+
icon: 'Bot',
|
|
353
|
+
color: '#0ea5e9'
|
|
354
|
+
});
|
|
355
|
+
const db = core.db;
|
|
356
|
+
const croppedKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-cropped-Felix.webp`;
|
|
357
|
+
const sourceKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-source-Felix.webp`;
|
|
358
|
+
db.prepare(`
|
|
359
|
+
INSERT INTO workspace_assets (
|
|
360
|
+
asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
|
|
361
|
+
storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
|
|
362
|
+
created_at, updated_at
|
|
363
|
+
) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
|
|
364
|
+
`).run('asset-cropped', 'Felix avatar', 'cropped-Felix.webp', croppedKey, 'sha-cropped', '2026-04-28T15:00:00.000Z', '2026-04-28T15:00:00.000Z');
|
|
365
|
+
db.prepare(`
|
|
366
|
+
INSERT INTO workspace_assets (
|
|
367
|
+
asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
|
|
368
|
+
storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
|
|
369
|
+
created_at, updated_at
|
|
370
|
+
) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
|
|
371
|
+
`).run('asset-source', 'Felix source', 'source-Felix.webp', sourceKey, 'sha-source', '2026-04-28T15:01:00.000Z', '2026-04-28T15:01:00.000Z');
|
|
372
|
+
const repaired = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
|
|
373
|
+
expect(repaired?.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(croppedKey)}`);
|
|
374
|
+
expect(repaired?.avatarSourceUrl).toBe(`/api/taskforce/context/${encodeURIComponent(sourceKey)}`);
|
|
375
|
+
expect(repaired?.avatarRevision).toBe(1);
|
|
376
|
+
expect(repaired?.avatarUpdatedAt).toBe('2026-04-28T15:00:00.000Z');
|
|
377
|
+
core.updateAiProfileAvatar({
|
|
378
|
+
workspaceId: 'workspace-1',
|
|
379
|
+
profileId: resolved.profile.id,
|
|
380
|
+
avatarUrl: null,
|
|
381
|
+
avatarSourceUrl: null
|
|
382
|
+
});
|
|
383
|
+
const cleared = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
|
|
384
|
+
expect(cleared?.avatarUrl).toBeNull();
|
|
385
|
+
expect(cleared?.avatarSourceUrl).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
it('repairs missing synced ai profile avatars when timestamps match', () => {
|
|
388
|
+
const projectRoot = createProjectRoot();
|
|
389
|
+
createdRoots.push(projectRoot);
|
|
390
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
391
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
392
|
+
const resolved = core.resolveAiProfile({
|
|
393
|
+
workspaceId: 'workspace-1',
|
|
394
|
+
name: 'Codex',
|
|
395
|
+
icon: 'Bot',
|
|
396
|
+
color: '#0ea5e9'
|
|
397
|
+
});
|
|
398
|
+
const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
|
|
399
|
+
const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
|
|
400
|
+
const updated = core.updateAiProfileAvatar({
|
|
401
|
+
workspaceId: 'workspace-1',
|
|
402
|
+
profileId: resolved.profile.id,
|
|
403
|
+
avatarUrl,
|
|
404
|
+
avatarSourceUrl
|
|
405
|
+
});
|
|
406
|
+
core.db.prepare(`
|
|
407
|
+
UPDATE ai_profiles
|
|
408
|
+
SET avatar_url = NULL,
|
|
409
|
+
avatar_source_url = NULL
|
|
410
|
+
WHERE id = ?
|
|
411
|
+
`).run(updated.id);
|
|
412
|
+
const repaired = core.upsertAiProfileForSync(updated);
|
|
413
|
+
expect(repaired.updatedAt).toBe(updated.updatedAt);
|
|
414
|
+
expect(repaired.avatarUrl).toBe(avatarUrl);
|
|
415
|
+
expect(repaired.avatarSourceUrl).toBe(avatarSourceUrl);
|
|
416
|
+
});
|
|
101
417
|
it('stores expanded ai profile fields and preserves them when omitted on re-resolve', () => {
|
|
102
418
|
const projectRoot = createProjectRoot();
|
|
103
419
|
createdRoots.push(projectRoot);
|
|
@@ -339,6 +655,73 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
339
655
|
});
|
|
340
656
|
expect(second.profile.surfaceType).toBe('coding_tool');
|
|
341
657
|
});
|
|
658
|
+
it('supports cli and maps legacy other surfaceType values to agent', () => {
|
|
659
|
+
const projectRoot = createProjectRoot();
|
|
660
|
+
createdRoots.push(projectRoot);
|
|
661
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
662
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
663
|
+
const cliProfile = core.resolveAiProfile({
|
|
664
|
+
workspaceId: 'workspace-1',
|
|
665
|
+
name: 'Codex',
|
|
666
|
+
surfaceType: 'cli'
|
|
667
|
+
});
|
|
668
|
+
expect(cliProfile.profile.surfaceType).toBe('cli');
|
|
669
|
+
expect(cliProfile.profile.role).toBe('CLI Coding Agent');
|
|
670
|
+
const legacyOtherProfile = core.resolveAiProfile({
|
|
671
|
+
workspaceId: 'workspace-1',
|
|
672
|
+
name: 'Legacy Agent',
|
|
673
|
+
surfaceType: 'other'
|
|
674
|
+
});
|
|
675
|
+
expect(legacyOtherProfile.profile.surfaceType).toBe('agent');
|
|
676
|
+
});
|
|
677
|
+
it('keeps a manually updated ai profile surfaceType from being overwritten by future registration', () => {
|
|
678
|
+
const projectRoot = createProjectRoot();
|
|
679
|
+
createdRoots.push(projectRoot);
|
|
680
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
681
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
682
|
+
const first = core.resolveAiProfile({
|
|
683
|
+
workspaceId: 'workspace-1',
|
|
684
|
+
name: 'ChatGPT',
|
|
685
|
+
surfaceType: 'chat_app'
|
|
686
|
+
});
|
|
687
|
+
const updated = core.updateAiProfileSurfaceType({
|
|
688
|
+
workspaceId: 'workspace-1',
|
|
689
|
+
profileId: first.profile.id,
|
|
690
|
+
surfaceType: 'coding_tool'
|
|
691
|
+
});
|
|
692
|
+
expect(updated.surfaceType).toBe('coding_tool');
|
|
693
|
+
const replayedRegistration = core.resolveAiProfile({
|
|
694
|
+
workspaceId: 'workspace-1',
|
|
695
|
+
name: 'ChatGPT',
|
|
696
|
+
surfaceType: 'chat_app',
|
|
697
|
+
profileToken: first.profile.profileToken
|
|
698
|
+
});
|
|
699
|
+
expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
|
|
700
|
+
});
|
|
701
|
+
it('treats unclassified ai profile category updates as clearing the manual override', () => {
|
|
702
|
+
const projectRoot = createProjectRoot();
|
|
703
|
+
createdRoots.push(projectRoot);
|
|
704
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
705
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
706
|
+
const first = core.resolveAiProfile({
|
|
707
|
+
workspaceId: 'workspace-1',
|
|
708
|
+
name: 'ChatGPT',
|
|
709
|
+
surfaceType: 'chat_app'
|
|
710
|
+
});
|
|
711
|
+
const cleared = core.updateAiProfileSurfaceType({
|
|
712
|
+
workspaceId: 'workspace-1',
|
|
713
|
+
profileId: first.profile.id,
|
|
714
|
+
surfaceType: null
|
|
715
|
+
});
|
|
716
|
+
expect(cleared.surfaceType).toBeNull();
|
|
717
|
+
const replayedRegistration = core.resolveAiProfile({
|
|
718
|
+
workspaceId: 'workspace-1',
|
|
719
|
+
name: 'ChatGPT',
|
|
720
|
+
surfaceType: 'coding_tool',
|
|
721
|
+
profileToken: first.profile.profileToken
|
|
722
|
+
});
|
|
723
|
+
expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
|
|
724
|
+
});
|
|
342
725
|
it('rejects invalid ai profile surfaceType values', () => {
|
|
343
726
|
const projectRoot = createProjectRoot();
|
|
344
727
|
createdRoots.push(projectRoot);
|
|
@@ -454,7 +837,7 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
454
837
|
name: 'Claude',
|
|
455
838
|
color: '#f59e0b'
|
|
456
839
|
});
|
|
457
|
-
// Resolve by name
|
|
840
|
+
// Resolve by name only when the alias is uniquely attributable.
|
|
458
841
|
const actorByName = core.resolveWorkspaceActor('workspace-1', 'Claude');
|
|
459
842
|
expect(actorByName).not.toBeNull();
|
|
460
843
|
expect(actorByName?.id).toBe(profile.id);
|
|
@@ -464,6 +847,35 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
464
847
|
const actorLower = core.resolveWorkspaceActor('workspace-1', 'claude');
|
|
465
848
|
expect(actorLower?.id).toBe(profile.id);
|
|
466
849
|
});
|
|
850
|
+
it('leaves duplicate ai profile aliases unresolved instead of picking one profile', () => {
|
|
851
|
+
const projectRoot = createProjectRoot();
|
|
852
|
+
createdRoots.push(projectRoot);
|
|
853
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
854
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
855
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
|
|
856
|
+
const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
|
|
857
|
+
const db = core.db;
|
|
858
|
+
db.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
|
|
859
|
+
.run('default', second.profile.id);
|
|
860
|
+
const actor = core.resolveWorkspaceActor('workspace-1', 'Claude');
|
|
861
|
+
expect(actor?.id).toBe('Claude');
|
|
862
|
+
expect(actor?.kind).toBe('ai');
|
|
863
|
+
expect(actor?.meta?.unresolved).toBe(true);
|
|
864
|
+
const task = core.createTask({
|
|
865
|
+
title: 'Ambiguous legacy comment author',
|
|
866
|
+
comments: [{
|
|
867
|
+
id: 'comment-1',
|
|
868
|
+
author: 'Claude',
|
|
869
|
+
text: 'Keep the original ambiguous alias.',
|
|
870
|
+
timestamp: '2026-03-22T10:00:00.000Z'
|
|
871
|
+
}]
|
|
872
|
+
}, { workspaceId: 'workspace-1' });
|
|
873
|
+
const fetched = core.getTask(task.id, 'workspace-1');
|
|
874
|
+
expect(fetched?.comments?.[0]?.author).toBe('Claude');
|
|
875
|
+
expect(fetched?.comments?.[0]?.actor?.id).toBe('Claude');
|
|
876
|
+
expect(fetched?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
|
|
877
|
+
expect(first.profile.id).not.toBe(second.profile.id);
|
|
878
|
+
});
|
|
467
879
|
it('classifies well-known AI model names as ai-kind even without a registered profile', () => {
|
|
468
880
|
const projectRoot = createProjectRoot();
|
|
469
881
|
createdRoots.push(projectRoot);
|
|
@@ -492,82 +904,398 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
492
904
|
expect(second.profile.id).toBe(first.profile.id);
|
|
493
905
|
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
494
906
|
});
|
|
495
|
-
it('reuses
|
|
907
|
+
it('reuses a stateless ai profile by name and provider while treating model changes as metadata updates', () => {
|
|
496
908
|
const projectRoot = createProjectRoot();
|
|
497
909
|
createdRoots.push(projectRoot);
|
|
498
910
|
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
499
911
|
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
500
|
-
core.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
912
|
+
const first = core.resolveAiProfile({
|
|
913
|
+
workspaceId: 'workspace-1',
|
|
914
|
+
name: 'Claude',
|
|
915
|
+
provider: 'Anthropic',
|
|
916
|
+
model: 'claude-sonnet-4-5',
|
|
917
|
+
surfaceType: 'chat_app'
|
|
918
|
+
});
|
|
919
|
+
const second = core.resolveAiProfile({
|
|
920
|
+
workspaceId: 'workspace-1',
|
|
921
|
+
name: 'claude',
|
|
922
|
+
provider: 'anthropic',
|
|
923
|
+
model: 'claude-sonnet-4-6',
|
|
924
|
+
surfaceType: 'chat_app'
|
|
925
|
+
});
|
|
505
926
|
expect(second.created).toBe(false);
|
|
506
927
|
expect(second.profile.id).toBe(first.profile.id);
|
|
507
|
-
expect(
|
|
508
|
-
expect(otherWorkspace.profile.id).not.toBe(first.profile.id);
|
|
928
|
+
expect(second.profile.model).toBe('claude-sonnet-4-6');
|
|
509
929
|
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
510
|
-
expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
|
|
511
930
|
});
|
|
512
|
-
it('
|
|
931
|
+
it('reuses a provider-bearing stateless ai profile when a later resolve omits provider and model', () => {
|
|
513
932
|
const projectRoot = createProjectRoot();
|
|
514
933
|
createdRoots.push(projectRoot);
|
|
515
934
|
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
516
935
|
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
517
|
-
|
|
518
|
-
|
|
936
|
+
const first = core.resolveAiProfile({
|
|
937
|
+
workspaceId: 'workspace-1',
|
|
938
|
+
name: 'Claude',
|
|
939
|
+
provider: 'Anthropic',
|
|
940
|
+
model: 'claude-sonnet-4-5',
|
|
941
|
+
surfaceType: 'chat_app'
|
|
942
|
+
});
|
|
519
943
|
const second = core.resolveAiProfile({
|
|
520
944
|
workspaceId: 'workspace-1',
|
|
521
|
-
name: '
|
|
522
|
-
icon: 'Sparkles',
|
|
523
|
-
profileToken: first.profile.profileToken
|
|
945
|
+
name: 'claude'
|
|
524
946
|
});
|
|
525
947
|
expect(second.created).toBe(false);
|
|
526
948
|
expect(second.profile.id).toBe(first.profile.id);
|
|
527
|
-
expect(second.profile.
|
|
949
|
+
expect(second.profile.provider).toBe('Anthropic');
|
|
950
|
+
expect(second.profile.model).toBe('claude-sonnet-4-5');
|
|
528
951
|
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
529
952
|
});
|
|
530
|
-
it('reuses
|
|
953
|
+
it('reuses same-scope ai profiles across provider drift', () => {
|
|
531
954
|
const projectRoot = createProjectRoot();
|
|
532
955
|
createdRoots.push(projectRoot);
|
|
533
956
|
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
534
957
|
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
535
|
-
|
|
536
|
-
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' });
|
|
537
|
-
const second = core.resolveAiProfile({
|
|
958
|
+
const original = core.resolveAiProfile({
|
|
538
959
|
workspaceId: 'workspace-1',
|
|
539
|
-
name: '
|
|
540
|
-
|
|
960
|
+
name: 'Felix Leiter',
|
|
961
|
+
provider: 'OpenAI',
|
|
962
|
+
model: 'openai-codex/gpt-5.4',
|
|
963
|
+
surfaceType: 'agent'
|
|
541
964
|
});
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
965
|
+
const drifted = core.resolveAiProfile({
|
|
966
|
+
workspaceId: 'workspace-1',
|
|
967
|
+
name: 'Felix Leiter',
|
|
968
|
+
provider: 'OpenClaw',
|
|
969
|
+
model: 'gpt-5.4',
|
|
970
|
+
surfaceType: 'agent'
|
|
971
|
+
});
|
|
972
|
+
expect(drifted.created).toBe(false);
|
|
973
|
+
expect(drifted.profile.id).toBe(original.profile.id);
|
|
974
|
+
expect(drifted.profile.provider).toBe('OpenClaw');
|
|
975
|
+
expect(drifted.profile.model).toBe('gpt-5.4');
|
|
545
976
|
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
546
977
|
});
|
|
547
|
-
it('
|
|
978
|
+
it('reuses same-scope ai profiles across surface type drift', () => {
|
|
548
979
|
const projectRoot = createProjectRoot();
|
|
549
980
|
createdRoots.push(projectRoot);
|
|
550
981
|
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
551
982
|
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
983
|
+
const chat = core.resolveAiProfile({
|
|
984
|
+
workspaceId: 'workspace-1',
|
|
985
|
+
name: 'Claude',
|
|
986
|
+
provider: 'Anthropic',
|
|
987
|
+
model: 'claude-sonnet-4-6',
|
|
988
|
+
surfaceType: 'chat_app'
|
|
989
|
+
});
|
|
990
|
+
const codingTool = core.resolveAiProfile({
|
|
991
|
+
workspaceId: 'workspace-1',
|
|
992
|
+
name: 'Claude',
|
|
993
|
+
provider: 'Anthropic',
|
|
994
|
+
model: 'claude-sonnet-4-6',
|
|
995
|
+
surfaceType: 'coding_tool'
|
|
996
|
+
});
|
|
997
|
+
expect(codingTool.created).toBe(false);
|
|
998
|
+
expect(codingTool.profile.id).toBe(chat.profile.id);
|
|
999
|
+
expect(codingTool.profile.surfaceType).toBe('coding_tool');
|
|
1000
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1001
|
+
});
|
|
1002
|
+
it('reuses same-scope ai profiles when reconnecting with a stale token', () => {
|
|
1003
|
+
const projectRoot = createProjectRoot();
|
|
1004
|
+
createdRoots.push(projectRoot);
|
|
1005
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1006
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1007
|
+
const original = core.resolveAiProfile({
|
|
1008
|
+
workspaceId: 'workspace-1',
|
|
1009
|
+
name: 'Tiffany Case',
|
|
1010
|
+
provider: 'openai-codex',
|
|
1011
|
+
model: 'gpt-5.4',
|
|
1012
|
+
surfaceType: 'agent'
|
|
1013
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1014
|
+
const reconnected = core.resolveAiProfile({
|
|
1015
|
+
workspaceId: 'workspace-1',
|
|
1016
|
+
name: 'Tiffany Case',
|
|
1017
|
+
profileToken: 'tfp_stale_tiffany_case_token',
|
|
1018
|
+
provider: 'OpenAI Codex',
|
|
1019
|
+
model: 'openai-codex/gpt-5.4',
|
|
1020
|
+
surfaceType: 'agent'
|
|
1021
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1022
|
+
expect(reconnected.created).toBe(false);
|
|
1023
|
+
expect(reconnected.profile.id).toBe(original.profile.id);
|
|
1024
|
+
expect(reconnected.profile.provider).toBe('OpenAI Codex');
|
|
1025
|
+
expect(reconnected.profile.model).toBe('openai-codex/gpt-5.4');
|
|
1026
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1027
|
+
});
|
|
1028
|
+
it('prefers the most recently active matching ai profile when stateless candidates are duplicated', () => {
|
|
1029
|
+
const projectRoot = createProjectRoot();
|
|
1030
|
+
createdRoots.push(projectRoot);
|
|
1031
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1032
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1033
|
+
const older = core.resolveAiProfile({
|
|
1034
|
+
workspaceId: 'workspace-1',
|
|
1035
|
+
name: 'Claude',
|
|
1036
|
+
provider: 'Anthropic',
|
|
1037
|
+
model: 'claude-sonnet-4-5',
|
|
1038
|
+
surfaceType: 'chat_app'
|
|
1039
|
+
});
|
|
1040
|
+
const newer = core.resolveAiProfile({
|
|
1041
|
+
workspaceId: 'workspace-1',
|
|
1042
|
+
name: 'Claude Duplicate',
|
|
1043
|
+
provider: 'Anthropic',
|
|
1044
|
+
model: 'claude-sonnet-4-6',
|
|
1045
|
+
surfaceType: 'chat_app'
|
|
1046
|
+
});
|
|
1047
|
+
core.db.prepare(`
|
|
1048
|
+
UPDATE ai_profiles
|
|
1049
|
+
SET name = 'Claude',
|
|
1050
|
+
username = 'claude',
|
|
1051
|
+
last_active_at = CASE id
|
|
1052
|
+
WHEN ? THEN '2026-04-01T00:00:00.000Z'
|
|
1053
|
+
ELSE '2026-04-02T00:00:00.000Z'
|
|
1054
|
+
END,
|
|
1055
|
+
updated_at = CASE id
|
|
1056
|
+
WHEN ? THEN '2026-04-01T00:00:00.000Z'
|
|
1057
|
+
ELSE '2026-04-02T00:00:00.000Z'
|
|
1058
|
+
END
|
|
1059
|
+
WHERE id IN (?, ?)
|
|
1060
|
+
`).run(older.profile.id, older.profile.id, older.profile.id, newer.profile.id);
|
|
1061
|
+
const resolved = core.resolveAiProfile({
|
|
1062
|
+
workspaceId: 'workspace-1',
|
|
1063
|
+
name: 'Claude',
|
|
1064
|
+
provider: 'Anthropic',
|
|
1065
|
+
model: 'claude-sonnet-4-7',
|
|
1066
|
+
surfaceType: 'chat_app'
|
|
1067
|
+
});
|
|
1068
|
+
expect(resolved.created).toBe(false);
|
|
1069
|
+
expect(resolved.profile.id).toBe(newer.profile.id);
|
|
1070
|
+
expect(resolved.duplicateWarning?.existingProfileId).toBe(newer.profile.id);
|
|
1071
|
+
});
|
|
1072
|
+
it('reuses Codex by exact name without leaking across workspaces', () => {
|
|
1073
|
+
const projectRoot = createProjectRoot();
|
|
1074
|
+
createdRoots.push(projectRoot);
|
|
1075
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1076
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1077
|
+
core.createWorkspace({ workspaceId: 'workspace-2', name: 'Workspace 2' });
|
|
1078
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
|
|
1079
|
+
const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
|
|
1080
|
+
const otherWorkspace = core.resolveAiProfile({ workspaceId: 'workspace-2', name: 'Codex' });
|
|
1081
|
+
expect(first.created).toBe(true);
|
|
1082
|
+
expect(second.created).toBe(false);
|
|
1083
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
1084
|
+
expect(otherWorkspace.created).toBe(true);
|
|
1085
|
+
expect(otherWorkspace.profile.id).not.toBe(first.profile.id);
|
|
1086
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1087
|
+
expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
|
|
1088
|
+
});
|
|
1089
|
+
it('allows token-based re-resolve even when the ai profile limit is full', () => {
|
|
1090
|
+
const projectRoot = createProjectRoot();
|
|
1091
|
+
createdRoots.push(projectRoot);
|
|
1092
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1093
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1094
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
1095
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
|
|
1096
|
+
const second = core.resolveAiProfile({
|
|
1097
|
+
workspaceId: 'workspace-1',
|
|
1098
|
+
name: 'Codex Prime',
|
|
1099
|
+
icon: 'Sparkles',
|
|
1100
|
+
profileToken: first.profile.profileToken
|
|
1101
|
+
});
|
|
1102
|
+
expect(second.created).toBe(false);
|
|
1103
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
1104
|
+
expect(second.profile.name).toBe('Codex Prime');
|
|
1105
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1106
|
+
});
|
|
1107
|
+
it('reuses an exact-match ai profile even when the ai profile limit is full', () => {
|
|
1108
|
+
const projectRoot = createProjectRoot();
|
|
1109
|
+
createdRoots.push(projectRoot);
|
|
1110
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1111
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1112
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
1113
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' }, { trustedSeatScope: 'cloud_metered' });
|
|
1114
|
+
const second = core.resolveAiProfile({
|
|
1115
|
+
workspaceId: 'workspace-1',
|
|
1116
|
+
name: 'Automation Codex',
|
|
1117
|
+
role: 'Assistant',
|
|
1118
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1119
|
+
expect(second.created).toBe(false);
|
|
1120
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
1121
|
+
expect(second.profile.role).toBe('Assistant');
|
|
1122
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1123
|
+
});
|
|
1124
|
+
it('blocks net-new ai profile creation when the ai profile limit is full', () => {
|
|
1125
|
+
const projectRoot = createProjectRoot();
|
|
1126
|
+
createdRoots.push(projectRoot);
|
|
1127
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1128
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1129
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
1130
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' }, { trustedSeatScope: 'cloud_metered' });
|
|
1131
|
+
expect(first.created).toBe(true);
|
|
1132
|
+
try {
|
|
1133
|
+
core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude' }, { trustedSeatScope: 'cloud_metered' });
|
|
1134
|
+
throw new Error('Expected AI profile creation to be blocked');
|
|
1135
|
+
}
|
|
1136
|
+
catch (error) {
|
|
1137
|
+
expect(error.message).toContain('No AI seats are available for this workspace');
|
|
1138
|
+
expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
|
|
1139
|
+
expect(error.statusCode).toBe(403);
|
|
1140
|
+
expect(error.details).toEqual({
|
|
1141
|
+
workspaceId: 'workspace-1',
|
|
1142
|
+
limit: 1,
|
|
1143
|
+
used: 1,
|
|
567
1144
|
remaining: 0
|
|
568
1145
|
});
|
|
569
1146
|
}
|
|
570
1147
|
});
|
|
1148
|
+
it('counts only cloud-metered ai profiles toward the seat limit', () => {
|
|
1149
|
+
const projectRoot = createProjectRoot();
|
|
1150
|
+
createdRoots.push(projectRoot);
|
|
1151
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1152
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1153
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
1154
|
+
const local = core.resolveAiProfile({
|
|
1155
|
+
workspaceId: 'workspace-1',
|
|
1156
|
+
name: 'Local Codex',
|
|
1157
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
1158
|
+
const cloud = core.resolveAiProfile({
|
|
1159
|
+
workspaceId: 'workspace-1',
|
|
1160
|
+
name: 'Cloud Codex',
|
|
1161
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1162
|
+
const extraLocal = core.resolveAiProfile({
|
|
1163
|
+
workspaceId: 'workspace-1',
|
|
1164
|
+
name: 'Another Local Agent',
|
|
1165
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
1166
|
+
expect(local.profile.seatScope).toBe('local_unmetered');
|
|
1167
|
+
expect(cloud.profile.seatScope).toBe('cloud_metered');
|
|
1168
|
+
expect(extraLocal.profile.seatScope).toBe('local_unmetered');
|
|
1169
|
+
expect(core.countAiProfileUsage('workspace-1')).toEqual({
|
|
1170
|
+
used: 1,
|
|
1171
|
+
limit: 1,
|
|
1172
|
+
remaining: 0
|
|
1173
|
+
});
|
|
1174
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(3);
|
|
1175
|
+
});
|
|
1176
|
+
it('honors configured ai profile limits even when legacy plan data stores the feature as enabled', () => {
|
|
1177
|
+
const projectRoot = createProjectRoot();
|
|
1178
|
+
createdRoots.push(projectRoot);
|
|
1179
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1180
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1181
|
+
core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
|
|
1182
|
+
core.ensureBootstrapUserAccess({
|
|
1183
|
+
userId: 'owner-u1',
|
|
1184
|
+
workspaceId: 'workspace-1',
|
|
1185
|
+
role: 'owner',
|
|
1186
|
+
email: 'owner-u1@example.com'
|
|
1187
|
+
});
|
|
1188
|
+
core.updatePlanVersionFeatures({
|
|
1189
|
+
planVersionId: 'free-v1',
|
|
1190
|
+
features: [
|
|
1191
|
+
{ featureKey: 'collaboration.ai_profiles', access: 'enabled', config: { maxAiProfiles: 20 } }
|
|
1192
|
+
]
|
|
1193
|
+
});
|
|
1194
|
+
core.db.prepare(`
|
|
1195
|
+
UPDATE plan_feature_matrix
|
|
1196
|
+
SET access = 'enabled',
|
|
1197
|
+
config_json = ?
|
|
1198
|
+
WHERE tenant_id = ? AND plan_version_id = ? AND feature_key = ?
|
|
1199
|
+
`).run(JSON.stringify({ maxAiProfiles: 20 }), 'default', 'free-v1', 'collaboration.ai_profiles');
|
|
1200
|
+
core.updateAccountEntitlement({
|
|
1201
|
+
accountId: 'owner-u1',
|
|
1202
|
+
planVersionId: 'free-v1',
|
|
1203
|
+
entitlementState: 'active',
|
|
1204
|
+
actorUserId: 'system-admin',
|
|
1205
|
+
actorRole: 'system_admin'
|
|
1206
|
+
});
|
|
1207
|
+
core.resolveAiProfile({
|
|
1208
|
+
workspaceId: 'workspace-1',
|
|
1209
|
+
name: 'Cloud Codex',
|
|
1210
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1211
|
+
expect(core.countAiProfileUsage('workspace-1')).toEqual({
|
|
1212
|
+
used: 1,
|
|
1213
|
+
limit: 20,
|
|
1214
|
+
remaining: 19
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
it('blocks cloud promotion when a local ai profile would exceed the seat limit', () => {
|
|
1218
|
+
const projectRoot = createProjectRoot();
|
|
1219
|
+
createdRoots.push(projectRoot);
|
|
1220
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1221
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1222
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
1223
|
+
core.resolveAiProfile({
|
|
1224
|
+
workspaceId: 'workspace-1',
|
|
1225
|
+
name: 'Cloud Bot',
|
|
1226
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1227
|
+
const local = core.resolveAiProfile({
|
|
1228
|
+
workspaceId: 'workspace-1',
|
|
1229
|
+
name: 'Hybrid Bot',
|
|
1230
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
1231
|
+
try {
|
|
1232
|
+
core.resolveAiProfile({
|
|
1233
|
+
workspaceId: 'workspace-1',
|
|
1234
|
+
name: 'Hybrid Bot',
|
|
1235
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1236
|
+
throw new Error('Expected cloud promotion to be blocked');
|
|
1237
|
+
}
|
|
1238
|
+
catch (error) {
|
|
1239
|
+
expect(error.message).toContain('No AI seats are available for this workspace');
|
|
1240
|
+
expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
|
|
1241
|
+
}
|
|
1242
|
+
const refreshed = core.getAiProfileByToken({
|
|
1243
|
+
workspaceId: 'workspace-1',
|
|
1244
|
+
profileToken: local.profile.profileToken
|
|
1245
|
+
});
|
|
1246
|
+
expect(refreshed?.seatScope).toBe('local_unmetered');
|
|
1247
|
+
});
|
|
1248
|
+
it('keeps local and cloud MCP profiles separate when they share the same name', () => {
|
|
1249
|
+
const projectRoot = createProjectRoot();
|
|
1250
|
+
createdRoots.push(projectRoot);
|
|
1251
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1252
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1253
|
+
const cloud = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
|
|
1254
|
+
const local = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
|
|
1255
|
+
expect(local.created).toBe(true);
|
|
1256
|
+
expect(local.profile.id).not.toBe(cloud.profile.id);
|
|
1257
|
+
expect(local.profile.seatScope).toBe('local_unmetered');
|
|
1258
|
+
expect(cloud.profile.seatScope).toBe('cloud_metered');
|
|
1259
|
+
expect(core.countAiProfileUsage('workspace-1')).toEqual({
|
|
1260
|
+
used: 1,
|
|
1261
|
+
limit: null,
|
|
1262
|
+
remaining: null
|
|
1263
|
+
});
|
|
1264
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(2);
|
|
1265
|
+
});
|
|
1266
|
+
it('claims a legacy unscoped profile for the trusted runtime instead of creating a duplicate', () => {
|
|
1267
|
+
const projectRoot = createProjectRoot();
|
|
1268
|
+
createdRoots.push(projectRoot);
|
|
1269
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1270
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1271
|
+
const legacy = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Legacy Codex' });
|
|
1272
|
+
expect(legacy.profile.seatScope ?? null).toBeNull();
|
|
1273
|
+
const claimed = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Legacy Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
|
|
1274
|
+
expect(claimed.created).toBe(false);
|
|
1275
|
+
expect(claimed.profile.id).toBe(legacy.profile.id);
|
|
1276
|
+
expect(claimed.profile.seatScope).toBe('local_unmetered');
|
|
1277
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1278
|
+
});
|
|
1279
|
+
it('reactivates a retired legacy unscoped profile for the trusted runtime instead of recreating it', () => {
|
|
1280
|
+
const projectRoot = createProjectRoot();
|
|
1281
|
+
createdRoots.push(projectRoot);
|
|
1282
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1283
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1284
|
+
const legacy = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Retired Legacy Codex' });
|
|
1285
|
+
expect(legacy.profile.seatScope ?? null).toBeNull();
|
|
1286
|
+
core.archiveAiProfile({
|
|
1287
|
+
workspaceId: 'workspace-1',
|
|
1288
|
+
profileId: legacy.profile.id,
|
|
1289
|
+
archivedBy: 'owner-1',
|
|
1290
|
+
reason: 'manual_archive'
|
|
1291
|
+
});
|
|
1292
|
+
const reactivated = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Retired Legacy Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
|
|
1293
|
+
expect(reactivated.created).toBe(false);
|
|
1294
|
+
expect(reactivated.profile.id).toBe(legacy.profile.id);
|
|
1295
|
+
expect(reactivated.profile.archivedAt).toBeNull();
|
|
1296
|
+
expect(reactivated.profile.seatScope).toBe('cloud_metered');
|
|
1297
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1298
|
+
});
|
|
571
1299
|
it('merges two duplicate profiles, reassigning task assignees and comment authors', () => {
|
|
572
1300
|
const projectRoot = createProjectRoot();
|
|
573
1301
|
createdRoots.push(projectRoot);
|
|
@@ -593,16 +1321,324 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
593
1321
|
mergeId: second.profile.id
|
|
594
1322
|
});
|
|
595
1323
|
expect(merged.id).toBe(first.profile.id);
|
|
596
|
-
// Duplicate profile should
|
|
1324
|
+
// Duplicate profile should leave the active roster but remain archived for history and sync.
|
|
597
1325
|
const profiles = core.listAiProfiles('workspace-1');
|
|
598
1326
|
expect(profiles.some((p) => p.id === second.profile.id)).toBe(false);
|
|
599
1327
|
expect(profiles.some((p) => p.id === first.profile.id)).toBe(true);
|
|
1328
|
+
const archivedProfiles = core.listAiProfiles('workspace-1', { includeArchived: true });
|
|
1329
|
+
const archivedDuplicate = archivedProfiles.find((p) => p.id === second.profile.id);
|
|
1330
|
+
expect(archivedDuplicate).toMatchObject({
|
|
1331
|
+
id: second.profile.id,
|
|
1332
|
+
archivedReason: 'merged_duplicate',
|
|
1333
|
+
mergedIntoProfileId: first.profile.id,
|
|
1334
|
+
archivedBy: first.profile.id
|
|
1335
|
+
});
|
|
1336
|
+
expect(archivedDuplicate?.archivedAt).toBeTruthy();
|
|
600
1337
|
// Task assignee should point to the kept profile
|
|
601
1338
|
const updatedTask = core.getTask(task.id, 'workspace-1');
|
|
602
1339
|
expect(updatedTask?.assignee).toBe(first.profile.id);
|
|
603
1340
|
// Comment author should point to the kept profile
|
|
604
1341
|
expect(updatedTask?.comments?.[0]?.author).toBe(first.profile.id);
|
|
605
1342
|
});
|
|
1343
|
+
it('does not merge separate local and cloud AI profile registrations', () => {
|
|
1344
|
+
const projectRoot = createProjectRoot();
|
|
1345
|
+
createdRoots.push(projectRoot);
|
|
1346
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1347
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1348
|
+
const cloud = core.resolveAiProfile({
|
|
1349
|
+
workspaceId: 'workspace-1',
|
|
1350
|
+
name: 'Codex',
|
|
1351
|
+
surfaceType: 'coding_tool'
|
|
1352
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1353
|
+
const local = core.resolveAiProfile({
|
|
1354
|
+
workspaceId: 'workspace-1',
|
|
1355
|
+
name: 'Codex',
|
|
1356
|
+
surfaceType: 'coding_tool'
|
|
1357
|
+
}, { trustedSeatScope: 'local_unmetered' });
|
|
1358
|
+
expect(() => core.mergeAiProfiles({
|
|
1359
|
+
workspaceId: 'workspace-1',
|
|
1360
|
+
keepId: local.profile.id,
|
|
1361
|
+
mergeId: cloud.profile.id
|
|
1362
|
+
})).toThrow(/separate registrations/);
|
|
1363
|
+
expect(core.listAiProfiles('workspace-1').map((profile) => profile.id).sort()).toEqual([cloud.profile.id, local.profile.id].sort());
|
|
1364
|
+
});
|
|
1365
|
+
it('archives AI profiles out of the active roster while preserving history and freeing seats', () => {
|
|
1366
|
+
const projectRoot = createProjectRoot();
|
|
1367
|
+
createdRoots.push(projectRoot);
|
|
1368
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1369
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1370
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-1', 1);
|
|
1371
|
+
const first = core.resolveAiProfile({
|
|
1372
|
+
workspaceId: 'workspace-1',
|
|
1373
|
+
name: 'Codex',
|
|
1374
|
+
surfaceType: 'coding_tool'
|
|
1375
|
+
});
|
|
1376
|
+
const archived = core.archiveAiProfile({
|
|
1377
|
+
workspaceId: 'workspace-1',
|
|
1378
|
+
profileId: first.profile.id,
|
|
1379
|
+
archivedBy: 'owner-1',
|
|
1380
|
+
reason: 'manual_archive'
|
|
1381
|
+
});
|
|
1382
|
+
expect(archived.id).toBe(first.profile.id);
|
|
1383
|
+
expect(archived.archivedReason).toBe('manual_archive');
|
|
1384
|
+
expect(archived.archivedBy).toBe('owner-1');
|
|
1385
|
+
expect(archived.archivedAt).toBeTruthy();
|
|
1386
|
+
expect(core.listAiProfiles('workspace-1')).toEqual([]);
|
|
1387
|
+
expect(core.listAssignableActors('workspace-1').some((actor) => actor.id === first.profile.id)).toBe(false);
|
|
1388
|
+
const historicalActor = core.resolveWorkspaceActor('workspace-1', first.profile.id);
|
|
1389
|
+
expect(historicalActor?.id).toBe(first.profile.id);
|
|
1390
|
+
expect(historicalActor?.label).toBe('Codex');
|
|
1391
|
+
expect(historicalActor?.meta).toEqual(expect.objectContaining({
|
|
1392
|
+
archivedReason: 'manual_archive'
|
|
1393
|
+
}));
|
|
1394
|
+
const second = core.resolveAiProfile({
|
|
1395
|
+
workspaceId: 'workspace-1',
|
|
1396
|
+
name: 'Claude',
|
|
1397
|
+
surfaceType: 'coding_tool'
|
|
1398
|
+
});
|
|
1399
|
+
expect(second.created).toBe(true);
|
|
1400
|
+
const archivedProfiles = core.listAiProfiles('workspace-1', { includeArchived: true });
|
|
1401
|
+
expect(archivedProfiles.map((profile) => profile.name)).toEqual(['Claude', 'Codex']);
|
|
1402
|
+
const archivedEntry = archivedProfiles.find((profile) => profile.id === first.profile.id);
|
|
1403
|
+
expect(archivedEntry?.archivedReason).toBe('manual_archive');
|
|
1404
|
+
expect(archivedEntry?.rosterStateUpdatedAt).toBe(archivedEntry?.archivedAt);
|
|
1405
|
+
});
|
|
1406
|
+
it('keeps an archived AI profile archived when sync replays an equal-watermark active copy', () => {
|
|
1407
|
+
const projectRoot = createProjectRoot();
|
|
1408
|
+
createdRoots.push(projectRoot);
|
|
1409
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1410
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1411
|
+
const resolved = core.resolveAiProfile({
|
|
1412
|
+
workspaceId: 'workspace-1',
|
|
1413
|
+
name: 'Codex T-714 Verifier',
|
|
1414
|
+
surfaceType: 'coding_tool'
|
|
1415
|
+
});
|
|
1416
|
+
const archived = core.archiveAiProfile({
|
|
1417
|
+
workspaceId: 'workspace-1',
|
|
1418
|
+
profileId: resolved.profile.id,
|
|
1419
|
+
archivedBy: 'owner-1',
|
|
1420
|
+
reason: 'manual_archive'
|
|
1421
|
+
});
|
|
1422
|
+
const replayedActive = core.upsertAiProfileForSync({
|
|
1423
|
+
...archived,
|
|
1424
|
+
archivedAt: null,
|
|
1425
|
+
archivedBy: null,
|
|
1426
|
+
archivedReason: null,
|
|
1427
|
+
mergedIntoProfileId: null,
|
|
1428
|
+
updatedAt: archived.updatedAt
|
|
1429
|
+
});
|
|
1430
|
+
expect(replayedActive.archivedAt).toBeTruthy();
|
|
1431
|
+
expect(core.listAiProfiles('workspace-1')).toEqual([]);
|
|
1432
|
+
const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
|
|
1433
|
+
.find((profile) => profile.id === archived.id);
|
|
1434
|
+
expect(stored?.archivedAt).toBe(archived.archivedAt);
|
|
1435
|
+
expect(stored?.archivedReason).toBe('manual_archive');
|
|
1436
|
+
});
|
|
1437
|
+
it('accepts newer synced seat scope for the same AI profile id', () => {
|
|
1438
|
+
const projectRoot = createProjectRoot();
|
|
1439
|
+
createdRoots.push(projectRoot);
|
|
1440
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1441
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1442
|
+
const contaminated = core.resolveAiProfile({
|
|
1443
|
+
workspaceId: 'workspace-1',
|
|
1444
|
+
name: 'Codex',
|
|
1445
|
+
surfaceType: 'coding_tool'
|
|
1446
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1447
|
+
const repairedAt = new Date(Date.parse(contaminated.profile.updatedAt) + 1000).toISOString();
|
|
1448
|
+
const repaired = core.upsertAiProfileForSync({
|
|
1449
|
+
...contaminated.profile,
|
|
1450
|
+
seatScope: 'local_unmetered',
|
|
1451
|
+
updatedAt: repairedAt,
|
|
1452
|
+
lastActiveAt: repairedAt
|
|
1453
|
+
});
|
|
1454
|
+
expect(repaired.id).toBe(contaminated.profile.id);
|
|
1455
|
+
expect(repaired.seatScope).toBe('local_unmetered');
|
|
1456
|
+
const staleReplay = core.upsertAiProfileForSync({
|
|
1457
|
+
...contaminated.profile,
|
|
1458
|
+
seatScope: 'cloud_metered',
|
|
1459
|
+
updatedAt: contaminated.profile.updatedAt
|
|
1460
|
+
});
|
|
1461
|
+
expect(staleReplay.seatScope).toBe('local_unmetered');
|
|
1462
|
+
});
|
|
1463
|
+
it('keeps an archived AI profile archived when sync replays a newer metadata update with an older lifecycle timestamp', () => {
|
|
1464
|
+
const projectRoot = createProjectRoot();
|
|
1465
|
+
createdRoots.push(projectRoot);
|
|
1466
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1467
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1468
|
+
const resolved = core.resolveAiProfile({
|
|
1469
|
+
workspaceId: 'workspace-1',
|
|
1470
|
+
name: 'Antigravity/jCodeMunch',
|
|
1471
|
+
surfaceType: 'coding_tool'
|
|
1472
|
+
});
|
|
1473
|
+
const archived = core.archiveAiProfile({
|
|
1474
|
+
workspaceId: 'workspace-1',
|
|
1475
|
+
profileId: resolved.profile.id,
|
|
1476
|
+
archivedBy: 'owner-1',
|
|
1477
|
+
reason: 'manual_archive'
|
|
1478
|
+
});
|
|
1479
|
+
const replayedActive = core.upsertAiProfileForSync({
|
|
1480
|
+
...archived,
|
|
1481
|
+
description: 'Cloud-side metadata changed after the archive write was missed.',
|
|
1482
|
+
archivedAt: null,
|
|
1483
|
+
archivedBy: null,
|
|
1484
|
+
archivedReason: null,
|
|
1485
|
+
mergedIntoProfileId: null,
|
|
1486
|
+
rosterStateUpdatedAt: resolved.profile.rosterStateUpdatedAt,
|
|
1487
|
+
updatedAt: '2099-03-22T10:30:00.000Z'
|
|
1488
|
+
});
|
|
1489
|
+
expect(replayedActive.archivedAt).toBeTruthy();
|
|
1490
|
+
const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
|
|
1491
|
+
.find((profile) => profile.id === archived.id);
|
|
1492
|
+
expect(stored?.archivedAt).toBe(archived.archivedAt);
|
|
1493
|
+
expect(stored?.description).toBe('Cloud-side metadata changed after the archive write was missed.');
|
|
1494
|
+
});
|
|
1495
|
+
it('repairs stale archived roster lifecycle timestamps before applying active sync snapshots', () => {
|
|
1496
|
+
const projectRoot = createProjectRoot();
|
|
1497
|
+
createdRoots.push(projectRoot);
|
|
1498
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1499
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1500
|
+
const resolved = core.resolveAiProfile({
|
|
1501
|
+
workspaceId: 'workspace-1',
|
|
1502
|
+
name: 'Codex',
|
|
1503
|
+
surfaceType: 'coding_tool'
|
|
1504
|
+
});
|
|
1505
|
+
const archived = core.archiveAiProfile({
|
|
1506
|
+
workspaceId: 'workspace-1',
|
|
1507
|
+
profileId: resolved.profile.id,
|
|
1508
|
+
archivedBy: 'owner-1',
|
|
1509
|
+
reason: 'manual_archive'
|
|
1510
|
+
});
|
|
1511
|
+
core.db.prepare(`
|
|
1512
|
+
UPDATE ai_profiles
|
|
1513
|
+
SET roster_state_updated_at = ?
|
|
1514
|
+
WHERE tenant_id = ? AND id = ?
|
|
1515
|
+
`).run(resolved.profile.createdAt, 'default', archived.id);
|
|
1516
|
+
const replayedActive = core.upsertAiProfileForSync({
|
|
1517
|
+
...archived,
|
|
1518
|
+
description: 'Cloud active snapshot with newer metadata but no lifecycle marker.',
|
|
1519
|
+
archivedAt: null,
|
|
1520
|
+
archivedBy: null,
|
|
1521
|
+
archivedReason: null,
|
|
1522
|
+
mergedIntoProfileId: null,
|
|
1523
|
+
rosterStateUpdatedAt: null,
|
|
1524
|
+
updatedAt: '2099-03-22T10:30:00.000Z'
|
|
1525
|
+
});
|
|
1526
|
+
expect(replayedActive.archivedAt).toBe(archived.archivedAt);
|
|
1527
|
+
expect(replayedActive.rosterStateUpdatedAt).toBe(archived.archivedAt);
|
|
1528
|
+
const stored = core.listAiProfiles('workspace-1', { includeArchived: true })
|
|
1529
|
+
.find((profile) => profile.id === archived.id);
|
|
1530
|
+
expect(stored?.archivedAt).toBe(archived.archivedAt);
|
|
1531
|
+
expect(stored?.description).toBe('Cloud active snapshot with newer metadata but no lifecycle marker.');
|
|
1532
|
+
expect(core.listAiProfiles('workspace-1')).toEqual([]);
|
|
1533
|
+
});
|
|
1534
|
+
it('allows sync to reactivate an archived AI profile when lifecycle timestamp is newer', () => {
|
|
1535
|
+
const projectRoot = createProjectRoot();
|
|
1536
|
+
createdRoots.push(projectRoot);
|
|
1537
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1538
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1539
|
+
const resolved = core.resolveAiProfile({
|
|
1540
|
+
workspaceId: 'workspace-1',
|
|
1541
|
+
name: 'Claude Code',
|
|
1542
|
+
surfaceType: 'coding_tool'
|
|
1543
|
+
});
|
|
1544
|
+
const archived = core.archiveAiProfile({
|
|
1545
|
+
workspaceId: 'workspace-1',
|
|
1546
|
+
profileId: resolved.profile.id,
|
|
1547
|
+
archivedBy: 'owner-1',
|
|
1548
|
+
reason: 'manual_archive'
|
|
1549
|
+
});
|
|
1550
|
+
const reactivated = core.upsertAiProfileForSync({
|
|
1551
|
+
...archived,
|
|
1552
|
+
archivedAt: null,
|
|
1553
|
+
archivedBy: null,
|
|
1554
|
+
archivedReason: null,
|
|
1555
|
+
mergedIntoProfileId: null,
|
|
1556
|
+
rosterStateUpdatedAt: '2099-03-22T10:45:00.000Z',
|
|
1557
|
+
updatedAt: '2099-03-22T10:45:00.000Z'
|
|
1558
|
+
});
|
|
1559
|
+
expect(reactivated.archivedAt).toBeNull();
|
|
1560
|
+
expect(core.listAiProfiles('workspace-1').map((profile) => profile.id)).toEqual([resolved.profile.id]);
|
|
1561
|
+
});
|
|
1562
|
+
it('reactivates an archived AI profile when resolveAiProfile is called explicitly', () => {
|
|
1563
|
+
const projectRoot = createProjectRoot();
|
|
1564
|
+
createdRoots.push(projectRoot);
|
|
1565
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1566
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1567
|
+
const initial = core.resolveAiProfile({
|
|
1568
|
+
workspaceId: 'workspace-1',
|
|
1569
|
+
name: 'Codex',
|
|
1570
|
+
surfaceType: 'coding_tool',
|
|
1571
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1572
|
+
core.archiveAiProfile({
|
|
1573
|
+
workspaceId: 'workspace-1',
|
|
1574
|
+
profileId: initial.profile.id,
|
|
1575
|
+
archivedBy: 'owner-1',
|
|
1576
|
+
reason: 'manual_archive'
|
|
1577
|
+
});
|
|
1578
|
+
const reactivatedByName = core.resolveAiProfile({
|
|
1579
|
+
workspaceId: 'workspace-1',
|
|
1580
|
+
name: 'Codex',
|
|
1581
|
+
surfaceType: 'coding_tool'
|
|
1582
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1583
|
+
expect(reactivatedByName.created).toBe(false);
|
|
1584
|
+
expect(reactivatedByName.profile.id).toBe(initial.profile.id);
|
|
1585
|
+
expect(reactivatedByName.profile.archivedAt).toBeNull();
|
|
1586
|
+
expect(reactivatedByName.profile.archivedReason).toBeNull();
|
|
1587
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1588
|
+
core.archiveAiProfile({
|
|
1589
|
+
workspaceId: 'workspace-1',
|
|
1590
|
+
profileId: initial.profile.id,
|
|
1591
|
+
archivedBy: 'owner-1',
|
|
1592
|
+
reason: 'manual_archive'
|
|
1593
|
+
});
|
|
1594
|
+
const reactivatedByToken = core.resolveAiProfile({
|
|
1595
|
+
workspaceId: 'workspace-1',
|
|
1596
|
+
name: 'Codex',
|
|
1597
|
+
profileToken: initial.profile.profileToken,
|
|
1598
|
+
surfaceType: 'coding_tool'
|
|
1599
|
+
}, { trustedSeatScope: 'cloud_metered' });
|
|
1600
|
+
expect(reactivatedByToken.created).toBe(false);
|
|
1601
|
+
expect(reactivatedByToken.profile.id).toBe(initial.profile.id);
|
|
1602
|
+
expect(reactivatedByToken.profile.profileToken).toBe(initial.profile.profileToken);
|
|
1603
|
+
expect(reactivatedByToken.profile.archivedAt).toBeNull();
|
|
1604
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1605
|
+
});
|
|
1606
|
+
it('rejects archiving an AI profile that still owns open tasks', () => {
|
|
1607
|
+
const projectRoot = createProjectRoot();
|
|
1608
|
+
createdRoots.push(projectRoot);
|
|
1609
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1610
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1611
|
+
const profile = core.resolveAiProfile({
|
|
1612
|
+
workspaceId: 'workspace-1',
|
|
1613
|
+
name: 'Antigravity',
|
|
1614
|
+
surfaceType: 'agent'
|
|
1615
|
+
});
|
|
1616
|
+
core.createTask({
|
|
1617
|
+
title: 'Still assigned',
|
|
1618
|
+
status: 'in-progress',
|
|
1619
|
+
assignee: profile.profile.id
|
|
1620
|
+
}, { workspaceId: 'workspace-1' });
|
|
1621
|
+
try {
|
|
1622
|
+
core.archiveAiProfile({
|
|
1623
|
+
workspaceId: 'workspace-1',
|
|
1624
|
+
profileId: profile.profile.id,
|
|
1625
|
+
archivedBy: 'owner-1',
|
|
1626
|
+
reason: 'manual_archive'
|
|
1627
|
+
});
|
|
1628
|
+
expect.unreachable('Expected archiveAiProfile to reject open-task ownership');
|
|
1629
|
+
}
|
|
1630
|
+
catch (error) {
|
|
1631
|
+
expect(error.code).toBe('AI_PROFILE_HAS_OPEN_TASKS');
|
|
1632
|
+
expect(error.statusCode).toBe(409);
|
|
1633
|
+
expect(error.details).toEqual({
|
|
1634
|
+
workspaceId: 'workspace-1',
|
|
1635
|
+
profileId: profile.profile.id,
|
|
1636
|
+
openTaskCount: 1
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
1640
|
+
expect(core.listAiProfiles('workspace-1', { includeArchived: true })[0]?.archivedAt).toBeNull();
|
|
1641
|
+
});
|
|
606
1642
|
it('canonicalizes AI names to profile ids when creating tasks and comments', () => {
|
|
607
1643
|
const projectRoot = createProjectRoot();
|
|
608
1644
|
createdRoots.push(projectRoot);
|
|
@@ -628,6 +1664,145 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
628
1664
|
expect(fetched?.assigneeActor?.label).toBe('ChatGPT');
|
|
629
1665
|
expect(fetched?.comments?.[0]?.actor?.label).toBe('ChatGPT');
|
|
630
1666
|
});
|
|
1667
|
+
it('keeps ambiguous legacy AI comment aliases unresolved instead of rebinding them', () => {
|
|
1668
|
+
const projectRoot = createProjectRoot();
|
|
1669
|
+
createdRoots.push(projectRoot);
|
|
1670
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1671
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1672
|
+
const cloud = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
|
|
1673
|
+
const local = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
|
|
1674
|
+
const task = core.createTask({
|
|
1675
|
+
title: 'Ambiguous legacy comment alias',
|
|
1676
|
+
comments: [{
|
|
1677
|
+
id: 'legacy-codex',
|
|
1678
|
+
author: 'codex',
|
|
1679
|
+
text: 'Leave me unresolved',
|
|
1680
|
+
timestamp: '2026-03-22T10:00:00.000Z'
|
|
1681
|
+
}]
|
|
1682
|
+
}, { workspaceId: 'workspace-1' });
|
|
1683
|
+
const fetched = core.getTask(task.id, 'workspace-1');
|
|
1684
|
+
expect(fetched?.comments?.[0]?.author).toBe('codex');
|
|
1685
|
+
expect(fetched?.comments?.[0]?.actor).toEqual(expect.objectContaining({
|
|
1686
|
+
id: 'codex',
|
|
1687
|
+
kind: 'ai',
|
|
1688
|
+
label: 'Codex',
|
|
1689
|
+
meta: expect.objectContaining({ unresolved: true })
|
|
1690
|
+
}));
|
|
1691
|
+
expect(fetched?.comments?.[0]?.actor?.id).not.toBe(cloud.profile.id);
|
|
1692
|
+
expect(fetched?.comments?.[0]?.actor?.id).not.toBe(local.profile.id);
|
|
1693
|
+
});
|
|
1694
|
+
it('repairs legacy comments from submittedAuthor under the v2 migration even after v1 completed', () => {
|
|
1695
|
+
const projectRoot = createProjectRoot();
|
|
1696
|
+
createdRoots.push(projectRoot);
|
|
1697
|
+
const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1698
|
+
firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1699
|
+
const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
|
|
1700
|
+
const task = firstCore.createTask({
|
|
1701
|
+
title: 'Repair legacy submitted author',
|
|
1702
|
+
comments: [{
|
|
1703
|
+
id: 'legacy-agent',
|
|
1704
|
+
author: 'agent',
|
|
1705
|
+
submittedAuthor: 'Claude',
|
|
1706
|
+
text: 'Legacy comment should repair forward',
|
|
1707
|
+
timestamp: '2026-03-22T10:00:00.000Z'
|
|
1708
|
+
}]
|
|
1709
|
+
}, { workspaceId: 'workspace-1' });
|
|
1710
|
+
const firstDb = firstCore.db;
|
|
1711
|
+
const originalTaskWatermark = '2026-03-22T10:00:00.000Z';
|
|
1712
|
+
firstDb.prepare(`
|
|
1713
|
+
UPDATE tasks
|
|
1714
|
+
SET updated_at = ?
|
|
1715
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
1716
|
+
`).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
|
|
1717
|
+
const completedAt = '2026-03-22T11:00:00.000Z';
|
|
1718
|
+
firstDb.prepare(`
|
|
1719
|
+
INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
|
|
1720
|
+
VALUES (?, ?, ?, ?)
|
|
1721
|
+
ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
|
|
1722
|
+
payload = excluded.payload,
|
|
1723
|
+
updated_at = excluded.updated_at
|
|
1724
|
+
`).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
|
|
1725
|
+
firstDb.prepare(`
|
|
1726
|
+
DELETE FROM system_settings
|
|
1727
|
+
WHERE tenant_id = ? AND settings_key = ?
|
|
1728
|
+
`).run('default', 'migration.actor_refs.canonicalize_comments.v2');
|
|
1729
|
+
firstCore.close();
|
|
1730
|
+
const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1731
|
+
const fetched = secondCore.getTask(task.id, 'workspace-1');
|
|
1732
|
+
expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
|
|
1733
|
+
expect(fetched?.comments?.[0]?.submittedAuthor).toBe('Claude');
|
|
1734
|
+
expect(fetched?.comments?.[0]?.actor?.id).toBe(resolved.profile.id);
|
|
1735
|
+
expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
|
|
1736
|
+
const syncRows = secondCore.listTasksForSync('workspace-1', {
|
|
1737
|
+
watermark: originalTaskWatermark,
|
|
1738
|
+
id: task.id,
|
|
1739
|
+
}, 10);
|
|
1740
|
+
expect(syncRows).toHaveLength(1);
|
|
1741
|
+
expect(syncRows[0]?.task?.id).toBe(task.id);
|
|
1742
|
+
expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
|
|
1743
|
+
const secondDb = secondCore.db;
|
|
1744
|
+
const migrationRow = secondDb.prepare(`
|
|
1745
|
+
SELECT payload
|
|
1746
|
+
FROM system_settings
|
|
1747
|
+
WHERE tenant_id = ? AND settings_key = ?
|
|
1748
|
+
LIMIT 1
|
|
1749
|
+
`).get('default', 'migration.actor_refs.canonicalize_comments.v2');
|
|
1750
|
+
expect(JSON.parse(String(migrationRow?.payload || '{}'))).toEqual(expect.objectContaining({
|
|
1751
|
+
repairedCount: 1,
|
|
1752
|
+
ambiguousCount: 0,
|
|
1753
|
+
skippedCount: expect.any(Number)
|
|
1754
|
+
}));
|
|
1755
|
+
secondCore.close();
|
|
1756
|
+
});
|
|
1757
|
+
it('keeps repaired legacy comment sync watermarks monotonic when a task watermark is already ahead of local now', () => {
|
|
1758
|
+
const projectRoot = createProjectRoot();
|
|
1759
|
+
createdRoots.push(projectRoot);
|
|
1760
|
+
const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1761
|
+
firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1762
|
+
const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
|
|
1763
|
+
const task = firstCore.createTask({
|
|
1764
|
+
title: 'Repair legacy submitted author with future watermark',
|
|
1765
|
+
comments: [{
|
|
1766
|
+
id: 'legacy-agent-future',
|
|
1767
|
+
author: 'agent',
|
|
1768
|
+
submittedAuthor: 'Claude',
|
|
1769
|
+
text: 'Legacy comment should repair without moving sync backwards',
|
|
1770
|
+
timestamp: '2026-03-22T10:00:00.000Z'
|
|
1771
|
+
}]
|
|
1772
|
+
}, { workspaceId: 'workspace-1' });
|
|
1773
|
+
const firstDb = firstCore.db;
|
|
1774
|
+
const originalTaskWatermark = '2099-03-22T10:00:00.000Z';
|
|
1775
|
+
firstDb.prepare(`
|
|
1776
|
+
UPDATE tasks
|
|
1777
|
+
SET updated_at = ?
|
|
1778
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
1779
|
+
`).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
|
|
1780
|
+
const completedAt = '2026-03-22T11:00:00.000Z';
|
|
1781
|
+
firstDb.prepare(`
|
|
1782
|
+
INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
|
|
1783
|
+
VALUES (?, ?, ?, ?)
|
|
1784
|
+
ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
|
|
1785
|
+
payload = excluded.payload,
|
|
1786
|
+
updated_at = excluded.updated_at
|
|
1787
|
+
`).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
|
|
1788
|
+
firstDb.prepare(`
|
|
1789
|
+
DELETE FROM system_settings
|
|
1790
|
+
WHERE tenant_id = ? AND settings_key = ?
|
|
1791
|
+
`).run('default', 'migration.actor_refs.canonicalize_comments.v2');
|
|
1792
|
+
firstCore.close();
|
|
1793
|
+
const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1794
|
+
const fetched = secondCore.getTask(task.id, 'workspace-1');
|
|
1795
|
+
expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
|
|
1796
|
+
expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
|
|
1797
|
+
const syncRows = secondCore.listTasksForSync('workspace-1', {
|
|
1798
|
+
watermark: originalTaskWatermark,
|
|
1799
|
+
id: task.id,
|
|
1800
|
+
}, 10);
|
|
1801
|
+
expect(syncRows).toHaveLength(1);
|
|
1802
|
+
expect(syncRows[0]?.task?.id).toBe(task.id);
|
|
1803
|
+
expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
|
|
1804
|
+
secondCore.close();
|
|
1805
|
+
});
|
|
631
1806
|
it('repairs legacy actor refs and merges duplicate ai profiles on startup', () => {
|
|
632
1807
|
const projectRoot = createProjectRoot();
|
|
633
1808
|
createdRoots.push(projectRoot);
|
|
@@ -658,11 +1833,41 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
658
1833
|
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
659
1834
|
const reloaded = reloadedCore.getTask(task.id, 'workspace-1');
|
|
660
1835
|
const profiles = reloadedCore.listAiProfiles('workspace-1');
|
|
661
|
-
expect(profiles.map((profile) => profile.name)).toEqual(['
|
|
1836
|
+
expect(profiles.map((profile) => profile.name)).toEqual(['Antigravity', 'Claude']);
|
|
662
1837
|
const survivingAntigravityId = profiles.find((profile) => profile.name === 'Antigravity')?.id;
|
|
663
1838
|
expect(reloaded?.createdBy).toBe(claude.profile.id);
|
|
664
1839
|
expect(reloaded?.assignee).toBe(survivingAntigravityId);
|
|
665
1840
|
expect(reloaded?.comments?.[0]?.author).toBe(claude.profile.id);
|
|
666
1841
|
expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
|
|
667
1842
|
});
|
|
1843
|
+
it('does not repair ambiguous legacy comment authors on startup', () => {
|
|
1844
|
+
const projectRoot = createProjectRoot();
|
|
1845
|
+
createdRoots.push(projectRoot);
|
|
1846
|
+
const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1847
|
+
firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
1848
|
+
const firstDb = firstCore.db;
|
|
1849
|
+
const first = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
|
|
1850
|
+
const second = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
|
|
1851
|
+
const task = firstCore.createTask({
|
|
1852
|
+
title: 'Ambiguous legacy actor refs',
|
|
1853
|
+
comments: [{
|
|
1854
|
+
id: 'legacy-comment',
|
|
1855
|
+
author: first.profile.id,
|
|
1856
|
+
text: 'Leave ambiguous alias untouched.',
|
|
1857
|
+
timestamp: '2026-03-22T10:00:00.000Z'
|
|
1858
|
+
}]
|
|
1859
|
+
}, { workspaceId: 'workspace-1' });
|
|
1860
|
+
firstDb.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
|
|
1861
|
+
.run('default', second.profile.id);
|
|
1862
|
+
firstDb.prepare(`UPDATE comments SET author = 'claude', submitted_author = 'claude' WHERE tenant_id = ? AND task_id = ? AND id = ?`)
|
|
1863
|
+
.run('default', task.id, 'legacy-comment');
|
|
1864
|
+
firstDb.prepare(`DELETE FROM system_settings WHERE tenant_id = ? AND settings_key = ?`)
|
|
1865
|
+
.run('default', 'migration.actor_refs.canonicalize.v1');
|
|
1866
|
+
const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
1867
|
+
const reloaded = reloadedCore.getTask(task.id, 'workspace-1');
|
|
1868
|
+
expect(reloaded?.comments?.[0]?.author).toBe('claude');
|
|
1869
|
+
expect(reloaded?.comments?.[0]?.submittedAuthor).toBe('claude');
|
|
1870
|
+
expect(reloaded?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
|
|
1871
|
+
expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
|
|
1872
|
+
});
|
|
668
1873
|
});
|