@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.
Files changed (440) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +603 -107
  3. package/dist/TaskforceCore.js +216 -43
  4. package/dist/TaskforceCore.test.js +611 -28
  5. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  8. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  10. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  11. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  12. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  13. package/dist/compat/workspaceSyncCompat.js +46 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  16. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  17. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  18. package/dist/components/context/ContextAttachmentManager.js +561 -0
  19. package/dist/components/features/AgentsModule.d.ts +14 -1
  20. package/dist/components/features/AgentsModule.js +366 -18
  21. package/dist/components/features/AgentsModule.test.js +507 -8
  22. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  23. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  24. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  25. package/dist/components/features/DocumentIndex.d.ts +2 -2
  26. package/dist/components/features/DocumentIndex.js +3 -4
  27. package/dist/components/features/DocumentViewer.d.ts +7 -1
  28. package/dist/components/features/DocumentViewer.js +62 -4
  29. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  30. package/dist/components/features/DocumentWorkspace.js +15 -4
  31. package/dist/components/features/DocumentWorkspace.test.js +51 -1
  32. package/dist/components/features/TaskSettings.d.ts +2 -0
  33. package/dist/components/features/TaskSettings.js +83 -78
  34. package/dist/components/features/TaskSettings.test.js +171 -82
  35. package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
  36. package/dist/components/task/TaskActionHeader.js +11 -5
  37. package/dist/components/task/TaskActionHeader.test.js +10 -0
  38. package/dist/components/task/TaskCard.js +24 -29
  39. package/dist/components/task/TaskCard.test.js +38 -3
  40. package/dist/components/task/TaskContextUpload.js +4 -526
  41. package/dist/components/task/TaskContextUpload.test.js +3 -3
  42. package/dist/components/task/TaskForm.js +61 -116
  43. package/dist/components/task/TaskForm.test.js +110 -8
  44. package/dist/components/task/TaskKanban.d.ts +2 -1
  45. package/dist/components/task/TaskKanban.js +94 -21
  46. package/dist/components/task/TaskKanban.test.js +36 -0
  47. package/dist/components/task/TaskList.js +5 -1
  48. package/dist/components/task/TaskList.test.js +4 -0
  49. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  50. package/dist/components/ui/EditableAvatarButton.js +18 -0
  51. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  52. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  53. package/dist/components/views/AppShellHeader.d.ts +3 -1
  54. package/dist/components/views/AppShellHeader.js +27 -11
  55. package/dist/components/views/AppShellHeader.test.js +18 -1
  56. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  57. package/dist/components/views/PlanComparisonPage.js +78 -14
  58. package/dist/components/views/PlanComparisonPage.test.js +257 -0
  59. package/dist/components/views/PlansPage.js +127 -36
  60. package/dist/components/views/PlansPage.test.js +294 -12
  61. package/dist/components/views/StandaloneLayout.js +529 -592
  62. package/dist/components/views/WidgetView.js +10 -8
  63. package/dist/components/views/panels/PlanningDrawer.d.ts +25 -5
  64. package/dist/components/views/panels/PlanningDrawer.js +270 -55
  65. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  66. package/dist/components/views/panels/PlanningDrawer.test.js +529 -0
  67. package/dist/components/views/planningScope.d.ts +20 -0
  68. package/dist/components/views/planningScope.js +25 -0
  69. package/dist/components/views/planningScope.test.d.ts +1 -0
  70. package/dist/components/views/planningScope.test.js +60 -0
  71. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
  72. package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
  73. package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
  74. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  75. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  76. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  77. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  78. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  79. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  80. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  81. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  82. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  83. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  84. package/dist/config/envSchema.js +1 -0
  85. package/dist/core/AiProfileService.d.ts +46 -2
  86. package/dist/core/AiProfileService.js +1073 -82
  87. package/dist/core/AiProfileService.test.js +19 -1
  88. package/dist/core/AiProfiles.test.js +1249 -44
  89. package/dist/core/AttachmentLinkService.d.ts +3 -0
  90. package/dist/core/AttachmentLinkService.js +110 -23
  91. package/dist/core/AuthIdentityService.d.ts +107 -0
  92. package/dist/core/AuthIdentityService.js +284 -0
  93. package/dist/core/AuthTokenService.d.ts +4 -0
  94. package/dist/core/AuthTokenService.js +34 -6
  95. package/dist/core/AuthTokenService.test.js +3 -3
  96. package/dist/core/EntitlementsPolicy.test.js +2 -2
  97. package/dist/core/GlobalSettingsService.js +127 -6
  98. package/dist/core/McpTokenService.d.ts +12 -0
  99. package/dist/core/McpTokenService.js +44 -1
  100. package/dist/core/PlanEntitlementService.d.ts +23 -2
  101. package/dist/core/PlanEntitlementService.js +343 -57
  102. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  103. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  104. package/dist/core/PlanVersionPolicy.test.js +35 -2
  105. package/dist/core/PlanningEntities.test.js +418 -4
  106. package/dist/core/SignupMonetizationSettings.test.js +199 -27
  107. package/dist/core/SyncReconciliationService.js +2 -65
  108. package/dist/core/SystemAdmin.test.js +42 -42
  109. package/dist/core/TaskActivityService.d.ts +28 -1
  110. package/dist/core/TaskActivityService.js +227 -46
  111. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  112. package/dist/core/TaskActivitySync.test.js +90 -0
  113. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  114. package/dist/core/TaskAuditTimeline.test.js +96 -0
  115. package/dist/core/Taskforce.d.ts +415 -40
  116. package/dist/core/Taskforce.ids.test.js +58 -0
  117. package/dist/core/Taskforce.js +1543 -266
  118. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  119. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  120. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  121. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  122. package/dist/core/WorkspaceLifecycleService.js +0 -5
  123. package/dist/core/WorkspacePermissions.test.js +25 -1
  124. package/dist/core/WorkspacePlanMode.test.js +117 -16
  125. package/dist/core/shared.d.ts +1 -1
  126. package/dist/core/shared.js +5 -1
  127. package/dist/core/types.d.ts +75 -2
  128. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +28 -2
  129. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  130. package/dist/hooks/bootstrapTimeouts.js +8 -0
  131. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  132. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  133. package/dist/hooks/sync/bootstrap.js +2 -0
  134. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  135. package/dist/hooks/sync/controlPlane.js +78 -0
  136. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  137. package/dist/hooks/sync/controlPlane.test.js +121 -0
  138. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  139. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  140. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  141. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  142. package/dist/hooks/sync/orchestratorShared.d.ts +13 -4
  143. package/dist/hooks/sync/orchestratorShared.js +129 -48
  144. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  145. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  146. package/dist/hooks/sync/pullLifecycle.js +24 -0
  147. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  148. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  149. package/dist/hooks/sync/recovery.d.ts +16 -2
  150. package/dist/hooks/sync/recovery.js +179 -53
  151. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  152. package/dist/hooks/sync/recovery.test.js +292 -0
  153. package/dist/hooks/sync/transfers.d.ts +16 -3
  154. package/dist/hooks/sync/transfers.js +210 -66
  155. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  156. package/dist/hooks/sync/transfers.test.js +928 -0
  157. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  158. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  159. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  160. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  161. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  162. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  163. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  164. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  165. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  166. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  167. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  168. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  169. package/dist/hooks/useFilterSort.js +13 -13
  170. package/dist/hooks/useFilterSort.test.js +87 -1
  171. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  172. package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
  173. package/dist/hooks/useSyncOrchestrator.js +409 -183
  174. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
  175. package/dist/hooks/useTaskData.js +8 -3
  176. package/dist/hooks/useTaskData.test.js +45 -0
  177. package/dist/hooks/useTaskforce.d.ts +145 -52
  178. package/dist/hooks/useTaskforce.js +592 -253
  179. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  180. package/dist/hooks/useTaskforce.runtime-routing.test.js +246 -0
  181. package/dist/hooks/useTaskforce.sync-behavior.test.js +2468 -201
  182. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  183. package/dist/hooks/useWorkspaceSyncController.js +13 -2
  184. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  185. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  186. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +328 -40
  187. package/dist/localization/locales/en-US.d.ts +0 -1
  188. package/dist/localization/locales/en-US.js +0 -1
  189. package/dist/localization/locales/es-419.js +0 -1
  190. package/dist/localization/locales/pt-BR.js +0 -1
  191. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  192. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  193. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  194. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  195. package/dist/mcp/clientIntegrations.d.ts +1 -0
  196. package/dist/mcp/clientIntegrations.js +25 -0
  197. package/dist/mcp/clientIntegrations.test.js +14 -3
  198. package/dist/mcp/clientProfileDefaults.js +3 -3
  199. package/dist/mcp/collaborationRegistrar.js +7 -7
  200. package/dist/mcp/documentAssetRegistrar.js +116 -10
  201. package/dist/mcp/runtime.d.ts +3 -0
  202. package/dist/mcp/runtime.js +1099 -185
  203. package/dist/mcp/runtime.test.js +1491 -140
  204. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  205. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  206. package/dist/mcp/toolRegistry.d.ts +56 -0
  207. package/dist/mcp/toolRegistry.js +8 -0
  208. package/dist/mcp/toolRegistry.test.js +4 -0
  209. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  210. package/dist/migrations/taskSchemaMigrations.js +192 -61
  211. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  212. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  213. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  214. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  215. package/dist/resources/templates/workflows/plan.yaml +1 -1
  216. package/dist/resources/templates/workflows/review.yaml +2 -2
  217. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  218. package/dist/server/aiProfileAvatarAssets.js +319 -0
  219. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  220. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  221. package/dist/server/annotatedAttachmentsRoutes.test.js +69 -10
  222. package/dist/server/auth/providers/apple.d.ts +33 -0
  223. package/dist/server/auth/providers/apple.js +82 -0
  224. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  225. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  226. package/dist/server/auth/providers/github.d.ts +26 -0
  227. package/dist/server/auth/providers/github.js +86 -0
  228. package/dist/server/auth/providers/google.d.ts +21 -0
  229. package/dist/server/auth/providers/google.js +56 -0
  230. package/dist/server/auth/providers/types.d.ts +21 -0
  231. package/dist/server/auth/providers/types.js +1 -0
  232. package/dist/server/auth.d.ts +2 -0
  233. package/dist/server/auth.js +6 -3
  234. package/dist/server/documentReviewRoutes.test.js +36 -3
  235. package/dist/server/index.cookieProxy.test.js +1 -0
  236. package/dist/server/index.d.ts +12 -0
  237. package/dist/server/index.js +124 -9
  238. package/dist/server/index.rateLimit.test.js +3 -0
  239. package/dist/server/index.test.js +111 -3
  240. package/dist/server/routes/admin.d.ts +3 -0
  241. package/dist/server/routes/admin.js +459 -28
  242. package/dist/server/routes/annotatedAttachments.js +51 -0
  243. package/dist/server/routes/auth.d.ts +26 -2
  244. package/dist/server/routes/auth.js +1170 -71
  245. package/dist/server/routes/authSupport.d.ts +5 -1
  246. package/dist/server/routes/authSupport.js +13 -2
  247. package/dist/server/routes/billing.js +372 -35
  248. package/dist/server/routes/billing.test.js +595 -12
  249. package/dist/server/routes/documents.d.ts +1 -0
  250. package/dist/server/routes/documents.js +182 -70
  251. package/dist/server/routes/shared.d.ts +28 -2
  252. package/dist/server/routes/shared.js +69 -14
  253. package/dist/server/routes/sync.integration.test.js +658 -39
  254. package/dist/server/routes/sync.js +74 -608
  255. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  256. package/dist/server/routes/syncAuxRoutes.js +91 -0
  257. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  258. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  259. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  260. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  261. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  262. package/dist/server/routes/syncPushRoutes.js +212 -0
  263. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  264. package/dist/server/routes/syncRouteGuards.js +67 -0
  265. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  266. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  267. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  268. package/dist/server/routes/syncRouteTypes.js +1 -0
  269. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  270. package/dist/server/routes/syncSnapshotRoutes.js +182 -0
  271. package/dist/server/routes/tasks.js +55 -10
  272. package/dist/server/routes/workspaces.js +36 -5
  273. package/dist/server/routes.js +147 -11
  274. package/dist/server/routes.test.js +2704 -183
  275. package/dist/services/workflowExportSnapshots.test.js +2 -0
  276. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  277. package/dist/shared/aiProfileSeatScope.js +21 -0
  278. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  279. package/dist/shared/aiProfileSurfaceType.js +10 -8
  280. package/dist/shared/passwordPolicy.d.ts +13 -0
  281. package/dist/shared/passwordPolicy.js +84 -0
  282. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  283. package/dist/shared/passwordPolicy.test.js +34 -0
  284. package/dist/storage/objectStorage.d.ts +1 -0
  285. package/dist/storage/objectStorage.js +2 -1
  286. package/dist/storage/objectStorageClient.d.ts +1 -0
  287. package/dist/storage/objectStorageClient.js +41 -8
  288. package/dist/storage/objectStorageClient.test.js +26 -1
  289. package/dist/storage/objectStorageConfig.js +3 -1
  290. package/dist/storage/objectStorageConfig.test.js +13 -1
  291. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  292. package/dist/storage/workspaceAssetStore.js +132 -44
  293. package/dist/storage/workspaceAssetStore.test.js +93 -6
  294. package/dist/styles/fonts.css +70 -0
  295. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  296. package/dist/sync/collaborationSyncPayload.js +19 -11
  297. package/dist/sync/collaborationSyncPayload.test.js +14 -1
  298. package/dist/sync/collaborationSyncState.d.ts +3 -3
  299. package/dist/sync/collaborationSyncState.js +19 -12
  300. package/dist/sync/contentSyncPayload.d.ts +5 -1
  301. package/dist/sync/contentSyncPayload.js +9 -0
  302. package/dist/sync/contentSyncPayload.test.js +39 -0
  303. package/dist/sync/contentSyncState.d.ts +9 -9
  304. package/dist/sync/contentSyncState.js +248 -107
  305. package/dist/sync/planningSyncPayload.js +5 -0
  306. package/dist/sync/planningSyncPayload.test.js +24 -0
  307. package/dist/sync/syncApplyHandlers.d.ts +11 -4
  308. package/dist/sync/syncApplyHandlers.js +115 -42
  309. package/dist/sync/syncApplyHandlers.test.js +251 -4
  310. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  311. package/dist/sync/syncFieldSemantics.js +55 -0
  312. package/dist/sync/syncResourceAdapters.d.ts +46 -0
  313. package/dist/sync/syncResourceAdapters.js +79 -0
  314. package/dist/sync/syncService.d.ts +6 -0
  315. package/dist/sync/syncService.js +65 -1
  316. package/dist/sync/syncService.test.js +96 -3
  317. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  318. package/dist/sync/taskSyncChangeKey.js +143 -0
  319. package/dist/sync/taskSyncPayload.js +3 -8
  320. package/dist/sync/workspacePullFeed.d.ts +4 -0
  321. package/dist/sync/workspacePullFeed.js +132 -72
  322. package/dist/sync/workspacePullFeed.test.js +92 -0
  323. package/dist/sync/workspaceRepair.js +5 -2
  324. package/dist/sync/workspaceSyncModel.d.ts +73 -6
  325. package/dist/sync/workspaceSyncModel.js +342 -73
  326. package/dist/sync/workspaceSyncModel.test.js +559 -14
  327. package/dist/sync/workspaceSyncState.d.ts +17 -0
  328. package/dist/sync/workspaceSyncState.js +2 -1
  329. package/dist/sync/workspaceSyncSurface.js +3 -8
  330. package/dist/types.d.ts +50 -2
  331. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  332. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  333. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  334. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  335. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  336. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  337. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  338. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  339. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  340. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  341. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  342. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  343. package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-D4XseTwW.js} +1 -1
  344. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  345. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  346. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  347. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  348. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  349. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  350. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  351. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  352. package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  353. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  354. package/dist/ui/assets/index-607WPo_X.js +4 -0
  355. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  356. package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-B32hGuF2.js} +1 -1
  357. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  358. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  359. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  360. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  361. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  362. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  363. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  364. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  365. package/dist/ui/fonts/Noir_medium.otf +0 -0
  366. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  367. package/dist/ui/fonts/Noir_regular.otf +0 -0
  368. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  369. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  370. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  371. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  372. package/dist/ui/index.html +3 -3
  373. package/dist/ui/product/agent manager.png +0 -0
  374. package/dist/ui/product/agent_manager_02.png +0 -0
  375. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  376. package/dist/utils/aiProfileEvents.d.ts +7 -0
  377. package/dist/utils/aiProfileEvents.js +6 -0
  378. package/dist/utils/assignees.d.ts +8 -0
  379. package/dist/utils/assignees.js +10 -1
  380. package/dist/utils/assignees.test.d.ts +1 -0
  381. package/dist/utils/assignees.test.js +25 -0
  382. package/dist/utils/avatarDisplay.d.ts +1 -0
  383. package/dist/utils/avatarDisplay.js +15 -0
  384. package/dist/utils/avatarUpload.d.ts +27 -0
  385. package/dist/utils/avatarUpload.js +132 -0
  386. package/dist/utils/avatarUpload.test.d.ts +1 -0
  387. package/dist/utils/avatarUpload.test.js +40 -0
  388. package/dist/utils/bootstrapTrace.d.ts +7 -0
  389. package/dist/utils/bootstrapTrace.js +29 -0
  390. package/dist/utils/commercialLifecyclePresentation.js +3 -0
  391. package/dist/utils/contextAssetEvents.d.ts +2 -0
  392. package/dist/utils/contextFiles.d.ts +3 -1
  393. package/dist/utils/contextFiles.js +2 -1
  394. package/dist/utils/syncEventDetails.d.ts +3 -0
  395. package/dist/utils/syncEventDetails.js +64 -0
  396. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  397. package/dist/utils/syncEventDetails.test.js +23 -0
  398. package/dist/utils/syncEventPresentation.d.ts +15 -0
  399. package/dist/utils/syncEventPresentation.js +129 -0
  400. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  401. package/dist/utils/syncEventPresentation.test.js +64 -0
  402. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  403. package/dist/utils/syncStatusPresentation.js +217 -0
  404. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  405. package/dist/utils/syncStatusPresentation.test.js +164 -0
  406. package/dist/utils/taskActivity.d.ts +9 -1
  407. package/dist/utils/taskActivity.js +290 -22
  408. package/dist/utils/taskActivity.test.js +141 -8
  409. package/dist/utils/taskSearch.d.ts +22 -0
  410. package/dist/utils/taskSearch.js +87 -0
  411. package/dist/utils/taskSearch.test.d.ts +1 -0
  412. package/dist/utils/taskSearch.test.js +69 -0
  413. package/dist/utils/taskUtils.js +10 -3
  414. package/dist/utils/taskUtils.test.d.ts +1 -0
  415. package/dist/utils/taskUtils.test.js +19 -0
  416. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  417. package/dist/utils/workspaceSyncPresentation.js +68 -2
  418. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  419. package/package.json +3 -2
  420. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  421. package/scripts/run-e2e-realtime.sh +17 -3
  422. package/scripts/run-smoke.sh +17 -5
  423. package/scripts/run-soak.sh +17 -5
  424. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  425. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  426. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  427. package/src/resources/templates/workflows/plan.yaml +1 -1
  428. package/src/resources/templates/workflows/review.yaml +2 -2
  429. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
  430. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BG6P_GVW.js +0 -3
  431. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  432. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
  433. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
  434. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  435. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
  436. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  437. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +0 -9
  438. package/dist/ui/assets/index-C2-OXZV7.css +0 -1
  439. package/dist/ui/assets/index-DcSI5F86.js +0 -6
  440. package/scripts/migrate-planning-model.ts +0 -233
@@ -7,15 +7,18 @@ import { TemplateEngine } from '../services/templateEngine.js';
7
7
  import { createDatabaseAdapter } from '../storage/createDatabaseAdapter.js';
8
8
  import { WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
9
9
  import { AuthTokenService } from './AuthTokenService.js';
10
- import { getAiProfileByTokenService, listAiProfilesService, mergeAiProfilesService, resolveAiProfileService, upsertAiProfileForSyncService, } from './AiProfileService.js';
11
- import { syncCanonicalTaskAttachmentLinksService, } from './AttachmentLinkService.js';
12
- import { addTaskEventService, buildTaskActivityService, listTaskEventsInternalService, listWorkspaceTaskEventsService, replaceTaskCommentsService, replaceTaskEventsService, upsertTaskEventForSyncService, } from './TaskActivityService.js';
10
+ import { AuthIdentityService } from './AuthIdentityService.js';
11
+ import { archiveAiProfileService, getAiProfileByTokenService, listAiProfilesService, mergeAiProfilesService, resolveAiProfileService, updateAiProfileAvatarService, updateAiProfileSurfaceTypeService, upsertAiProfileForSyncService, } from './AiProfileService.js';
12
+ import { syncCanonicalAttachmentLinksForTargetService, syncCanonicalTaskAttachmentLinksService, } from './AttachmentLinkService.js';
13
+ import { addEntityEventService, addTaskEventService, buildEntityActivityService, buildTaskActivityService, getEntityEventForSyncService, getTaskEventForSyncService, listEntityEventsInternalService, listWorkspaceEntityEventsForSyncService, listTaskEventsInternalService, listWorkspaceTaskEventsForSyncService, replaceEntityEventsService, replaceTaskCommentsService, replaceTaskEventsService, upsertEntityEventForSyncService, upsertTaskEventForSyncService, } from './TaskActivityService.js';
13
14
  import { addDeletedTaskRecordService, getDeletedTaskService, listDeletedTasksService, mapDeletedTaskRowService, normalizeTaskSnapshotForRestoreService, pruneDeletedTasksService, purgeExpiredDeletedTasksService, } from './DeletedTaskLifecycleService.js';
14
15
  import { applyWorkspaceSyncSnapshotService, deleteDocumentWatermarkService, getDocumentWatermarkService, listTasksForSyncService, listWorkspaceSyncDeletesSinceService, readPushIdempotencyService, recordSyncEventService, upsertDocumentWatermarkService, writePushIdempotencyService, } from './SyncReconciliationService.js';
15
16
  import { getGlobalSettingsService, hasPersistedGlobalSettingsService, readGlobalConfigService, updateGlobalSettingsService, writeGlobalConfigService, } from './GlobalSettingsService.js';
16
17
  import { getWorkspaceTaxonomyStateService, upsertWorkspaceTaxonomyStateService, } from './TaxonomySettingsService.js';
17
18
  import { normalizeWorkspaceTaxonomyStatePayload, } from '../shared/taxonomyState.js';
18
19
  import { parseAiProfileSurfaceType } from '../shared/aiProfileSurfaceType.js';
20
+ import { parseAiProfileSeatScope } from '../shared/aiProfileSeatScope.js';
21
+ import { validatePasswordPolicy } from '../shared/passwordPolicy.js';
19
22
  import { normalizeTaskforceTheme } from '../utils/theme.js';
20
23
  import { McpTokenService } from './McpTokenService.js';
21
24
  import { PlanEntitlementService } from './PlanEntitlementService.js';
@@ -38,7 +41,7 @@ import { getDocumentReferenceLabel } from '../utils/documentReferences.js';
38
41
  import { getImageReferenceLabel } from '../utils/imageReferences.js';
39
42
  import { getCanonicalDocumentName } from '../utils/documentNames.js';
40
43
  import { DEFAULT_TASKFORCE_THEME } from '../utils/theme.js';
41
- import { ensureAssigneeTaskSchema, ensureAnnotatedAttachmentSessionsSchema, ensureBillingSchema, ensureDocumentReviewSessionsSchema, ensureDocumentReferenceSchema, ensureEntitlementsSchema, ensureParentTaskSchema, ensureScheduleTaskSchema, ensureTaskAssetsSchema, ensureTaskReferenceSchema, ensureTenantSchema, ensureWorkspaceAssetsSchema, ensureWorkspaceSchema } from '../migrations/taskSchemaMigrations.js';
44
+ import { ensureAssigneeTaskSchema, ensureAnnotatedAttachmentSessionsSchema, ensureAuthIdentitySchema, ensureBillingSchema, ensureDocumentReviewSessionsSchema, ensureDocumentReferenceSchema, ensureEntitlementsSchema, ensureScheduleTaskSchema, ensureTaskAssetsSchema, ensureTaskReferenceSchema, ensureTenantSchema, ensureWorkspaceAssetsSchema, ensureWorkspaceSchema } from '../migrations/taskSchemaMigrations.js';
42
45
  export class TaskforceRuleError extends Error {
43
46
  statusCode;
44
47
  code;
@@ -54,52 +57,7 @@ export class TaskforceRuleError extends Error {
54
57
  const WORKSPACE_SYNC_SETTINGS_KEY_ALLOWLIST = new Set([]);
55
58
  const GENERIC_AI_ACTOR_COLOR = '#6B7280';
56
59
  const GENERIC_AI_ACTOR_ICON = 'Bot';
57
- const DOCUMENT_CLOUD_STORAGE_FEATURE_KEY = 'context.uploads';
58
- const LEGACY_REMOVED_PLAN_FEATURE_KEYS = Object.freeze([
59
- 'documents.workspace_access'
60
- ]);
61
- const WORKSPACES_FEATURE_KEY = 'workspace.switching';
62
- const DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY = 'storageLimitMb';
63
- const WORKSPACES_LIMIT_CONFIG_KEY = 'maxWorkspaces';
64
- const MCP_ACCESS_TOKEN_MAX_ACTIVE_PER_USER_WORKSPACE = 10;
65
- const MCP_ACCESS_TOKEN_DEFAULT_PAGE_SIZE = 20;
66
- const MCP_ACCESS_TOKEN_MAX_PAGE_SIZE = 100;
67
- const MCP_OAUTH_AUTH_CODE_TTL_MS = 10 * 60 * 1000;
68
- const MCP_ACCESS_TOKEN_ALLOWED_SCOPES = ['tasks:read', 'tasks:write', 'workspace:read'];
69
60
  const DELETED_TASK_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
70
- const DEFAULT_PLAN_FEATURE_CATALOG = [
71
- {
72
- featureKey: 'collaboration.team_management',
73
- label: 'Team Management',
74
- description: 'Allow managing members, invites, roles, and collaboration settings.',
75
- allowedAccessModes: ['enabled', 'disabled'],
76
- configTemplate: {}
77
- },
78
- {
79
- featureKey: DOCUMENT_CLOUD_STORAGE_FEATURE_KEY,
80
- label: 'Document Cloud Storage',
81
- description: 'Allow cloud document storage with an optional storage allowance in MB.',
82
- allowedAccessModes: ['enabled', 'limited', 'disabled'],
83
- configTemplate: {
84
- [DOCUMENT_CLOUD_STORAGE_LIMIT_CONFIG_KEY]: 1024
85
- }
86
- },
87
- {
88
- featureKey: WORKSPACES_FEATURE_KEY,
89
- label: 'Workspaces',
90
- description: 'Allow multiple workspaces with a configurable workspace limit.',
91
- allowedAccessModes: ['enabled', 'disabled'],
92
- configTemplate: {
93
- [WORKSPACES_LIMIT_CONFIG_KEY]: 1
94
- }
95
- }
96
- ];
97
- const PLAN_FEATURE_DEPENDENCIES = Object.freeze(DEFAULT_PLAN_FEATURE_CATALOG.reduce((acc, feature) => {
98
- if (Array.isArray(feature.dependsOn) && feature.dependsOn.length > 0) {
99
- acc[feature.featureKey] = [...feature.dependsOn];
100
- }
101
- return acc;
102
- }, {}));
103
61
  function hashPassword(password) {
104
62
  const iterations = 120000;
105
63
  const salt = randomBytes(16).toString('hex');
@@ -121,6 +79,12 @@ function verifyPassword(password, encoded) {
121
79
  return false;
122
80
  return timingSafeEqual(actual, expected);
123
81
  }
82
+ function assertPasswordPolicy(password, context) {
83
+ const validation = validatePasswordPolicy(password, context);
84
+ if (!validation.ok) {
85
+ throw new TaskforceRuleError(validation.message || 'Password does not meet the password policy.', 400, validation.code || 'PASSWORD_TOO_SHORT');
86
+ }
87
+ }
124
88
  function hashAuthToken(token) {
125
89
  return createHash('sha256').update(token).digest('hex');
126
90
  }
@@ -179,6 +143,7 @@ export class TaskforceCore {
179
143
  db;
180
144
  globalSettingsDb = null;
181
145
  authTokenService;
146
+ identityService;
182
147
  mcpTokenService;
183
148
  planEntitlementService;
184
149
  realtimeMutationListeners = new Set();
@@ -303,6 +268,15 @@ export class TaskforceCore {
303
268
  const normalized = String(value || '').trim().toLowerCase();
304
269
  return normalized === 'user' || normalized === 'human';
305
270
  }
271
+ isStableActorReference(value) {
272
+ const normalized = String(value || '').trim().toLowerCase();
273
+ if (!normalized)
274
+ return false;
275
+ return normalized === 'user'
276
+ || normalized === 'system'
277
+ || normalized.startsWith('user-')
278
+ || normalized.startsWith('ai-profile-');
279
+ }
306
280
  normalizeAiProfileName(name) {
307
281
  const normalized = String(name || '').trim();
308
282
  if (!normalized) {
@@ -408,12 +382,24 @@ export class TaskforceCore {
408
382
  username: String(row.username || '').trim() || this.normalizeAiProfileUsername(row.name, 'ai'),
409
383
  icon: this.normalizeAiProfileIcon(row.icon),
410
384
  color: this.normalizeAiProfileColor(row.color),
385
+ avatarUrl: typeof row.avatar_url === 'string' && row.avatar_url.trim().length > 0 ? row.avatar_url.trim() : null,
386
+ avatarSourceUrl: typeof row.avatar_source_url === 'string' && row.avatar_source_url.trim().length > 0 ? row.avatar_source_url.trim() : null,
387
+ avatarRevision: Number.isFinite(Number(row.avatar_revision)) ? Math.max(0, Math.floor(Number(row.avatar_revision))) : 0,
388
+ avatarUpdatedAt: typeof row.avatar_updated_at === 'string' && row.avatar_updated_at.trim().length > 0 ? row.avatar_updated_at.trim() : null,
411
389
  description: typeof row.description === 'string' && row.description.trim().length > 0 ? row.description.trim() : null,
412
390
  role: typeof row.role === 'string' && row.role.trim().length > 0 ? row.role.trim() : null,
413
391
  provider: typeof row.provider === 'string' && row.provider.trim().length > 0 ? row.provider.trim() : null,
414
392
  model: typeof row.model === 'string' && row.model.trim().length > 0 ? row.model.trim() : null,
415
393
  surfaceType: parseAiProfileSurfaceType(row.surface_type),
394
+ seatScope: parseAiProfileSeatScope(row.seat_scope),
416
395
  providerMetadata,
396
+ archivedAt: typeof row.archived_at === 'string' ? row.archived_at : null,
397
+ archivedBy: typeof row.archived_by === 'string' && row.archived_by.trim().length > 0 ? row.archived_by.trim() : null,
398
+ archivedReason: typeof row.archived_reason === 'string' && row.archived_reason.trim().length > 0 ? row.archived_reason.trim() : null,
399
+ mergedIntoProfileId: typeof row.merged_into_profile_id === 'string' && row.merged_into_profile_id.trim().length > 0 ? row.merged_into_profile_id.trim() : null,
400
+ rosterStateUpdatedAt: typeof row.roster_state_updated_at === 'string' && row.roster_state_updated_at.trim().length > 0
401
+ ? row.roster_state_updated_at.trim()
402
+ : null,
417
403
  createdAt: String(row.created_at || '').trim(),
418
404
  updatedAt: String(row.updated_at || '').trim(),
419
405
  lastActiveAt: typeof row.last_active_at === 'string' ? row.last_active_at : null
@@ -429,17 +415,77 @@ export class TaskforceCore {
429
415
  color: profile.color,
430
416
  isNamed: true,
431
417
  meta: {
432
- profileToken: profile.profileToken,
433
418
  workspaceId: profile.workspaceId,
419
+ avatarUrl: profile.avatarUrl ?? null,
420
+ avatarSourceUrl: profile.avatarSourceUrl ?? null,
434
421
  lastActiveAt: profile.lastActiveAt ?? null,
435
422
  description: profile.description ?? null,
436
423
  role: profile.role ?? null,
437
424
  provider: profile.provider ?? null,
438
425
  model: profile.model ?? null,
439
- surfaceType: profile.surfaceType ?? null
426
+ surfaceType: profile.surfaceType ?? null,
427
+ seatScope: profile.seatScope ?? null,
428
+ archivedAt: profile.archivedAt ?? null,
429
+ archivedBy: profile.archivedBy ?? null,
430
+ archivedReason: profile.archivedReason ?? null,
431
+ mergedIntoProfileId: profile.mergedIntoProfileId ?? null,
432
+ rosterStateUpdatedAt: profile.rosterStateUpdatedAt ?? null
440
433
  }
441
434
  };
442
435
  }
436
+ resolveCanonicalAiProfileId(rowById, profileId) {
437
+ let currentId = String(profileId || '').trim();
438
+ const visited = new Set();
439
+ while (currentId) {
440
+ if (visited.has(currentId))
441
+ break;
442
+ visited.add(currentId);
443
+ const row = rowById.get(currentId);
444
+ const mergedIntoProfileId = String(row?.merged_into_profile_id || '').trim();
445
+ if (!mergedIntoProfileId || mergedIntoProfileId === currentId)
446
+ break;
447
+ currentId = mergedIntoProfileId;
448
+ }
449
+ return currentId;
450
+ }
451
+ findCanonicalAiProfileIdsByAlias(workspaceId, actorRef) {
452
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
453
+ const rawActorRef = String(actorRef || '').trim();
454
+ if (!rawActorRef)
455
+ return [];
456
+ const matches = this.db.prepare(`
457
+ SELECT *
458
+ FROM ai_profiles
459
+ WHERE tenant_id = ?
460
+ AND workspace_id = ?
461
+ AND (
462
+ LOWER(name) = LOWER(?)
463
+ OR LOWER(username) = LOWER(?)
464
+ )
465
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
466
+ `).all(this.tenantId, normalizedWorkspaceId, rawActorRef, rawActorRef);
467
+ const rowById = new Map(matches
468
+ .map((row) => [String(row.id || '').trim(), row])
469
+ .filter(([id]) => Boolean(id)));
470
+ return [...new Set(matches
471
+ .map((row) => this.resolveCanonicalAiProfileId(rowById, String(row.id || '').trim()))
472
+ .filter(Boolean))];
473
+ }
474
+ resolveUniqueAiProfileActorByAlias(workspaceId, actorRef) {
475
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
476
+ const uniqueCanonicalIds = this.findCanonicalAiProfileIdsByAlias(normalizedWorkspaceId, actorRef);
477
+ if (uniqueCanonicalIds.length !== 1)
478
+ return null;
479
+ const resolvedRow = this.db.prepare(`
480
+ SELECT *
481
+ FROM ai_profiles
482
+ WHERE tenant_id = ?
483
+ AND workspace_id = ?
484
+ AND id = ?
485
+ LIMIT 1
486
+ `).get(this.tenantId, normalizedWorkspaceId, uniqueCanonicalIds[0]);
487
+ return resolvedRow ? this.aiProfileToActor(this.aiProfileRowToRecord(resolvedRow)) : null;
488
+ }
443
489
  canonicalizeActorReference(workspaceId, value, fallback) {
444
490
  const raw = String(value || '').trim();
445
491
  if (!raw)
@@ -548,8 +594,21 @@ export class TaskforceCore {
548
594
  normalized.fsPath = raw.fsPath.trim();
549
595
  if (typeof raw.caption === 'string' && raw.caption.trim())
550
596
  normalized.caption = raw.caption.trim();
597
+ if (typeof raw.displayName === 'string' && raw.displayName.trim())
598
+ normalized.displayName = raw.displayName.trim();
599
+ if (typeof raw.originalFilename === 'string' && raw.originalFilename.trim())
600
+ normalized.originalFilename = raw.originalFilename.trim();
551
601
  if (typeof raw.timestamp === 'string' && raw.timestamp.trim())
552
602
  normalized.timestamp = raw.timestamp.trim();
603
+ if (typeof raw.assetId === 'string' && raw.assetId.trim())
604
+ normalized.assetId = raw.assetId.trim();
605
+ const referenceNumber = this.sanitizeTaskReferenceNumber(raw.referenceNumber);
606
+ if (referenceNumber !== null)
607
+ normalized.referenceNumber = referenceNumber;
608
+ if (typeof raw.referenceLabel === 'string' && raw.referenceLabel.trim())
609
+ normalized.referenceLabel = raw.referenceLabel.trim();
610
+ if (typeof raw.taskId === 'string' && raw.taskId.trim())
611
+ normalized.taskId = raw.taskId.trim();
553
612
  return normalized;
554
613
  })
555
614
  .filter((attachment) => attachment !== null);
@@ -569,15 +628,27 @@ export class TaskforceCore {
569
628
  actorProfile
570
629
  };
571
630
  }
631
+ listEntityEventsInternal(entityType, entityId, workspaceId) {
632
+ return listEntityEventsInternalService(this.getTaskActivityServiceDeps(), entityType, entityId, workspaceId);
633
+ }
572
634
  listTaskEventsInternal(taskId, workspaceId) {
573
635
  return listTaskEventsInternalService(this.getTaskActivityServiceDeps(), taskId, workspaceId);
574
636
  }
637
+ buildEntityActivity(comments, entityEvents, hiddenActions) {
638
+ return buildEntityActivityService(comments, entityEvents, hiddenActions?.length ? { hiddenActions } : undefined);
639
+ }
575
640
  buildTaskActivity(comments, taskEvents) {
576
641
  return buildTaskActivityService(comments, taskEvents);
577
642
  }
643
+ addEntityEvent(input) {
644
+ addEntityEventService(this.getTaskActivityServiceDeps(), input);
645
+ }
578
646
  addTaskEvent(input) {
579
647
  addTaskEventService(this.getTaskActivityServiceDeps(), input);
580
648
  }
649
+ replaceEntityEvents(entityType, entityId, workspaceId, entityEvents) {
650
+ replaceEntityEventsService(this.getTaskActivityServiceDeps(), entityType, entityId, workspaceId, entityEvents);
651
+ }
581
652
  replaceTaskEvents(taskId, workspaceId, taskEvents) {
582
653
  replaceTaskEventsService(this.getTaskActivityServiceDeps(), taskId, workspaceId, taskEvents);
583
654
  }
@@ -1083,6 +1154,65 @@ export class TaskforceCore {
1083
1154
  }
1084
1155
  return (existingValue ?? null) === (nextValue ?? null);
1085
1156
  }
1157
+ buildPlanningEntityEventChanges(existing, next, fields) {
1158
+ const changes = {};
1159
+ for (const field of fields) {
1160
+ const previousValue = existing[field] ?? null;
1161
+ const nextValue = next[field] ?? null;
1162
+ if (JSON.stringify(previousValue) === JSON.stringify(nextValue))
1163
+ continue;
1164
+ changes[field] = { from: previousValue, to: nextValue };
1165
+ }
1166
+ return changes;
1167
+ }
1168
+ getPlanningEntityEventAction(entityType, changes) {
1169
+ const fields = Object.keys(changes);
1170
+ if (fields.length === 1 && fields[0] === 'attachments') {
1171
+ return `${entityType}-attachments-changed`;
1172
+ }
1173
+ if (entityType === 'workstream' && fields.length === 1 && fields[0] === 'initiativeId') {
1174
+ return 'workstream-relationship-changed';
1175
+ }
1176
+ return `${entityType}-updated`;
1177
+ }
1178
+ addWorkstreamTaskMembershipEvents(input) {
1179
+ const previousWorkstreamId = this.sanitizeWorkstreamId(input.previousWorkstreamId);
1180
+ const nextWorkstreamId = this.sanitizeWorkstreamId(input.nextWorkstreamId);
1181
+ if (previousWorkstreamId === nextWorkstreamId)
1182
+ return;
1183
+ const details = {
1184
+ taskId: input.task.id,
1185
+ taskTitle: input.task.title,
1186
+ changes: {
1187
+ workstreamId: {
1188
+ from: previousWorkstreamId ?? null,
1189
+ to: nextWorkstreamId ?? null,
1190
+ }
1191
+ }
1192
+ };
1193
+ if (previousWorkstreamId) {
1194
+ this.addEntityEvent({
1195
+ entityType: 'workstream',
1196
+ entityId: previousWorkstreamId,
1197
+ workspaceId: input.workspaceId,
1198
+ action: 'workstream-task-removed',
1199
+ actor: input.actor,
1200
+ createdAt: input.createdAt,
1201
+ details,
1202
+ });
1203
+ }
1204
+ if (nextWorkstreamId) {
1205
+ this.addEntityEvent({
1206
+ entityType: 'workstream',
1207
+ entityId: nextWorkstreamId,
1208
+ workspaceId: input.workspaceId,
1209
+ action: 'workstream-task-added',
1210
+ actor: input.actor,
1211
+ createdAt: input.createdAt,
1212
+ details,
1213
+ });
1214
+ }
1215
+ }
1086
1216
  assertWorkstreamLinkAllowed(workstreamId, workspaceId) {
1087
1217
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
1088
1218
  if (!workstreamId)
@@ -1160,7 +1290,6 @@ export class TaskforceCore {
1160
1290
  this.initializeSchema();
1161
1291
  ensureTenantSchema(this.db);
1162
1292
  ensureWorkspaceSchema(this.db);
1163
- ensureParentTaskSchema(this.db);
1164
1293
  ensureScheduleTaskSchema(this.db);
1165
1294
  ensureAssigneeTaskSchema(this.db);
1166
1295
  ensureTaskReferenceSchema(this.db);
@@ -1171,6 +1300,7 @@ export class TaskforceCore {
1171
1300
  ensureAnnotatedAttachmentSessionsSchema(this.db);
1172
1301
  ensureBillingSchema(this.db);
1173
1302
  ensureEntitlementsSchema(this.db);
1303
+ ensureAuthIdentitySchema(this.db);
1174
1304
  this.performMigrationIfNeeded();
1175
1305
  this.planEntitlementService = new PlanEntitlementService(this.db, this.tenantId, {
1176
1306
  getWorkspaceOwnerUserId: (wid) => this.getWorkspaceOwnerUserId(wid),
@@ -1182,6 +1312,8 @@ export class TaskforceCore {
1182
1312
  this.planEntitlementService.backfillAccountEntitlements();
1183
1313
  this.repairDetachedAnnotatedAttachmentSessions();
1184
1314
  this.authTokenService = new AuthTokenService(this.db, this.tenantId);
1315
+ this.identityService = new AuthIdentityService(this.db, this.tenantId);
1316
+ this.authTokenService.setIdentityService(this.identityService);
1185
1317
  this.mcpTokenService = new McpTokenService(this.db, this.tenantId, {
1186
1318
  getAccountAccessStatus: (userId) => this.getAccountAccessStatus(userId),
1187
1319
  });
@@ -1225,11 +1357,23 @@ export class TaskforceCore {
1225
1357
  if (!accountId) {
1226
1358
  throw new TaskforceRuleError('accountId is required', 400, 'ACCOUNT_ID_REQUIRED');
1227
1359
  }
1228
- if (this.hasAccountEntitlement(accountId)) {
1229
- return this.getAccountEntitlement(accountId);
1360
+ const existingEntitlement = this.hasAccountEntitlement(accountId)
1361
+ ? this.getAccountEntitlement(accountId)
1362
+ : null;
1363
+ if (existingEntitlement) {
1364
+ if (options?.source !== 'invite-accept') {
1365
+ return existingEntitlement;
1366
+ }
1367
+ const existingAccess = this.getAccountAccessStatus(accountId);
1368
+ if (existingAccess.canAccessApp) {
1369
+ return existingAccess.entitlement || existingEntitlement;
1370
+ }
1230
1371
  }
1231
1372
  const now = options?.now || new Date().toISOString();
1232
- const onboardingOutcome = this.resolveRegistrationOnboardingOutcome();
1373
+ const inviteOutcome = options?.source === 'invite-accept'
1374
+ ? this.planEntitlementService.resolveInviteAcceptanceDefaultEntitlement()
1375
+ : null;
1376
+ const onboardingOutcome = inviteOutcome || this.resolveRegistrationOnboardingOutcome();
1233
1377
  this.transitionCanonicalCommercialState({
1234
1378
  accountId,
1235
1379
  planVersionId: onboardingOutcome.planVersionId,
@@ -1315,6 +1459,26 @@ export class TaskforceCore {
1315
1459
  }
1316
1460
  }
1317
1461
  }
1462
+ supportsWorkspaceAssetLinkTargetType() {
1463
+ try {
1464
+ if (this.db.provider === 'postgres') {
1465
+ const row = this.db.prepare(`
1466
+ SELECT 1
1467
+ FROM information_schema.columns
1468
+ WHERE table_schema = current_schema()
1469
+ AND table_name = 'workspace_asset_links'
1470
+ AND column_name = 'target_type'
1471
+ LIMIT 1
1472
+ `).get();
1473
+ return Boolean(row);
1474
+ }
1475
+ const columns = this.db.prepare(`PRAGMA table_info(workspace_asset_links)`).all();
1476
+ return columns.some((column) => String(column.name || '').trim() === 'target_type');
1477
+ }
1478
+ catch {
1479
+ return false;
1480
+ }
1481
+ }
1318
1482
  initializeSchema() {
1319
1483
  const syncEventsIdColumnSql = this.db.provider === 'postgres'
1320
1484
  ? 'id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY'
@@ -1350,10 +1514,7 @@ export class TaskforceCore {
1350
1514
  scheduled_week_key TEXT,
1351
1515
  order_in_day INTEGER,
1352
1516
  workstream_id TEXT,
1353
- parent_task_id TEXT,
1354
- child_display_order INTEGER,
1355
- FOREIGN KEY(workstream_id) REFERENCES workstreams(id) ON DELETE SET NULL,
1356
- FOREIGN KEY(parent_task_id) REFERENCES tasks(id) ON DELETE RESTRICT
1517
+ FOREIGN KEY(workstream_id) REFERENCES workstreams(id) ON DELETE SET NULL
1357
1518
  );
1358
1519
 
1359
1520
  CREATE TABLE IF NOT EXISTS workspace_task_reference_sequences (
@@ -1456,6 +1617,19 @@ export class TaskforceCore {
1456
1617
  FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
1457
1618
  );
1458
1619
 
1620
+ CREATE TABLE IF NOT EXISTS entity_events (
1621
+ id TEXT PRIMARY KEY,
1622
+ tenant_id TEXT NOT NULL DEFAULT 'default',
1623
+ workspace_id TEXT NOT NULL DEFAULT 'default',
1624
+ entity_type TEXT NOT NULL,
1625
+ entity_id TEXT NOT NULL,
1626
+ action TEXT NOT NULL,
1627
+ actor TEXT NOT NULL,
1628
+ actor_type TEXT NOT NULL,
1629
+ details_json TEXT,
1630
+ created_at TEXT NOT NULL
1631
+ );
1632
+
1459
1633
  CREATE TABLE IF NOT EXISTS deleted_tasks (
1460
1634
  id TEXT PRIMARY KEY,
1461
1635
  tenant_id TEXT NOT NULL DEFAULT 'default',
@@ -1584,6 +1758,9 @@ export class TaskforceCore {
1584
1758
  token_prefix TEXT NOT NULL,
1585
1759
  token_hash TEXT NOT NULL,
1586
1760
  status TEXT NOT NULL DEFAULT 'active',
1761
+ ai_profile_id TEXT,
1762
+ ai_profile_token TEXT,
1763
+ ai_profile_name TEXT,
1587
1764
  last_used_at TEXT,
1588
1765
  revoked_at TEXT,
1589
1766
  revoked_by_user_id TEXT,
@@ -1640,12 +1817,22 @@ export class TaskforceCore {
1640
1817
  username TEXT NOT NULL,
1641
1818
  icon TEXT NOT NULL,
1642
1819
  color TEXT NOT NULL,
1820
+ avatar_url TEXT,
1821
+ avatar_source_url TEXT,
1822
+ avatar_revision INTEGER NOT NULL DEFAULT 0,
1823
+ avatar_updated_at TEXT,
1643
1824
  description TEXT,
1644
1825
  role TEXT,
1645
1826
  provider TEXT,
1646
1827
  model TEXT,
1647
1828
  surface_type TEXT,
1829
+ seat_scope TEXT,
1648
1830
  provider_metadata_json TEXT,
1831
+ archived_at TEXT,
1832
+ archived_by TEXT,
1833
+ archived_reason TEXT,
1834
+ merged_into_profile_id TEXT,
1835
+ roster_state_updated_at TEXT,
1649
1836
  created_at TEXT NOT NULL,
1650
1837
  updated_at TEXT NOT NULL,
1651
1838
  last_active_at TEXT
@@ -1660,6 +1847,8 @@ export class TaskforceCore {
1660
1847
  CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_workspace_created ON admin_audit_logs(workspace_id, created_at);
1661
1848
  CREATE INDEX IF NOT EXISTS idx_task_events_task_created ON task_events(tenant_id, workspace_id, task_id, created_at);
1662
1849
  CREATE INDEX IF NOT EXISTS idx_task_events_workspace_created ON task_events(tenant_id, workspace_id, created_at);
1850
+ CREATE INDEX IF NOT EXISTS idx_entity_events_entity_created ON entity_events(tenant_id, workspace_id, entity_type, entity_id, created_at);
1851
+ CREATE INDEX IF NOT EXISTS idx_entity_events_workspace_created ON entity_events(tenant_id, workspace_id, created_at);
1663
1852
  CREATE UNIQUE INDEX IF NOT EXISTS idx_deleted_tasks_workspace_task ON deleted_tasks(tenant_id, workspace_id, task_id);
1664
1853
  CREATE INDEX IF NOT EXISTS idx_deleted_tasks_workspace_deleted ON deleted_tasks(tenant_id, workspace_id, deleted_at);
1665
1854
  CREATE INDEX IF NOT EXISTS idx_mcp_audit_logs_workspace_created ON mcp_audit_logs(tenant_id, workspace_id, created_at);
@@ -1764,7 +1953,27 @@ export class TaskforceCore {
1764
1953
  // Best-effort schema repair for older deployments.
1765
1954
  }
1766
1955
  }
1956
+ if (this.db.provider === 'sqlite') {
1957
+ this.db.exec(`
1958
+ INSERT OR IGNORE INTO entity_events (
1959
+ id, tenant_id, workspace_id, entity_type, entity_id, action, actor, actor_type, details_json, created_at
1960
+ )
1961
+ SELECT id, tenant_id, workspace_id, 'task', task_id, action, actor, actor_type, details_json, created_at
1962
+ FROM task_events
1963
+ `);
1964
+ }
1965
+ else {
1966
+ this.db.exec(`
1967
+ INSERT INTO entity_events (
1968
+ id, tenant_id, workspace_id, entity_type, entity_id, action, actor, actor_type, details_json, created_at
1969
+ )
1970
+ SELECT id, tenant_id, workspace_id, 'task', task_id, action, actor, actor_type, details_json, created_at
1971
+ FROM task_events
1972
+ ON CONFLICT (id) DO NOTHING
1973
+ `);
1974
+ }
1767
1975
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workstream ON tasks(tenant_id, workspace_id, workstream_id)`);
1976
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(tenant_id, workspace_id, assignee, is_archived)`);
1768
1977
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_workstreams_initiative ON workstreams(tenant_id, workspace_id, initiative_id)`);
1769
1978
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_checklist_items_task_order ON checklist_items(tenant_id, workspace_id, task_id, sort_order)`);
1770
1979
  if (this.db.provider === 'sqlite') {
@@ -1783,6 +1992,28 @@ export class TaskforceCore {
1783
1992
  }
1784
1993
  }
1785
1994
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_auth_tokens_workspace_purpose ON auth_tokens(workspace_id, purpose)`);
1995
+ if (this.db.provider === 'sqlite') {
1996
+ const mcpOauthGrantColumns = this.db.prepare(`PRAGMA table_info(mcp_oauth_connector_grants)`).all();
1997
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_id')) {
1998
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_id TEXT`);
1999
+ }
2000
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_token')) {
2001
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_token TEXT`);
2002
+ }
2003
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_name')) {
2004
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_name TEXT`);
2005
+ }
2006
+ }
2007
+ else {
2008
+ try {
2009
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_id TEXT`);
2010
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_token TEXT`);
2011
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_name TEXT`);
2012
+ }
2013
+ catch {
2014
+ // Best-effort schema repair for existing deployments.
2015
+ }
2016
+ }
1786
2017
  if (this.db.provider === 'sqlite') {
1787
2018
  const mcpAuditColumns = this.db.prepare(`PRAGMA table_info(mcp_audit_logs)`).all();
1788
2019
  if (!mcpAuditColumns.some((c) => c.name === 'request_id')) {
@@ -1881,12 +2112,22 @@ export class TaskforceCore {
1881
2112
  { name: 'username', ddl: `ALTER TABLE ai_profiles ADD COLUMN username TEXT NOT NULL DEFAULT 'ai'` },
1882
2113
  { name: 'icon', ddl: `ALTER TABLE ai_profiles ADD COLUMN icon TEXT NOT NULL DEFAULT 'Bot'` },
1883
2114
  { name: 'color', ddl: `ALTER TABLE ai_profiles ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'` },
2115
+ { name: 'avatar_url', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_url TEXT` },
2116
+ { name: 'avatar_source_url', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_source_url TEXT` },
2117
+ { name: 'avatar_revision', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_revision INTEGER NOT NULL DEFAULT 0` },
2118
+ { name: 'avatar_updated_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_updated_at TEXT` },
1884
2119
  { name: 'description', ddl: `ALTER TABLE ai_profiles ADD COLUMN description TEXT` },
1885
2120
  { name: 'role', ddl: `ALTER TABLE ai_profiles ADD COLUMN role TEXT` },
1886
2121
  { name: 'provider', ddl: `ALTER TABLE ai_profiles ADD COLUMN provider TEXT` },
1887
2122
  { name: 'model', ddl: `ALTER TABLE ai_profiles ADD COLUMN model TEXT` },
1888
2123
  { name: 'surface_type', ddl: `ALTER TABLE ai_profiles ADD COLUMN surface_type TEXT` },
2124
+ { name: 'seat_scope', ddl: `ALTER TABLE ai_profiles ADD COLUMN seat_scope TEXT` },
1889
2125
  { name: 'provider_metadata_json', ddl: `ALTER TABLE ai_profiles ADD COLUMN provider_metadata_json TEXT` },
2126
+ { name: 'archived_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN archived_at TEXT` },
2127
+ { name: 'archived_by', ddl: `ALTER TABLE ai_profiles ADD COLUMN archived_by TEXT` },
2128
+ { name: 'archived_reason', ddl: `ALTER TABLE ai_profiles ADD COLUMN archived_reason TEXT` },
2129
+ { name: 'merged_into_profile_id', ddl: `ALTER TABLE ai_profiles ADD COLUMN merged_into_profile_id TEXT` },
2130
+ { name: 'roster_state_updated_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN roster_state_updated_at TEXT` },
1890
2131
  { name: 'created_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN created_at TEXT NOT NULL DEFAULT ''` },
1891
2132
  { name: 'updated_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''` },
1892
2133
  { name: 'last_active_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN last_active_at TEXT` }
@@ -1905,12 +2146,22 @@ export class TaskforceCore {
1905
2146
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS username TEXT NOT NULL DEFAULT 'ai'`,
1906
2147
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS icon TEXT NOT NULL DEFAULT 'Bot'`,
1907
2148
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#64748b'`,
2149
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_url TEXT`,
2150
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_source_url TEXT`,
2151
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_revision INTEGER NOT NULL DEFAULT 0`,
2152
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_updated_at TEXT`,
1908
2153
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS description TEXT`,
1909
2154
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS role TEXT`,
1910
2155
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS provider TEXT`,
1911
2156
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS model TEXT`,
1912
2157
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS surface_type TEXT`,
2158
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS seat_scope TEXT`,
1913
2159
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS provider_metadata_json TEXT`,
2160
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS archived_at TEXT`,
2161
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS archived_by TEXT`,
2162
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS archived_reason TEXT`,
2163
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS merged_into_profile_id TEXT`,
2164
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS roster_state_updated_at TEXT`,
1914
2165
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS created_at TEXT NOT NULL DEFAULT ''`,
1915
2166
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS updated_at TEXT NOT NULL DEFAULT ''`,
1916
2167
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS last_active_at TEXT`
@@ -1924,10 +2175,45 @@ export class TaskforceCore {
1924
2175
  }
1925
2176
  }
1926
2177
  }
2178
+ this.db.exec(`
2179
+ UPDATE ai_profiles
2180
+ SET roster_state_updated_at = CASE
2181
+ WHEN archived_at IS NOT NULL AND TRIM(archived_at) <> '' THEN archived_at
2182
+ WHEN created_at IS NOT NULL AND TRIM(created_at) <> '' THEN created_at
2183
+ ELSE updated_at
2184
+ END
2185
+ WHERE roster_state_updated_at IS NULL
2186
+ OR TRIM(roster_state_updated_at) = ''
2187
+ OR (
2188
+ archived_at IS NOT NULL
2189
+ AND TRIM(archived_at) <> ''
2190
+ AND roster_state_updated_at < archived_at
2191
+ )
2192
+ `);
2193
+ this.db.exec(`
2194
+ UPDATE ai_profiles
2195
+ SET avatar_revision = CASE
2196
+ WHEN (
2197
+ (avatar_url IS NOT NULL AND TRIM(avatar_url) <> '')
2198
+ OR (avatar_source_url IS NOT NULL AND TRIM(avatar_source_url) <> '')
2199
+ ) THEN 1
2200
+ ELSE 0
2201
+ END,
2202
+ avatar_updated_at = CASE
2203
+ WHEN (
2204
+ (avatar_url IS NOT NULL AND TRIM(avatar_url) <> '')
2205
+ OR (avatar_source_url IS NOT NULL AND TRIM(avatar_source_url) <> '')
2206
+ ) THEN COALESCE(NULLIF(TRIM(avatar_updated_at), ''), NULLIF(TRIM(updated_at), ''), NULLIF(TRIM(created_at), ''))
2207
+ ELSE NULL
2208
+ END
2209
+ WHERE avatar_revision IS NULL
2210
+ OR avatar_revision = 0
2211
+ `);
1927
2212
  this.migrateSystemRoleBackfillIfNeeded();
1928
2213
  this.migrateUserBetaAccessBackfillIfNeeded();
1929
2214
  this.migrateDuplicateAiProfilesIfNeeded();
1930
2215
  this.migrateLegacyActorReferencesIfNeeded();
2216
+ this.migrateLegacyCommentActorReferencesIfNeeded();
1931
2217
  // Ensure sync watermark index exists on existing databases (added in v0.3.52+).
1932
2218
  this.db.exec(`
1933
2219
  CREATE INDEX IF NOT EXISTS idx_tasks_sync_watermark ON tasks(tenant_id, workspace_id, updated_at, id);
@@ -2074,7 +2360,7 @@ export class TaskforceCore {
2074
2360
  if (existing?.payload)
2075
2361
  return;
2076
2362
  const rows = this.db.prepare(`
2077
- SELECT id, workspace_id, name, username, created_at, updated_at, last_active_at
2363
+ SELECT id, workspace_id, name, username, seat_scope, created_at, updated_at, last_active_at
2078
2364
  FROM ai_profiles
2079
2365
  WHERE tenant_id = ?
2080
2366
  `).all(this.tenantId);
@@ -2083,7 +2369,8 @@ export class TaskforceCore {
2083
2369
  const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2084
2370
  const normalizedName = String(row.name || '').trim().toLowerCase();
2085
2371
  const normalizedUsername = String(row.username || '').trim().toLowerCase();
2086
- const dedupeKey = `${workspaceId}::${normalizedName || normalizedUsername}`;
2372
+ const seatScope = String(row.seat_scope || 'unknown').trim().toLowerCase() || 'unknown';
2373
+ const dedupeKey = `${workspaceId}::${seatScope}::${normalizedName || normalizedUsername}`;
2087
2374
  if (!normalizedName && !normalizedUsername)
2088
2375
  continue;
2089
2376
  const list = groups.get(dedupeKey) || [];
@@ -2131,15 +2418,19 @@ export class TaskforceCore {
2131
2418
  if (existing?.payload)
2132
2419
  return;
2133
2420
  const rows = this.db.prepare(`
2134
- SELECT id, workspace_id, name, username
2421
+ SELECT id, workspace_id, name, username, merged_into_profile_id
2135
2422
  FROM ai_profiles
2136
2423
  WHERE tenant_id = ?
2137
2424
  `).all(this.tenantId);
2425
+ const rowById = new Map(rows
2426
+ .map((row) => [String(row.id || '').trim(), row])
2427
+ .filter(([id]) => Boolean(id)));
2138
2428
  const aliasesByWorkspace = new Map();
2139
2429
  for (const row of rows) {
2140
2430
  const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2141
2431
  const profileId = String(row.id || '').trim();
2142
- if (!profileId)
2432
+ const canonicalProfileId = this.resolveCanonicalAiProfileId(rowById, profileId);
2433
+ if (!canonicalProfileId)
2143
2434
  continue;
2144
2435
  const aliases = [
2145
2436
  String(row.name || '').trim().toLowerCase(),
@@ -2154,8 +2445,8 @@ export class TaskforceCore {
2154
2445
  }
2155
2446
  for (const alias of aliases) {
2156
2447
  const ids = aliasMap.get(alias) || [];
2157
- if (!ids.includes(profileId))
2158
- ids.push(profileId);
2448
+ if (!ids.includes(canonicalProfileId))
2449
+ ids.push(canonicalProfileId);
2159
2450
  aliasMap.set(alias, ids);
2160
2451
  }
2161
2452
  }
@@ -2196,6 +2487,135 @@ export class TaskforceCore {
2196
2487
  updated_at = excluded.updated_at
2197
2488
  `).run(this.tenantId, migrationKey, JSON.stringify({ completedAt: now }), now);
2198
2489
  }
2490
+ migrateLegacyCommentActorReferencesIfNeeded() {
2491
+ const migrationKey = 'migration.actor_refs.canonicalize_comments.v2';
2492
+ const existing = this.db.prepare(`
2493
+ SELECT payload
2494
+ FROM system_settings
2495
+ WHERE tenant_id = ? AND settings_key = ?
2496
+ LIMIT 1
2497
+ `).get(this.tenantId, migrationKey);
2498
+ if (existing?.payload)
2499
+ return;
2500
+ const commentRows = this.db.prepare(`
2501
+ SELECT id, task_id, workspace_id, author, submitted_author
2502
+ FROM comments
2503
+ WHERE tenant_id = ?
2504
+ `).all(this.tenantId);
2505
+ const updateCommentAuthor = this.db.prepare(`
2506
+ UPDATE comments
2507
+ SET author = ?
2508
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2509
+ `);
2510
+ const updateTaskSyncWatermark = this.db.prepare(`
2511
+ UPDATE tasks
2512
+ SET updated_at = ?
2513
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2514
+ `);
2515
+ const selectTaskSyncWatermark = this.db.prepare(`
2516
+ SELECT updated_at, created_at
2517
+ FROM tasks
2518
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2519
+ LIMIT 1
2520
+ `);
2521
+ let repairedCount = 0;
2522
+ let ambiguousCount = 0;
2523
+ let skippedCount = 0;
2524
+ const touchedTaskIdsByWorkspace = new Map();
2525
+ const now = new Date().toISOString();
2526
+ const nowMs = Date.parse(now);
2527
+ const tx = this.db.transaction(() => {
2528
+ for (const row of commentRows) {
2529
+ const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2530
+ const commentId = String(row.id || '').trim();
2531
+ const taskId = String(row.task_id || '').trim();
2532
+ const rawAuthor = String(row.author || '').trim();
2533
+ const rawSubmittedAuthor = String(row.submitted_author || '').trim();
2534
+ const normalizedAuthor = rawAuthor.toLowerCase();
2535
+ if (!commentId) {
2536
+ skippedCount += 1;
2537
+ continue;
2538
+ }
2539
+ if (this.isStableActorReference(rawAuthor)
2540
+ || this.isHumanActorReference(rawAuthor)
2541
+ || normalizedAuthor === 'system') {
2542
+ skippedCount += 1;
2543
+ continue;
2544
+ }
2545
+ const candidateIds = new Set();
2546
+ let hasAmbiguousEvidence = false;
2547
+ const evidenceValues = [rawAuthor, rawSubmittedAuthor];
2548
+ for (const value of evidenceValues) {
2549
+ const rawValue = String(value || '').trim();
2550
+ const normalizedValue = rawValue.toLowerCase();
2551
+ if (!rawValue)
2552
+ continue;
2553
+ if (this.isHumanActorReference(rawValue)
2554
+ || this.isGenericAiActorReference(rawValue)
2555
+ || normalizedValue === 'system'
2556
+ || normalizedValue.startsWith('user-')) {
2557
+ continue;
2558
+ }
2559
+ if (normalizedValue.startsWith('ai-profile-')) {
2560
+ const actor = this.resolveWorkspaceActor(workspaceId, rawValue);
2561
+ if (actor?.kind === 'ai' && actor.meta?.unresolved !== true) {
2562
+ candidateIds.add(actor.id);
2563
+ }
2564
+ continue;
2565
+ }
2566
+ const matches = this.findCanonicalAiProfileIdsByAlias(workspaceId, rawValue);
2567
+ if (matches.length === 1) {
2568
+ candidateIds.add(matches[0]);
2569
+ }
2570
+ else if (matches.length > 1) {
2571
+ hasAmbiguousEvidence = true;
2572
+ }
2573
+ }
2574
+ if (!hasAmbiguousEvidence && candidateIds.size === 1) {
2575
+ updateCommentAuthor.run([...candidateIds][0], this.tenantId, workspaceId, commentId);
2576
+ if (taskId) {
2577
+ let touchedTaskIds = touchedTaskIdsByWorkspace.get(workspaceId);
2578
+ if (!touchedTaskIds) {
2579
+ touchedTaskIds = new Set();
2580
+ touchedTaskIdsByWorkspace.set(workspaceId, touchedTaskIds);
2581
+ }
2582
+ touchedTaskIds.add(taskId);
2583
+ }
2584
+ repairedCount += 1;
2585
+ continue;
2586
+ }
2587
+ if (hasAmbiguousEvidence || candidateIds.size > 1) {
2588
+ ambiguousCount += 1;
2589
+ continue;
2590
+ }
2591
+ skippedCount += 1;
2592
+ }
2593
+ for (const [workspaceId, touchedTaskIds] of touchedTaskIdsByWorkspace.entries()) {
2594
+ for (const taskId of touchedTaskIds) {
2595
+ const taskRow = selectTaskSyncWatermark.get(this.tenantId, workspaceId, taskId);
2596
+ const existingWatermark = String(taskRow?.updated_at || taskRow?.created_at || '').trim();
2597
+ const existingWatermarkMs = Date.parse(existingWatermark);
2598
+ const nextWatermarkMs = Number.isFinite(existingWatermarkMs)
2599
+ ? Math.max(existingWatermarkMs, nowMs) + 1
2600
+ : nowMs;
2601
+ updateTaskSyncWatermark.run(new Date(nextWatermarkMs).toISOString(), this.tenantId, workspaceId, taskId);
2602
+ }
2603
+ }
2604
+ });
2605
+ tx();
2606
+ this.db.prepare(`
2607
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
2608
+ VALUES (?, ?, ?, ?)
2609
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
2610
+ payload = excluded.payload,
2611
+ updated_at = excluded.updated_at
2612
+ `).run(this.tenantId, migrationKey, JSON.stringify({
2613
+ completedAt: now,
2614
+ repairedCount,
2615
+ ambiguousCount,
2616
+ skippedCount,
2617
+ }), now);
2618
+ }
2199
2619
  getWorkspaceOwnerUserId(workspaceId) {
2200
2620
  const normalizedWorkspaceId = String(workspaceId || '').trim();
2201
2621
  if (!normalizedWorkspaceId)
@@ -2668,6 +3088,9 @@ export class TaskforceCore {
2668
3088
  getOnboardingPolicy() {
2669
3089
  return this.planEntitlementService.getOnboardingPolicy();
2670
3090
  }
3091
+ listOnboardingPlanOptions() {
3092
+ return this.planEntitlementService.listOnboardingPlanOptions();
3093
+ }
2671
3094
  validateOnboardingPolicy(input) {
2672
3095
  return this.planEntitlementService.validateOnboardingPolicy(input);
2673
3096
  }
@@ -2709,31 +3132,58 @@ export class TaskforceCore {
2709
3132
  listCanonicalTaskAttachments(taskId, workspaceId) {
2710
3133
  try {
2711
3134
  const workspaceAssetStore = new WorkspaceAssetStore(this.db, this.tenantId);
2712
- const rows = this.db.prepare(`
2713
- SELECT
2714
- a.asset_id,
2715
- a.kind,
2716
- a.reference_number,
2717
- a.storage_provider,
2718
- a.storage_key,
2719
- a.logical_name,
2720
- a.original_filename,
2721
- a.updated_at,
2722
- a.created_at,
2723
- l.role,
2724
- l.display_name
2725
- FROM workspace_asset_links l
2726
- JOIN workspace_assets a
2727
- ON a.tenant_id = l.tenant_id
2728
- AND a.workspace_id = l.workspace_id
2729
- AND a.asset_id = l.asset_id
2730
- WHERE l.tenant_id = ?
2731
- AND l.workspace_id = ?
2732
- AND l.task_id = ?
2733
- AND l.deleted_at IS NULL
2734
- AND a.deleted_at IS NULL
2735
- ORDER BY COALESCE(l.sort_order, 2147483647) ASC, a.updated_at DESC, a.asset_id ASC
2736
- `).all(this.tenantId, workspaceId, taskId);
3135
+ const rows = (this.supportsWorkspaceAssetLinkTargetType()
3136
+ ? this.db.prepare(`
3137
+ SELECT
3138
+ a.asset_id,
3139
+ a.kind,
3140
+ a.reference_number,
3141
+ a.storage_provider,
3142
+ a.storage_key,
3143
+ a.logical_name,
3144
+ a.original_filename,
3145
+ a.updated_at,
3146
+ a.created_at,
3147
+ l.role,
3148
+ l.display_name
3149
+ FROM workspace_asset_links l
3150
+ JOIN workspace_assets a
3151
+ ON a.tenant_id = l.tenant_id
3152
+ AND a.workspace_id = l.workspace_id
3153
+ AND a.asset_id = l.asset_id
3154
+ WHERE l.tenant_id = ?
3155
+ AND l.workspace_id = ?
3156
+ AND l.target_type = 'task'
3157
+ AND l.task_id = ?
3158
+ AND l.deleted_at IS NULL
3159
+ AND a.deleted_at IS NULL
3160
+ ORDER BY COALESCE(l.sort_order, 2147483647) ASC, a.updated_at DESC, a.asset_id ASC
3161
+ `).all(this.tenantId, workspaceId, taskId)
3162
+ : this.db.prepare(`
3163
+ SELECT
3164
+ a.asset_id,
3165
+ a.kind,
3166
+ a.reference_number,
3167
+ a.storage_provider,
3168
+ a.storage_key,
3169
+ a.logical_name,
3170
+ a.original_filename,
3171
+ a.updated_at,
3172
+ a.created_at,
3173
+ l.role,
3174
+ l.display_name
3175
+ FROM workspace_asset_links l
3176
+ JOIN workspace_assets a
3177
+ ON a.tenant_id = l.tenant_id
3178
+ AND a.workspace_id = l.workspace_id
3179
+ AND a.asset_id = l.asset_id
3180
+ WHERE l.tenant_id = ?
3181
+ AND l.workspace_id = ?
3182
+ AND l.task_id = ?
3183
+ AND l.deleted_at IS NULL
3184
+ AND a.deleted_at IS NULL
3185
+ ORDER BY COALESCE(l.sort_order, 2147483647) ASC, a.updated_at DESC, a.asset_id ASC
3186
+ `).all(this.tenantId, workspaceId, taskId));
2737
3187
  const attachments = rows.map((row) => {
2738
3188
  const storageKey = String(row.storage_key || '').trim();
2739
3189
  const storageProvider = String(row.storage_provider || 'local').trim();
@@ -2841,6 +3291,9 @@ export class TaskforceCore {
2841
3291
  syncCanonicalTaskAttachmentLinks(taskId, workspaceId, attachments) {
2842
3292
  syncCanonicalTaskAttachmentLinksService(this.getAttachmentLinkServiceDeps(), taskId, workspaceId, attachments);
2843
3293
  }
3294
+ syncCanonicalPlanningAttachmentLinks(targetType, targetId, workspaceId, attachments) {
3295
+ syncCanonicalAttachmentLinksForTargetService(this.getAttachmentLinkServiceDeps(), targetType, targetId, workspaceId, attachments);
3296
+ }
2844
3297
  rowToTask(row) {
2845
3298
  const workspaceId = this.normalizeWorkspaceId(row?.workspace_id);
2846
3299
  const commentsRaw = this.db.prepare('SELECT * FROM comments WHERE tenant_id = ? AND workspace_id = ? AND task_id = ? ORDER BY timestamp ASC').all(this.tenantId, workspaceId, row.id);
@@ -2899,12 +3352,21 @@ export class TaskforceCore {
2899
3352
  checklistItems
2900
3353
  };
2901
3354
  }
2902
- rowToInitiative(row) {
3355
+ rowToInitiative(row, options) {
3356
+ const id = String(row.id || '').trim();
3357
+ const workspaceId = this.normalizeWorkspaceId(typeof row.workspace_id === 'string' ? row.workspace_id : undefined);
2903
3358
  const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
2904
3359
  const comments = this.normalizeIncomingComments(this.parseJsonField(row.comments_json, []));
3360
+ const includeActivity = options?.includeActivity === true;
3361
+ const entityEvents = includeActivity
3362
+ ? this.listEntityEventsInternal('initiative', id, workspaceId)
3363
+ : undefined;
3364
+ const activity = includeActivity
3365
+ ? this.buildEntityActivity(comments, entityEvents || [])
3366
+ : undefined;
2905
3367
  const attachments = this.normalizePlanningAttachments(this.parseJsonField(row.attachments_json, []));
2906
3368
  return {
2907
- id: String(row.id || '').trim(),
3369
+ id,
2908
3370
  referenceNumber,
2909
3371
  referenceLabel: formatInitiativeReference(referenceNumber),
2910
3372
  title: String(row.title || '').trim() || 'Untitled Initiative',
@@ -2916,14 +3378,27 @@ export class TaskforceCore {
2916
3378
  isArchived: Boolean(row.is_archived),
2917
3379
  comments,
2918
3380
  attachments,
3381
+ ...(includeActivity ? {
3382
+ activity,
3383
+ entityEvents,
3384
+ } : {}),
2919
3385
  };
2920
3386
  }
2921
- rowToWorkstream(row) {
3387
+ rowToWorkstream(row, options) {
3388
+ const id = String(row.id || '').trim();
3389
+ const workspaceId = this.normalizeWorkspaceId(typeof row.workspace_id === 'string' ? row.workspace_id : undefined);
2922
3390
  const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
2923
3391
  const comments = this.normalizeIncomingComments(this.parseJsonField(row.comments_json, []));
3392
+ const includeActivity = options?.includeActivity === true;
3393
+ const entityEvents = includeActivity
3394
+ ? this.listEntityEventsInternal('workstream', id, workspaceId)
3395
+ : undefined;
3396
+ const activity = includeActivity
3397
+ ? this.buildEntityActivity(comments, entityEvents || [])
3398
+ : undefined;
2924
3399
  const attachments = this.normalizePlanningAttachments(this.parseJsonField(row.attachments_json, []));
2925
3400
  return {
2926
- id: String(row.id || '').trim(),
3401
+ id,
2927
3402
  referenceNumber,
2928
3403
  referenceLabel: formatWorkstreamReference(referenceNumber),
2929
3404
  initiativeId: typeof row.initiative_id === 'string' && row.initiative_id.trim().length > 0 ? row.initiative_id.trim() : null,
@@ -2936,6 +3411,10 @@ export class TaskforceCore {
2936
3411
  isArchived: Boolean(row.is_archived),
2937
3412
  comments,
2938
3413
  attachments,
3414
+ ...(includeActivity ? {
3415
+ activity,
3416
+ entityEvents,
3417
+ } : {}),
2939
3418
  };
2940
3419
  }
2941
3420
  rowToChecklistItem(row) {
@@ -2988,7 +3467,7 @@ export class TaskforceCore {
2988
3467
  `).get(this.tenantId, normalizedWorkspaceId, normalizedId);
2989
3468
  if (!row)
2990
3469
  return null;
2991
- return this.rowToInitiative(row);
3470
+ return this.rowToInitiative(row, { includeActivity: true });
2992
3471
  }
2993
3472
  listInitiatives(workspaceId) {
2994
3473
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -3000,7 +3479,45 @@ export class TaskforceCore {
3000
3479
  `).all(this.tenantId, normalizedWorkspaceId);
3001
3480
  return rows.map((row) => this.rowToInitiative(row));
3002
3481
  }
3003
- createInitiative(input, workspaceId) {
3482
+ isTransientPlanningHydrationError(error) {
3483
+ const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
3484
+ if (!message)
3485
+ return false;
3486
+ return /(timed out|timeout|ETIMEDOUT|query canceled|statement timeout|database timeout|postgres worker)/i.test(message);
3487
+ }
3488
+ getInitiativeForWriteResult(id, workspaceId, fallback, context) {
3489
+ try {
3490
+ return this.getInitiative(id, workspaceId) || fallback;
3491
+ }
3492
+ catch (error) {
3493
+ if (!this.isTransientPlanningHydrationError(error))
3494
+ throw error;
3495
+ console.warn('[Taskforce] Falling back to post-write initiative snapshot after hydration failed', {
3496
+ context,
3497
+ initiativeId: id,
3498
+ workspaceId,
3499
+ message: error instanceof Error ? error.message : String(error),
3500
+ });
3501
+ return fallback;
3502
+ }
3503
+ }
3504
+ getWorkstreamForWriteResult(id, workspaceId, fallback, context) {
3505
+ try {
3506
+ return this.getWorkstream(id, workspaceId) || fallback;
3507
+ }
3508
+ catch (error) {
3509
+ if (!this.isTransientPlanningHydrationError(error))
3510
+ throw error;
3511
+ console.warn('[Taskforce] Falling back to post-write workstream snapshot after hydration failed', {
3512
+ context,
3513
+ workstreamId: id,
3514
+ workspaceId,
3515
+ message: error instanceof Error ? error.message : String(error),
3516
+ });
3517
+ return fallback;
3518
+ }
3519
+ }
3520
+ createInitiative(input, workspaceId, options) {
3004
3521
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3005
3522
  const id = String(input.id || '').trim() || this.generateEntityId('initiative');
3006
3523
  const referenceNumber = this.reserveInitiativeReferenceNumber(normalizedWorkspaceId, input.referenceNumber);
@@ -3039,18 +3556,37 @@ export class TaskforceCore {
3039
3556
  sortOrder: initiative.order,
3040
3557
  isArchived: initiative.isArchived ? 1 : 0,
3041
3558
  });
3042
- return initiative;
3559
+ this.syncCanonicalPlanningAttachmentLinks('initiative', initiative.id, normalizedWorkspaceId, initiative.attachments || []);
3560
+ if (options?.skipEntityEvents !== true) {
3561
+ this.addEntityEvent({
3562
+ entityType: 'initiative',
3563
+ entityId: initiative.id,
3564
+ workspaceId: normalizedWorkspaceId,
3565
+ action: 'initiative-created',
3566
+ actor: options?.actorRef || 'system',
3567
+ createdAt: initiative.updatedAt || initiative.createdAt,
3568
+ details: {
3569
+ title: initiative.title,
3570
+ ownerId: initiative.ownerId || null,
3571
+ }
3572
+ });
3573
+ }
3574
+ return this.getInitiativeForWriteResult(initiative.id, normalizedWorkspaceId, initiative, 'createInitiative:post-write');
3043
3575
  }
3044
- updateInitiative(id, patch, workspaceId) {
3576
+ updateInitiative(id, patch, workspaceId, options) {
3045
3577
  const existing = this.getInitiative(id, workspaceId);
3046
3578
  if (!existing) {
3047
3579
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3048
3580
  }
3049
3581
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3582
+ const nextAttachments = Object.prototype.hasOwnProperty.call(patch, 'attachments') && Array.isArray(patch.attachments)
3583
+ ? patch.attachments
3584
+ : existing.attachments;
3050
3585
  const next = {
3051
3586
  ...existing,
3052
3587
  ...patch,
3053
3588
  id: existing.id,
3589
+ attachments: nextAttachments,
3054
3590
  updatedAt: String(patch.updatedAt || '').trim() || new Date().toISOString(),
3055
3591
  referenceLabel: formatInitiativeReference(patch.referenceNumber ?? existing.referenceNumber),
3056
3592
  };
@@ -3080,25 +3616,68 @@ export class TaskforceCore {
3080
3616
  sortOrder: next.order,
3081
3617
  isArchived: next.isArchived ? 1 : 0,
3082
3618
  });
3083
- return this.getInitiative(existing.id, normalizedWorkspaceId);
3619
+ this.syncCanonicalPlanningAttachmentLinks('initiative', existing.id, normalizedWorkspaceId, next.attachments || []);
3620
+ const hydrated = this.getInitiativeForWriteResult(existing.id, normalizedWorkspaceId, next, 'updateInitiative:post-write');
3621
+ if (options?.skipEntityEvents !== true) {
3622
+ const changes = this.buildPlanningEntityEventChanges(existing, next, ['title', 'description', 'ownerId', 'attachments']);
3623
+ if (Object.keys(changes).length > 0) {
3624
+ this.addEntityEvent({
3625
+ entityType: 'initiative',
3626
+ entityId: existing.id,
3627
+ workspaceId: normalizedWorkspaceId,
3628
+ action: this.getPlanningEntityEventAction('initiative', changes),
3629
+ actor: options?.actorRef || 'system',
3630
+ createdAt: next.updatedAt,
3631
+ details: { changes }
3632
+ });
3633
+ return this.getInitiativeForWriteResult(existing.id, normalizedWorkspaceId, hydrated, 'updateInitiative:post-event');
3634
+ }
3635
+ }
3636
+ return hydrated;
3084
3637
  }
3085
- archiveInitiative(id, workspaceId) {
3638
+ archiveInitiative(id, workspaceId, options) {
3086
3639
  const existing = this.getInitiative(id, workspaceId);
3087
3640
  if (!existing) {
3088
3641
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3089
3642
  }
3090
3643
  if (existing.isArchived)
3091
3644
  return existing;
3092
- return this.updateInitiative(id, { isArchived: true }, workspaceId);
3645
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3646
+ const archived = this.updateInitiative(id, { isArchived: true }, workspaceId, { ...options, skipEntityEvents: true });
3647
+ if (options?.skipEntityEvents !== true) {
3648
+ this.addEntityEvent({
3649
+ entityType: 'initiative',
3650
+ entityId: id,
3651
+ workspaceId: normalizedWorkspaceId,
3652
+ action: 'initiative-archived',
3653
+ actor: options?.actorRef || 'system',
3654
+ createdAt: archived.updatedAt || new Date().toISOString(),
3655
+ });
3656
+ return this.getInitiativeForWriteResult(id, normalizedWorkspaceId, archived, 'archiveInitiative:post-event');
3657
+ }
3658
+ return archived;
3093
3659
  }
3094
- unarchiveInitiative(id, workspaceId) {
3660
+ unarchiveInitiative(id, workspaceId, options) {
3095
3661
  const existing = this.getInitiative(id, workspaceId);
3096
3662
  if (!existing) {
3097
3663
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3098
3664
  }
3099
3665
  if (!existing.isArchived)
3100
3666
  return existing;
3101
- return this.updateInitiative(id, { isArchived: false }, workspaceId);
3667
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3668
+ const restored = this.updateInitiative(id, { isArchived: false }, workspaceId, { ...options, skipEntityEvents: true });
3669
+ if (options?.skipEntityEvents !== true) {
3670
+ this.addEntityEvent({
3671
+ entityType: 'initiative',
3672
+ entityId: id,
3673
+ workspaceId: normalizedWorkspaceId,
3674
+ action: 'initiative-unarchived',
3675
+ actor: options?.actorRef || 'system',
3676
+ createdAt: restored.updatedAt || new Date().toISOString(),
3677
+ });
3678
+ return this.getInitiativeForWriteResult(id, normalizedWorkspaceId, restored, 'unarchiveInitiative:post-event');
3679
+ }
3680
+ return restored;
3102
3681
  }
3103
3682
  getWorkstream(id, workspaceId) {
3104
3683
  const normalizedId = String(id || '').trim();
@@ -3113,7 +3692,7 @@ export class TaskforceCore {
3113
3692
  `).get(this.tenantId, normalizedWorkspaceId, normalizedId);
3114
3693
  if (!row)
3115
3694
  return null;
3116
- return this.rowToWorkstream(row);
3695
+ return this.rowToWorkstream(row, { includeActivity: true });
3117
3696
  }
3118
3697
  listWorkstreams(workspaceId) {
3119
3698
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -3125,7 +3704,64 @@ export class TaskforceCore {
3125
3704
  `).all(this.tenantId, normalizedWorkspaceId);
3126
3705
  return rows.map((row) => this.rowToWorkstream(row));
3127
3706
  }
3128
- createWorkstream(input, workspaceId) {
3707
+ listPlanningBootstrap(workspaceId, diagnostics) {
3708
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3709
+ const startedAt = Date.now();
3710
+ const initiatives = this.listInitiatives(workspaceId);
3711
+ const initiativesLoadedAt = Date.now();
3712
+ const workstreams = this.listWorkstreams(workspaceId);
3713
+ const workstreamsLoadedAt = Date.now();
3714
+ const planningTaskRows = this.db.prepare(`
3715
+ SELECT id, reference_number, title, status, workstream_id
3716
+ FROM tasks
3717
+ WHERE tenant_id = ? AND workspace_id = ? AND is_archived = 0
3718
+ AND workstream_id IS NOT NULL AND TRIM(workstream_id) <> ''
3719
+ ORDER BY created_at DESC
3720
+ `).all(this.tenantId, normalizedWorkspaceId);
3721
+ const taskRowsLoadedAt = Date.now();
3722
+ const workstreamTaskSummaries = new Map();
3723
+ for (const row of planningTaskRows) {
3724
+ const workstreamId = String(row.workstream_id || '').trim();
3725
+ if (!workstreamId)
3726
+ continue;
3727
+ const status = this.normalizeStatusValue(row.status);
3728
+ const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
3729
+ const current = workstreamTaskSummaries.get(workstreamId) || {
3730
+ workstreamId,
3731
+ taskCount: 0,
3732
+ completedTaskCount: 0,
3733
+ tasks: [],
3734
+ };
3735
+ current.taskCount += 1;
3736
+ if (status === 'done' || status === 'cancelled') {
3737
+ current.completedTaskCount += 1;
3738
+ }
3739
+ current.tasks.push({
3740
+ id: String(row.id || '').trim(),
3741
+ referenceNumber,
3742
+ title: String(row.title || '').trim() || 'Untitled Task',
3743
+ status,
3744
+ });
3745
+ workstreamTaskSummaries.set(workstreamId, current);
3746
+ }
3747
+ const summariesBuiltAt = Date.now();
3748
+ if (diagnostics) {
3749
+ diagnostics.initiativeCount = initiatives.length;
3750
+ diagnostics.workstreamCount = workstreams.length;
3751
+ diagnostics.workstreamLinkedTaskCount = planningTaskRows.length;
3752
+ diagnostics.workstreamTaskSummaryCount = workstreamTaskSummaries.size;
3753
+ diagnostics.initiativeLoadMs = Math.max(0, initiativesLoadedAt - startedAt);
3754
+ diagnostics.workstreamLoadMs = Math.max(0, workstreamsLoadedAt - initiativesLoadedAt);
3755
+ diagnostics.taskSummaryQueryMs = Math.max(0, taskRowsLoadedAt - workstreamsLoadedAt);
3756
+ diagnostics.taskSummaryBuildMs = Math.max(0, summariesBuiltAt - taskRowsLoadedAt);
3757
+ }
3758
+ return {
3759
+ initiatives,
3760
+ workstreams,
3761
+ workstreamTaskSummaries: Array.from(workstreamTaskSummaries.values()),
3762
+ };
3763
+ }
3764
+ createWorkstream(input, workspaceId, options) {
3129
3765
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3130
3766
  const id = String(input.id || '').trim() || this.generateEntityId('workstream');
3131
3767
  const referenceNumber = this.reserveWorkstreamReferenceNumber(normalizedWorkspaceId, input.referenceNumber);
@@ -3168,14 +3804,33 @@ export class TaskforceCore {
3168
3804
  sortOrder: workstream.order,
3169
3805
  isArchived: workstream.isArchived ? 1 : 0,
3170
3806
  });
3171
- return workstream;
3807
+ this.syncCanonicalPlanningAttachmentLinks('workstream', workstream.id, normalizedWorkspaceId, workstream.attachments || []);
3808
+ if (options?.skipEntityEvents !== true) {
3809
+ this.addEntityEvent({
3810
+ entityType: 'workstream',
3811
+ entityId: workstream.id,
3812
+ workspaceId: normalizedWorkspaceId,
3813
+ action: 'workstream-created',
3814
+ actor: options?.actorRef || 'system',
3815
+ createdAt: workstream.updatedAt || workstream.createdAt,
3816
+ details: {
3817
+ title: workstream.title,
3818
+ ownerId: workstream.ownerId || null,
3819
+ initiativeId: workstream.initiativeId || null,
3820
+ }
3821
+ });
3822
+ }
3823
+ return this.getWorkstreamForWriteResult(workstream.id, normalizedWorkspaceId, workstream, 'createWorkstream:post-write');
3172
3824
  }
3173
- updateWorkstream(id, patch, workspaceId) {
3825
+ updateWorkstream(id, patch, workspaceId, options) {
3174
3826
  const existing = this.getWorkstream(id, workspaceId);
3175
3827
  if (!existing) {
3176
3828
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3177
3829
  }
3178
3830
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3831
+ const nextAttachments = Object.prototype.hasOwnProperty.call(patch, 'attachments') && Array.isArray(patch.attachments)
3832
+ ? patch.attachments
3833
+ : existing.attachments;
3179
3834
  const next = {
3180
3835
  ...existing,
3181
3836
  ...patch,
@@ -3183,6 +3838,7 @@ export class TaskforceCore {
3183
3838
  initiativeId: Object.prototype.hasOwnProperty.call(patch, 'initiativeId')
3184
3839
  ? this.sanitizeInitiativeId(patch.initiativeId)
3185
3840
  : existing.initiativeId,
3841
+ attachments: nextAttachments,
3186
3842
  updatedAt: String(patch.updatedAt || '').trim() || new Date().toISOString(),
3187
3843
  referenceLabel: formatWorkstreamReference(patch.referenceNumber ?? existing.referenceNumber),
3188
3844
  };
@@ -3215,41 +3871,94 @@ export class TaskforceCore {
3215
3871
  sortOrder: next.order,
3216
3872
  isArchived: next.isArchived ? 1 : 0,
3217
3873
  });
3218
- return this.getWorkstream(existing.id, normalizedWorkspaceId);
3874
+ this.syncCanonicalPlanningAttachmentLinks('workstream', existing.id, normalizedWorkspaceId, next.attachments || []);
3875
+ const hydrated = this.getWorkstreamForWriteResult(existing.id, normalizedWorkspaceId, next, 'updateWorkstream:post-write');
3876
+ if (options?.skipEntityEvents !== true) {
3877
+ const changes = this.buildPlanningEntityEventChanges(existing, next, ['title', 'description', 'ownerId', 'initiativeId', 'attachments']);
3878
+ if (Object.keys(changes).length > 0) {
3879
+ this.addEntityEvent({
3880
+ entityType: 'workstream',
3881
+ entityId: existing.id,
3882
+ workspaceId: normalizedWorkspaceId,
3883
+ action: this.getPlanningEntityEventAction('workstream', changes),
3884
+ actor: options?.actorRef || 'system',
3885
+ createdAt: next.updatedAt,
3886
+ details: { changes }
3887
+ });
3888
+ return this.getWorkstreamForWriteResult(existing.id, normalizedWorkspaceId, hydrated, 'updateWorkstream:post-event');
3889
+ }
3890
+ }
3891
+ return hydrated;
3219
3892
  }
3220
- archiveWorkstream(id, workspaceId) {
3893
+ archiveWorkstream(id, workspaceId, options) {
3221
3894
  const existing = this.getWorkstream(id, workspaceId);
3222
3895
  if (!existing) {
3223
3896
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3224
3897
  }
3225
3898
  if (existing.isArchived)
3226
3899
  return existing;
3227
- return this.updateWorkstream(id, { isArchived: true }, workspaceId);
3900
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3901
+ const archived = this.updateWorkstream(id, { isArchived: true }, workspaceId, { ...options, skipEntityEvents: true });
3902
+ if (options?.skipEntityEvents !== true) {
3903
+ this.addEntityEvent({
3904
+ entityType: 'workstream',
3905
+ entityId: id,
3906
+ workspaceId: normalizedWorkspaceId,
3907
+ action: 'workstream-archived',
3908
+ actor: options?.actorRef || 'system',
3909
+ createdAt: archived.updatedAt || new Date().toISOString(),
3910
+ });
3911
+ return this.getWorkstreamForWriteResult(id, normalizedWorkspaceId, archived, 'archiveWorkstream:post-event');
3912
+ }
3913
+ return archived;
3228
3914
  }
3229
- unarchiveWorkstream(id, workspaceId) {
3915
+ unarchiveWorkstream(id, workspaceId, options) {
3230
3916
  const existing = this.getWorkstream(id, workspaceId);
3231
3917
  if (!existing) {
3232
3918
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3233
3919
  }
3234
3920
  if (!existing.isArchived)
3235
3921
  return existing;
3236
- return this.updateWorkstream(id, { isArchived: false }, workspaceId);
3922
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3923
+ const restored = this.updateWorkstream(id, { isArchived: false }, workspaceId, { ...options, skipEntityEvents: true });
3924
+ if (options?.skipEntityEvents !== true) {
3925
+ this.addEntityEvent({
3926
+ entityType: 'workstream',
3927
+ entityId: id,
3928
+ workspaceId: normalizedWorkspaceId,
3929
+ action: 'workstream-unarchived',
3930
+ actor: options?.actorRef || 'system',
3931
+ createdAt: restored.updatedAt || new Date().toISOString(),
3932
+ });
3933
+ return this.getWorkstreamForWriteResult(id, normalizedWorkspaceId, restored, 'unarchiveWorkstream:post-event');
3934
+ }
3935
+ return restored;
3237
3936
  }
3238
3937
  upsertInitiativeForSync(initiative, workspaceId) {
3239
3938
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3240
3939
  const existing = this.getInitiative(initiative.id, normalizedWorkspaceId);
3241
3940
  if (existing) {
3242
- return this.updateInitiative(existing.id, initiative, normalizedWorkspaceId);
3941
+ const existingUpdatedAtMs = Date.parse(String(existing.updatedAt || existing.createdAt || '').trim());
3942
+ const incomingUpdatedAtMs = Date.parse(String(initiative.updatedAt || initiative.createdAt || '').trim());
3943
+ if (Number.isFinite(existingUpdatedAtMs) && Number.isFinite(incomingUpdatedAtMs) && incomingUpdatedAtMs < existingUpdatedAtMs) {
3944
+ return existing;
3945
+ }
3946
+ return this.updateInitiative(existing.id, initiative, normalizedWorkspaceId, { skipEntityEvents: true });
3243
3947
  }
3244
- return this.createInitiative(initiative, normalizedWorkspaceId);
3948
+ return this.createInitiative(initiative, normalizedWorkspaceId, { skipEntityEvents: true });
3245
3949
  }
3246
3950
  upsertWorkstreamForSync(workstream, workspaceId) {
3247
3951
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3248
3952
  const existing = this.getWorkstream(workstream.id, normalizedWorkspaceId);
3249
3953
  if (existing) {
3250
- return this.updateWorkstream(existing.id, workstream, normalizedWorkspaceId);
3954
+ const existingUpdatedAtMs = Date.parse(String(existing.updatedAt || existing.createdAt || '').trim());
3955
+ const incomingUpdatedAtMs = Date.parse(String(workstream.updatedAt || workstream.createdAt || '').trim());
3956
+ if (Number.isFinite(existingUpdatedAtMs) && Number.isFinite(incomingUpdatedAtMs) && incomingUpdatedAtMs < existingUpdatedAtMs) {
3957
+ return existing;
3958
+ }
3959
+ return this.updateWorkstream(existing.id, workstream, normalizedWorkspaceId, { skipEntityEvents: true });
3251
3960
  }
3252
- return this.createWorkstream(workstream, normalizedWorkspaceId);
3961
+ return this.createWorkstream(workstream, normalizedWorkspaceId, { skipEntityEvents: true });
3253
3962
  }
3254
3963
  listChecklistItems(taskId, workspaceId) {
3255
3964
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -3899,7 +4608,7 @@ export class TaskforceCore {
3899
4608
  listTasks(workspaceId) {
3900
4609
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3901
4610
  const rows = this.db.prepare(`
3902
- SELECT * FROM tasks
4611
+ SELECT * FROM tasks
3903
4612
  WHERE tenant_id = ?
3904
4613
  AND workspace_id = ?
3905
4614
  AND is_archived = 0
@@ -3910,30 +4619,136 @@ export class TaskforceCore {
3910
4619
  config: this.readConfig()
3911
4620
  };
3912
4621
  }
3913
- getDataVersion() {
3914
- if (this.db.provider === 'sqlite') {
3915
- const version = this.db.pragma('data_version', { simple: true });
3916
- return Number.isFinite(version) ? version : 0;
4622
+ /**
4623
+ * Filter-pushdown listing used by read-only surfaces that don't need the
4624
+ * full task graph (MCP list_tasks, CLI summaries). Avoids the per-task
4625
+ * comments/events/attachments/checklist subqueries and actor resolution
4626
+ * that rowToTask performs, and applies filters + sort + limit at the SQL
4627
+ * layer so hot polls don't load the entire workspace.
4628
+ */
4629
+ listTasksSlim(workspaceId, filters = {}) {
4630
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
4631
+ const clauses = [
4632
+ 'tenant_id = ?',
4633
+ 'workspace_id = ?',
4634
+ 'is_archived = 0',
4635
+ ];
4636
+ const params = [this.tenantId, normalizedWorkspaceId];
4637
+ if (filters.category) {
4638
+ clauses.push('category = ?');
4639
+ params.push(filters.category);
3917
4640
  }
3918
- // Fallback signal for non-SQLite backends until provider-specific versioning is implemented.
3919
- return 0;
3920
- }
3921
- getDatabaseProvider() {
3922
- return this.db.provider;
3923
- }
3924
- isDatabaseHealthy() {
3925
- try {
3926
- const row = this.db.prepare('SELECT 1 as ok').get();
3927
- return row?.ok === 1 || row?.ok === undefined;
4641
+ if (filters.status) {
4642
+ clauses.push('status = ?');
4643
+ params.push(filters.status);
3928
4644
  }
3929
- catch {
3930
- return false;
4645
+ else if (filters.openOnly) {
4646
+ clauses.push("status IN ('task','on-hold','in-progress','review')");
3931
4647
  }
3932
- }
3933
- listArchive(workspaceId) {
3934
- const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3935
- const rows = this.db.prepare(`
3936
- SELECT * FROM tasks
4648
+ if (filters.priority !== undefined) {
4649
+ clauses.push('priority = ?');
4650
+ params.push(Number(filters.priority));
4651
+ }
4652
+ if (filters.type) {
4653
+ clauses.push('type = ?');
4654
+ params.push(filters.type);
4655
+ }
4656
+ if (filters.assignee) {
4657
+ if (filters.assignee === 'unassigned') {
4658
+ clauses.push("(assignee IS NULL OR assignee = '' OR assignee = 'unassigned')");
4659
+ }
4660
+ else {
4661
+ clauses.push('assignee = ?');
4662
+ params.push(filters.assignee);
4663
+ }
4664
+ }
4665
+ const order = filters.order === 'asc' ? 'ASC' : 'DESC';
4666
+ const sort = filters.sort ?? 'created';
4667
+ let orderBy;
4668
+ if (sort === 'priority') {
4669
+ orderBy = `priority ${order}, created_at ${order}`;
4670
+ }
4671
+ else if (sort === 'updated') {
4672
+ orderBy = `COALESCE(updated_at, created_at) ${order}`;
4673
+ }
4674
+ else {
4675
+ orderBy = `created_at ${order}`;
4676
+ }
4677
+ let sql = `SELECT * FROM tasks WHERE ${clauses.join(' AND ')} ORDER BY ${orderBy}`;
4678
+ const rawLimit = Number(filters.limit);
4679
+ const limit = Number.isFinite(rawLimit) ? Math.max(0, Math.floor(rawLimit)) : 0;
4680
+ if (limit > 0) {
4681
+ sql += ' LIMIT ?';
4682
+ params.push(limit);
4683
+ }
4684
+ const rows = this.db.prepare(sql).all(...params);
4685
+ return rows.map((row) => {
4686
+ const sanitizedAssignee = this.sanitizeAssignee(row.assignee, normalizedWorkspaceId);
4687
+ const scheduledDate = this.sanitizeDateOnly(row.scheduled_date);
4688
+ const dueDate = this.sanitizeDateOnly(row.due_date);
4689
+ const scheduledWeekKey = row.scheduled_week_key
4690
+ || (scheduledDate ? this.dateOnlyToIsoWeekKey(scheduledDate) : null);
4691
+ const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
4692
+ const localReferenceNumber = this.sanitizeTaskReferenceNumber(row.local_reference_number);
4693
+ let taxonomies = {};
4694
+ try {
4695
+ taxonomies = JSON.parse(row.taxonomies || '{}');
4696
+ }
4697
+ catch {
4698
+ taxonomies = {};
4699
+ }
4700
+ return {
4701
+ id: String(row.id || '').trim(),
4702
+ referenceNumber,
4703
+ localReferenceNumber,
4704
+ referenceLabel: deriveTaskReferenceLabel({ referenceNumber, localReferenceNumber }),
4705
+ title: row.title,
4706
+ status: this.normalizeStatusValue(row.status),
4707
+ priority: this.normalizePriorityValue(row.priority),
4708
+ complexity: row.complexity,
4709
+ type: row.type,
4710
+ category: row.category,
4711
+ approach: row.approach || undefined,
4712
+ createdAt: row.created_at,
4713
+ updatedAt: row.updated_at,
4714
+ completedAt: row.completed_at,
4715
+ canceledReason: row.canceled_reason,
4716
+ isArchived: row.is_archived === 1,
4717
+ scheduledDate,
4718
+ dueDate,
4719
+ scheduledWeekKey,
4720
+ orderInDay: this.sanitizeOrderInDay(row.order_in_day),
4721
+ workstreamId: this.sanitizeWorkstreamId(row.workstream_id),
4722
+ taxonomies,
4723
+ createdBy: this.normalizeCreatedBy(row.created_by, normalizedWorkspaceId, { preserveGenericAi: true }),
4724
+ assignee: sanitizedAssignee,
4725
+ };
4726
+ });
4727
+ }
4728
+ getDataVersion() {
4729
+ if (this.db.provider === 'sqlite') {
4730
+ const version = this.db.pragma('data_version', { simple: true });
4731
+ return Number.isFinite(version) ? version : 0;
4732
+ }
4733
+ // Fallback signal for non-SQLite backends until provider-specific versioning is implemented.
4734
+ return 0;
4735
+ }
4736
+ getDatabaseProvider() {
4737
+ return this.db.provider;
4738
+ }
4739
+ isDatabaseHealthy() {
4740
+ try {
4741
+ const row = this.db.prepare('SELECT 1 as ok').get();
4742
+ return row?.ok === 1 || row?.ok === undefined;
4743
+ }
4744
+ catch {
4745
+ return false;
4746
+ }
4747
+ }
4748
+ listArchive(workspaceId) {
4749
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
4750
+ const rows = this.db.prepare(`
4751
+ SELECT * FROM tasks
3937
4752
  WHERE tenant_id = ?
3938
4753
  AND workspace_id = ?
3939
4754
  AND is_archived = 1
@@ -4050,9 +4865,9 @@ export class TaskforceCore {
4050
4865
  `).get(this.tenantId, userId);
4051
4866
  if (!existingUser) {
4052
4867
  this.db.prepare(`
4053
- INSERT INTO users (id, tenant_id, email, display_name, auth_provider, external_auth_id, beta_access, is_disabled, created_at, updated_at)
4054
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4055
- `).run(userId, this.tenantId, email, displayName, 'token', userId, 1, 0, now, now);
4868
+ INSERT INTO users (id, tenant_id, email, display_name, beta_access, is_disabled, created_at, updated_at)
4869
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4870
+ `).run(userId, this.tenantId, email, displayName, 1, 0, now, now);
4056
4871
  }
4057
4872
  const membership = this.db.prepare(`
4058
4873
  SELECT id, role FROM workspace_memberships
@@ -4097,16 +4912,17 @@ export class TaskforceCore {
4097
4912
  `).get(this.tenantId, normalizedUserId, normalizedWorkspaceId);
4098
4913
  if (!membership?.workspace_id)
4099
4914
  return null;
4100
- const disabled = Boolean(userRow.is_disabled);
4915
+ const accountDisabled = Boolean(userRow.is_disabled);
4101
4916
  const userBetaAccess = userRow.beta_access === undefined || userRow.beta_access === null
4102
4917
  ? true
4103
4918
  : Boolean(userRow.beta_access);
4104
4919
  const isSystemAdmin = this.normalizeSystemRole(userRow.system_role, 'user') === 'system_admin';
4105
4920
  const membershipActive = !membership.status || String(membership.status).toLowerCase() === 'active';
4921
+ const workspaceDisabled = !membershipActive;
4106
4922
  const baseRole = this.normalizeAccessRole(membership.role || fallbackRole, fallbackRole);
4107
4923
  const permissionMode = this.normalizePermissionMode(membership.permission_mode, 'read-write');
4108
4924
  const isMemberReadOnly = baseRole === 'member' && permissionMode === 'read-only';
4109
- const resolvedRole = (disabled || !membershipActive || isMemberReadOnly)
4925
+ const resolvedRole = (accountDisabled || workspaceDisabled || isMemberReadOnly)
4110
4926
  ? 'read-only'
4111
4927
  : baseRole;
4112
4928
  return {
@@ -4114,7 +4930,9 @@ export class TaskforceCore {
4114
4930
  workspaceId: String(membership.workspace_id || normalizedWorkspaceId),
4115
4931
  role: resolvedRole,
4116
4932
  permissionMode,
4117
- disabled,
4933
+ disabled: accountDisabled || workspaceDisabled,
4934
+ accountDisabled,
4935
+ workspaceDisabled,
4118
4936
  betaAccess: userBetaAccess || isSystemAdmin
4119
4937
  };
4120
4938
  }
@@ -4292,6 +5110,91 @@ export class TaskforceCore {
4292
5110
  `).run(now, now, this.tenantId, normalizedUserId, cutoff);
4293
5111
  return staleDrafts;
4294
5112
  }
5113
+ createTaskAttachmentUploadDraft(input) {
5114
+ const draftId = String(input.draftId || '').trim();
5115
+ const workspaceId = String(input.workspaceId || '').trim();
5116
+ const taskId = String(input.taskId || '').trim();
5117
+ const actorRef = String(input.actorRef || '').trim() || 'system';
5118
+ const storageKey = String(input.storageKey || '').trim();
5119
+ const mimeType = String(input.mimeType || 'application/octet-stream').trim() || 'application/octet-stream';
5120
+ const originalFilename = String(input.originalFilename || '').trim() || 'attachment';
5121
+ const sizeBytes = Number.isFinite(Number(input.sizeBytes)) ? Math.max(0, Math.floor(Number(input.sizeBytes))) : 0;
5122
+ const storageProvider = input.storageProvider === 'r2' || input.storageProvider === 'external' ? input.storageProvider : 'local';
5123
+ if (!draftId)
5124
+ throw new TaskforceRuleError('draftId is required', 400, 'TASK_ATTACHMENT_DRAFT_ID_REQUIRED');
5125
+ if (!workspaceId)
5126
+ throw new TaskforceRuleError('workspaceId is required', 400, 'WORKSPACE_ID_REQUIRED');
5127
+ if (!taskId)
5128
+ throw new TaskforceRuleError('taskId is required', 400, 'TASK_ID_REQUIRED');
5129
+ if (!storageKey)
5130
+ throw new TaskforceRuleError('storageKey is required', 400, 'TASK_ATTACHMENT_STORAGE_KEY_REQUIRED');
5131
+ const now = new Date().toISOString();
5132
+ this.db.prepare(`
5133
+ INSERT INTO task_attachment_upload_drafts (
5134
+ draft_id, tenant_id, workspace_id, task_id, actor_ref, storage_key, mime_type, original_filename,
5135
+ size_bytes, storage_provider, consumed_at, discarded_at, created_at, updated_at
5136
+ )
5137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
5138
+ `).run(draftId, this.tenantId, workspaceId, taskId, actorRef, storageKey, mimeType, originalFilename, sizeBytes, storageProvider, now, now);
5139
+ return this.getTaskAttachmentUploadDraft(draftId);
5140
+ }
5141
+ getTaskAttachmentUploadDraft(draftId) {
5142
+ const normalizedDraftId = String(draftId || '').trim();
5143
+ if (!normalizedDraftId)
5144
+ return null;
5145
+ const row = this.db.prepare(`
5146
+ SELECT draft_id, workspace_id, task_id, actor_ref, storage_key, mime_type, original_filename,
5147
+ size_bytes, storage_provider, consumed_at, discarded_at, created_at, updated_at
5148
+ FROM task_attachment_upload_drafts
5149
+ WHERE tenant_id = ? AND draft_id = ?
5150
+ LIMIT 1
5151
+ `).get(this.tenantId, normalizedDraftId);
5152
+ if (!row?.draft_id)
5153
+ return null;
5154
+ return {
5155
+ draftId: String(row.draft_id),
5156
+ workspaceId: String(row.workspace_id || ''),
5157
+ taskId: String(row.task_id || ''),
5158
+ actorRef: String(row.actor_ref || 'system'),
5159
+ storageKey: String(row.storage_key || ''),
5160
+ mimeType: String(row.mime_type || 'application/octet-stream'),
5161
+ originalFilename: String(row.original_filename || 'attachment'),
5162
+ sizeBytes: Number(row.size_bytes || 0),
5163
+ storageProvider: String(row.storage_provider || 'local') === 'r2'
5164
+ ? 'r2'
5165
+ : String(row.storage_provider || 'local') === 'external'
5166
+ ? 'external'
5167
+ : 'local',
5168
+ consumedAt: row.consumed_at ? String(row.consumed_at) : null,
5169
+ discardedAt: row.discarded_at ? String(row.discarded_at) : null,
5170
+ createdAt: String(row.created_at || ''),
5171
+ updatedAt: String(row.updated_at || ''),
5172
+ };
5173
+ }
5174
+ markTaskAttachmentUploadDraftConsumed(draftId) {
5175
+ const existing = this.getTaskAttachmentUploadDraft(draftId);
5176
+ if (!existing || existing.discardedAt)
5177
+ return null;
5178
+ const now = new Date().toISOString();
5179
+ this.db.prepare(`
5180
+ UPDATE task_attachment_upload_drafts
5181
+ SET consumed_at = COALESCE(consumed_at, ?), updated_at = ?
5182
+ WHERE tenant_id = ? AND draft_id = ? AND discarded_at IS NULL
5183
+ `).run(now, now, this.tenantId, existing.draftId);
5184
+ return this.getTaskAttachmentUploadDraft(existing.draftId);
5185
+ }
5186
+ discardTaskAttachmentUploadDraft(draftId) {
5187
+ const existing = this.getTaskAttachmentUploadDraft(draftId);
5188
+ if (!existing || existing.discardedAt)
5189
+ return existing;
5190
+ const now = new Date().toISOString();
5191
+ this.db.prepare(`
5192
+ UPDATE task_attachment_upload_drafts
5193
+ SET discarded_at = ?, updated_at = ?
5194
+ WHERE tenant_id = ? AND draft_id = ? AND discarded_at IS NULL
5195
+ `).run(now, now, this.tenantId, existing.draftId);
5196
+ return this.getTaskAttachmentUploadDraft(existing.draftId);
5197
+ }
4295
5198
  getSystemRole(userId) {
4296
5199
  const normalizedUserId = String(userId || '').trim();
4297
5200
  if (!normalizedUserId || normalizedUserId === 'anonymous')
@@ -4333,7 +5236,68 @@ export class TaskforceCore {
4333
5236
  hasAnySystemAdmin() {
4334
5237
  return this.countSystemAdmins() > 0;
4335
5238
  }
4336
- listSystemUsers() {
5239
+ countSystemUsers(input) {
5240
+ const query = String(input?.query || '').trim().toLowerCase();
5241
+ const queryPattern = query ? `%${query}%` : '';
5242
+ const searchClause = query
5243
+ ? `
5244
+ AND (
5245
+ LOWER(COALESCE(u.display_name, '')) LIKE ?
5246
+ OR LOWER(COALESCE(u.id, '')) LIKE ?
5247
+ OR LOWER(COALESCE(u.email, '')) LIKE ?
5248
+ OR LOWER(COALESCE(COALESCE(p.display_name, v.plan_id), '')) LIKE ?
5249
+ OR LOWER(COALESCE(v.plan_id, '')) LIKE ?
5250
+ OR LOWER(COALESCE(e.entitlement_state, '')) LIKE ?
5251
+ OR LOWER(COALESCE(u.system_role, 'user')) LIKE ?
5252
+ )`
5253
+ : '';
5254
+ const params = [this.tenantId];
5255
+ if (query) {
5256
+ params.push(queryPattern, queryPattern, queryPattern, queryPattern, queryPattern, queryPattern, queryPattern);
5257
+ }
5258
+ const row = this.db.prepare(`
5259
+ SELECT COUNT(*) AS count
5260
+ FROM users u
5261
+ LEFT JOIN account_entitlements e
5262
+ ON e.tenant_id = u.tenant_id
5263
+ AND e.account_id = u.id
5264
+ LEFT JOIN plan_versions v
5265
+ ON v.tenant_id = u.tenant_id
5266
+ AND v.plan_version_id = e.plan_version_id
5267
+ LEFT JOIN plan_catalog p
5268
+ ON p.tenant_id = u.tenant_id
5269
+ AND p.plan_id = v.plan_id
5270
+ WHERE u.tenant_id = ?
5271
+ ${searchClause}
5272
+ `).get(...params);
5273
+ return Number(row?.count || 0);
5274
+ }
5275
+ listSystemUsers(input) {
5276
+ const query = String(input?.query || '').trim().toLowerCase();
5277
+ const queryPattern = query ? `%${query}%` : '';
5278
+ const sort = input?.sort === 'oldest' ? 'oldest' : 'newest';
5279
+ const limit = Number.isFinite(Number(input?.limit)) ? Math.max(1, Math.min(200, Math.floor(Number(input?.limit)))) : 50;
5280
+ const offset = Number.isFinite(Number(input?.offset)) ? Math.max(0, Math.floor(Number(input?.offset))) : 0;
5281
+ const orderByClause = sort === 'oldest'
5282
+ ? `ORDER BY COALESCE(u.created_at, u.updated_at) ASC, COALESCE(u.updated_at, u.created_at) ASC, u.id ASC`
5283
+ : `ORDER BY COALESCE(u.created_at, u.updated_at) DESC, COALESCE(u.updated_at, u.created_at) DESC, u.id DESC`;
5284
+ const searchClause = query
5285
+ ? `
5286
+ AND (
5287
+ LOWER(COALESCE(u.display_name, '')) LIKE ?
5288
+ OR LOWER(COALESCE(u.id, '')) LIKE ?
5289
+ OR LOWER(COALESCE(u.email, '')) LIKE ?
5290
+ OR LOWER(COALESCE(COALESCE(p.display_name, v.plan_id), '')) LIKE ?
5291
+ OR LOWER(COALESCE(v.plan_id, '')) LIKE ?
5292
+ OR LOWER(COALESCE(e.entitlement_state, '')) LIKE ?
5293
+ OR LOWER(COALESCE(u.system_role, 'user')) LIKE ?
5294
+ )`
5295
+ : '';
5296
+ const params = [this.tenantId];
5297
+ if (query) {
5298
+ params.push(queryPattern, queryPattern, queryPattern, queryPattern, queryPattern, queryPattern, queryPattern);
5299
+ }
5300
+ params.push(limit, offset);
4337
5301
  const rows = this.db.prepare(`
4338
5302
  SELECT
4339
5303
  u.id AS user_id,
@@ -4360,8 +5324,10 @@ export class TaskforceCore {
4360
5324
  ON p.tenant_id = u.tenant_id
4361
5325
  AND p.plan_id = v.plan_id
4362
5326
  WHERE u.tenant_id = ?
4363
- ORDER BY COALESCE(u.updated_at, u.created_at) DESC, u.created_at DESC
4364
- `).all(this.tenantId);
5327
+ ${searchClause}
5328
+ ${orderByClause}
5329
+ LIMIT ? OFFSET ?
5330
+ `).all(...params);
4365
5331
  return rows
4366
5332
  .filter((row) => row.user_id && row.email)
4367
5333
  .map((row) => ({
@@ -4653,6 +5619,7 @@ export class TaskforceCore {
4653
5619
  u.id AS user_id,
4654
5620
  u.email AS email,
4655
5621
  u.display_name AS display_name,
5622
+ u.avatar_url AS avatar_url,
4656
5623
  u.is_disabled AS is_disabled,
4657
5624
  u.created_at AS created_at,
4658
5625
  u.updated_at AS updated_at,
@@ -4669,19 +5636,23 @@ export class TaskforceCore {
4669
5636
  AND m.workspace_id = ?
4670
5637
  ORDER BY COALESCE(m.updated_at, m.created_at) DESC, u.created_at DESC
4671
5638
  `).all(this.tenantId, normalizedWorkspaceId);
4672
- return rows.map((row) => ({
4673
- userId: row.user_id,
4674
- email: row.email,
4675
- displayName: row.display_name || null,
4676
- role: this.normalizeAccessRole(row.role || 'member', 'member'),
4677
- permissionMode: this.normalizePermissionMode(row.permission_mode, 'read-write'),
4678
- betaAccess: row.beta_access === undefined || row.beta_access === null ? true : Boolean(row.beta_access),
4679
- status: String(row.status || 'active'),
4680
- disabled: Boolean(row.is_disabled),
4681
- createdAt: row.created_at || null,
4682
- updatedAt: row.updated_at || null,
4683
- emailVerifiedAt: row.email_verified_at || null
4684
- }));
5639
+ return rows.map((row) => {
5640
+ const status = String(row.status || 'active');
5641
+ return {
5642
+ userId: row.user_id,
5643
+ email: row.email,
5644
+ displayName: row.display_name || null,
5645
+ avatarUrl: row.avatar_url || null,
5646
+ role: this.normalizeAccessRole(row.role || 'member', 'member'),
5647
+ permissionMode: this.normalizePermissionMode(row.permission_mode, 'read-write'),
5648
+ betaAccess: row.beta_access === undefined || row.beta_access === null ? true : Boolean(row.beta_access),
5649
+ status,
5650
+ disabled: Boolean(row.is_disabled) || status.toLowerCase() === 'disabled',
5651
+ createdAt: row.created_at || null,
5652
+ updatedAt: row.updated_at || null,
5653
+ emailVerifiedAt: row.email_verified_at || null
5654
+ };
5655
+ });
4685
5656
  }
4686
5657
  replaceWorkspaceUsersForSync(input) {
4687
5658
  const workspaceId = String(input.workspaceId || 'default').trim() || 'default';
@@ -4725,9 +5696,9 @@ export class TaskforceCore {
4725
5696
  }
4726
5697
  else {
4727
5698
  this.db.prepare(`
4728
- INSERT INTO users (id, tenant_id, email, display_name, auth_provider, external_auth_id, beta_access, is_disabled, created_at, updated_at, email_verified_at)
4729
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4730
- `).run(userId, this.tenantId, email, displayName, 'sync', userId, betaAccess ? 1 : 0, disabled ? 1 : 0, createdAt, updatedAt, emailVerifiedAt);
5699
+ INSERT INTO users (id, tenant_id, email, display_name, beta_access, is_disabled, created_at, updated_at, email_verified_at)
5700
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5701
+ `).run(userId, this.tenantId, email, displayName, betaAccess ? 1 : 0, disabled ? 1 : 0, createdAt, updatedAt, emailVerifiedAt);
4731
5702
  }
4732
5703
  const membership = this.db.prepare(`
4733
5704
  SELECT id
@@ -4770,8 +5741,8 @@ export class TaskforceCore {
4770
5741
  tx();
4771
5742
  return this.listWorkspaceUsers(workspaceId);
4772
5743
  }
4773
- listAiProfiles(workspaceId) {
4774
- return listAiProfilesService(this.getAiProfileServiceDeps(), workspaceId);
5744
+ listAiProfiles(workspaceId, options) {
5745
+ return listAiProfilesService(this.getAiProfileServiceDeps(), workspaceId, options);
4775
5746
  }
4776
5747
  getAiProfileByToken(input) {
4777
5748
  return getAiProfileByTokenService(this.getAiProfileServiceDeps(), input);
@@ -4782,8 +5753,17 @@ export class TaskforceCore {
4782
5753
  mergeAiProfiles(input) {
4783
5754
  return mergeAiProfilesService(this.getAiProfileServiceDeps(), input);
4784
5755
  }
4785
- resolveAiProfile(input) {
4786
- return resolveAiProfileService(this.getAiProfileServiceDeps(), input);
5756
+ archiveAiProfile(input) {
5757
+ return archiveAiProfileService(this.getAiProfileServiceDeps(), input).profile;
5758
+ }
5759
+ updateAiProfileAvatar(input) {
5760
+ return updateAiProfileAvatarService(this.getAiProfileServiceDeps(), input);
5761
+ }
5762
+ updateAiProfileSurfaceType(input) {
5763
+ return updateAiProfileSurfaceTypeService(this.getAiProfileServiceDeps(), input);
5764
+ }
5765
+ resolveAiProfile(input, options) {
5766
+ return resolveAiProfileService(this.getAiProfileServiceDeps(), input, options);
4787
5767
  }
4788
5768
  listAnnotatedAttachmentSessions(input) {
4789
5769
  return listAnnotatedAttachmentSessionsService(this.getAnnotatedAttachmentServiceDeps(), input);
@@ -4813,17 +5793,30 @@ export class TaskforceCore {
4813
5793
  const activeSessions = deps.sessionStore.listByWorkspace('default', { includeDeleted: false });
4814
5794
  if (activeSessions.length === 0)
4815
5795
  return;
4816
- const hasActiveImageLink = this.db.prepare(`
4817
- SELECT 1
4818
- FROM workspace_asset_links
4819
- WHERE tenant_id = ?
4820
- AND workspace_id = ?
4821
- AND task_id = ?
4822
- AND asset_id = ?
4823
- AND role = 'image'
4824
- AND deleted_at IS NULL
4825
- LIMIT 1
4826
- `);
5796
+ const hasActiveImageLink = this.supportsWorkspaceAssetLinkTargetType()
5797
+ ? this.db.prepare(`
5798
+ SELECT 1
5799
+ FROM workspace_asset_links
5800
+ WHERE tenant_id = ?
5801
+ AND workspace_id = ?
5802
+ AND target_type = 'task'
5803
+ AND task_id = ?
5804
+ AND asset_id = ?
5805
+ AND role = 'image'
5806
+ AND deleted_at IS NULL
5807
+ LIMIT 1
5808
+ `)
5809
+ : this.db.prepare(`
5810
+ SELECT 1
5811
+ FROM workspace_asset_links
5812
+ WHERE tenant_id = ?
5813
+ AND workspace_id = ?
5814
+ AND task_id = ?
5815
+ AND asset_id = ?
5816
+ AND role = 'image'
5817
+ AND deleted_at IS NULL
5818
+ LIMIT 1
5819
+ `);
4827
5820
  for (const session of activeSessions) {
4828
5821
  const taskId = String(session.taskId || '').trim();
4829
5822
  const imageAssetId = String(session.baseImageAssetId || '').trim();
@@ -4909,6 +5902,9 @@ export class TaskforceCore {
4909
5902
  upsertTaskEventForSync(event) {
4910
5903
  return upsertTaskEventForSyncService(this.getTaskActivityServiceDeps(), event);
4911
5904
  }
5905
+ upsertEntityEventForSync(event) {
5906
+ return upsertEntityEventForSyncService(this.getTaskActivityServiceDeps(), event);
5907
+ }
4912
5908
  deleteDocumentReviewSessionForSync(input) {
4913
5909
  const deps = this.getDocumentReviewServiceDeps();
4914
5910
  return deps.sessionStore.softDelete(input.sessionId, deps.normalizeWorkspaceId(input.workspaceId), input.deletedAt || undefined);
@@ -4976,21 +5972,9 @@ export class TaskforceCore {
4976
5972
  if (aiProfile) {
4977
5973
  return this.aiProfileToActor(this.aiProfileRowToRecord(aiProfile));
4978
5974
  }
4979
- // Name-based fallback: agents may use their display name instead of profile ID
4980
- const aiProfileByName = this.db.prepare(`
4981
- SELECT *
4982
- FROM ai_profiles
4983
- WHERE tenant_id = ?
4984
- AND workspace_id = ?
4985
- AND (
4986
- LOWER(name) = LOWER(?)
4987
- OR LOWER(username) = LOWER(?)
4988
- )
4989
- ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
4990
- LIMIT 1
4991
- `).get(this.tenantId, normalizedWorkspaceId, raw, raw);
4992
- if (aiProfileByName) {
4993
- return this.aiProfileToActor(this.aiProfileRowToRecord(aiProfileByName));
5975
+ const uniqueAiProfileByAlias = this.resolveUniqueAiProfileActorByAlias(normalizedWorkspaceId, raw);
5976
+ if (uniqueAiProfileByAlias) {
5977
+ return uniqueAiProfileByAlias;
4994
5978
  }
4995
5979
  const workspaceUser = this.listWorkspaceUsers(normalizedWorkspaceId).find((user) => user.userId === raw);
4996
5980
  if (workspaceUser) {
@@ -5235,6 +6219,9 @@ export class TaskforceCore {
5235
6219
  listPlanFeatureCatalog() {
5236
6220
  return this.planEntitlementService.listPlanFeatureCatalog();
5237
6221
  }
6222
+ updatePlanFeatureCatalogMarketingCopy(input) {
6223
+ return this.planEntitlementService.updatePlanFeatureCatalogMarketingCopy(input);
6224
+ }
5238
6225
  listPlanVersions(planId) {
5239
6226
  return this.planEntitlementService.listPlanVersions(planId);
5240
6227
  }
@@ -5354,11 +6341,6 @@ export class TaskforceCore {
5354
6341
  }
5355
6342
  }
5356
6343
  const now = new Date().toISOString();
5357
- this.db.prepare(`
5358
- UPDATE users
5359
- SET is_disabled = ?, updated_at = ?
5360
- WHERE tenant_id = ? AND id = ?
5361
- `).run(disabled ? 1 : 0, now, this.tenantId, normalizedUserId);
5362
6344
  if (membership?.id) {
5363
6345
  this.db.prepare(`
5364
6346
  UPDATE workspace_memberships
@@ -5532,9 +6514,7 @@ export class TaskforceCore {
5532
6514
  if (!email || !email.includes('@')) {
5533
6515
  throw new TaskforceRuleError('Valid email is required', 400, 'EMAIL_REQUIRED');
5534
6516
  }
5535
- if (!password || password.length < 8) {
5536
- throw new TaskforceRuleError('Password must be at least 8 characters', 400, 'PASSWORD_TOO_SHORT');
5537
- }
6517
+ assertPasswordPolicy(password, { email, displayName: input.displayName || null });
5538
6518
  if (!workspaceId || workspaceId.toLowerCase() === 'default') {
5539
6519
  throw new TaskforceRuleError('A non-default workspaceId is required', 400, 'WORKSPACE_ID_REQUIRED');
5540
6520
  }
@@ -5551,20 +6531,16 @@ export class TaskforceCore {
5551
6531
  email,
5552
6532
  displayName
5553
6533
  });
5554
- if (emailVerified) {
5555
- this.db.prepare(`
5556
- UPDATE users
5557
- SET password_hash = ?, auth_provider = ?, external_auth_id = ?, email_verified_at = ?, beta_access = ?, is_disabled = 0, updated_at = ?
5558
- WHERE tenant_id = ? AND id = ?
5559
- `).run(hashPassword(password), 'password', email, now, betaAccess ? 1 : 0, now, this.tenantId, userId);
5560
- }
5561
- else {
5562
- this.db.prepare(`
5563
- UPDATE users
5564
- SET password_hash = ?, auth_provider = ?, external_auth_id = ?, beta_access = ?, is_disabled = 0, updated_at = ?
5565
- WHERE tenant_id = ? AND id = ?
5566
- `).run(hashPassword(password), 'password', email, betaAccess ? 1 : 0, now, this.tenantId, userId);
5567
- }
6534
+ this.db.prepare(`
6535
+ UPDATE users
6536
+ SET
6537
+ email_verified_at = CASE WHEN ? = 1 THEN COALESCE(email_verified_at, ?) ELSE email_verified_at END,
6538
+ beta_access = ?,
6539
+ is_disabled = 0,
6540
+ updated_at = ?
6541
+ WHERE tenant_id = ? AND id = ?
6542
+ `).run(emailVerified ? 1 : 0, now, betaAccess ? 1 : 0, now, this.tenantId, userId);
6543
+ this.identityService.setPasswordIdentity(userId, password);
5568
6544
  this.updateWorkspaceUserRole(userId, workspaceId, role);
5569
6545
  this.setWorkspaceUserDisabled(userId, workspaceId, false);
5570
6546
  return { userId, workspaceId, role };
@@ -5575,9 +6551,7 @@ export class TaskforceCore {
5575
6551
  if (!email || !email.includes('@')) {
5576
6552
  throw new TaskforceRuleError('Valid email is required', 400, 'EMAIL_REQUIRED');
5577
6553
  }
5578
- if (!password || password.length < 8) {
5579
- throw new TaskforceRuleError('Password must be at least 8 characters', 400, 'PASSWORD_TOO_SHORT');
5580
- }
6554
+ assertPasswordPolicy(password, { email, displayName: input.displayName || null });
5581
6555
  const existing = this.db.prepare(`
5582
6556
  SELECT id FROM users WHERE tenant_id = ? AND email = ? LIMIT 1
5583
6557
  `).get(this.tenantId, email);
@@ -5606,9 +6580,10 @@ export class TaskforceCore {
5606
6580
  const userId = this.generateEntityId('user');
5607
6581
  const displayName = String(input.displayName || email.split('@')[0] || email).trim() || email;
5608
6582
  this.db.prepare(`
5609
- INSERT INTO users (id, tenant_id, email, display_name, auth_provider, external_auth_id, password_hash, system_role, beta_access, is_disabled, created_at, updated_at)
5610
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
5611
- `).run(userId, this.tenantId, email, displayName, 'password', email, hashPassword(password), assignFirstSystemAdmin ? 'system_admin' : 'user', betaAccess ? 1 : 0, now, now);
6583
+ INSERT INTO users (id, tenant_id, email, display_name, system_role, beta_access, is_disabled, created_at, updated_at)
6584
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
6585
+ `).run(userId, this.tenantId, email, displayName, assignFirstSystemAdmin ? 'system_admin' : 'user', betaAccess ? 1 : 0, now, now);
6586
+ this.identityService.createPasswordIdentity(userId, password);
5612
6587
  applyRegistrationCommercialState(userId, now);
5613
6588
  return {
5614
6589
  userId,
@@ -5642,6 +6617,151 @@ export class TaskforceCore {
5642
6617
  commercialState: onboardingOutcome.commercialState
5643
6618
  };
5644
6619
  }
6620
+ // ─── OAuth state management (delegated to AuthIdentityService) ────────────
6621
+ /**
6622
+ * Creates an OAuth state record and returns the state token + PKCE materials.
6623
+ * Delegates to AuthIdentityService.createOAuthState.
6624
+ */
6625
+ createOAuthLoginState(input) {
6626
+ return this.identityService.createOAuthState(input);
6627
+ }
6628
+ /**
6629
+ * Atomically consumes an OAuth state token and returns the row.
6630
+ * Returns null if invalid, expired, or already consumed.
6631
+ */
6632
+ consumeOAuthLoginState(state) {
6633
+ return this.identityService.consumeOAuthState(state);
6634
+ }
6635
+ // ─── OAuth identity operations ─────────────────────────────────────────────
6636
+ /**
6637
+ * Finds the Taskforce user associated with a provider identity.
6638
+ * Returns { userId, identityId } or null if not yet linked.
6639
+ */
6640
+ findUserByOAuthProvider(provider, providerSubject) {
6641
+ return this.identityService.findUserByProviderIdentity({ provider, providerSubject });
6642
+ }
6643
+ /**
6644
+ * Links an OAuth provider identity to an existing user.
6645
+ * Throws IDENTITY_ALREADY_EXISTS if the user already has an identity for this provider.
6646
+ */
6647
+ linkOAuthProviderToUser(input) {
6648
+ return this.identityService.linkProviderIdentity(input);
6649
+ }
6650
+ /**
6651
+ * Lists all active login identities for a user.
6652
+ * Used by the Account Hub "Login methods" section.
6653
+ */
6654
+ listLoginMethodsForUser(userId) {
6655
+ return this.identityService.listIdentitiesForUser(userId);
6656
+ }
6657
+ /**
6658
+ * Unlinks a provider identity from a user.
6659
+ * Throws LAST_IDENTITY_REQUIRED if it would be the last active method.
6660
+ */
6661
+ unlinkLoginMethod(userId, provider) {
6662
+ return this.identityService.unlinkProviderIdentity({ userId, provider });
6663
+ }
6664
+ /**
6665
+ * Adds or replaces a password identity for a user that signed up via OAuth.
6666
+ * Uses upsert semantics (safe to call on accounts that already have a password).
6667
+ */
6668
+ addPasswordToAccount(userId, password) {
6669
+ const user = this.db.prepare(`
6670
+ SELECT email, display_name
6671
+ FROM users
6672
+ WHERE tenant_id = ? AND id = ?
6673
+ LIMIT 1
6674
+ `).get(this.tenantId, String(userId || '').trim());
6675
+ assertPasswordPolicy(password, {
6676
+ email: user?.email || null,
6677
+ displayName: user?.display_name || null
6678
+ });
6679
+ return this.identityService.setPasswordIdentity(userId, password);
6680
+ }
6681
+ /**
6682
+ * Registers a new user account via an OAuth provider (no password).
6683
+ *
6684
+ * Creates the user row, applies commercial onboarding, and links the
6685
+ * provider identity — all in a single transaction.
6686
+ *
6687
+ * Returns the same shape as `registerPasswordUser` so callers can use
6688
+ * the same session-issuance path.
6689
+ */
6690
+ registerOAuthUser(input) {
6691
+ const email = input.email ? String(input.email).trim().toLowerCase() : null;
6692
+ if (email && !email.includes('@')) {
6693
+ throw new TaskforceRuleError('Valid email is required', 400, 'EMAIL_REQUIRED');
6694
+ }
6695
+ // Duplicate guard — email uniqueness (if email is provided)
6696
+ if (email) {
6697
+ const existing = this.db.prepare(`
6698
+ SELECT id FROM users WHERE tenant_id = ? AND email = ? LIMIT 1
6699
+ `).get(this.tenantId, email);
6700
+ if (existing?.id) {
6701
+ throw new TaskforceRuleError('Email is already registered', 409, 'EMAIL_ALREADY_EXISTS');
6702
+ }
6703
+ }
6704
+ const assignFirstSystemAdmin = !this.hasAnySystemAdmin();
6705
+ const betaAccess = input.betaAccess !== false;
6706
+ const onboardingOutcome = this.resolveRegistrationOnboardingOutcome(input.planVersionId);
6707
+ const now = new Date().toISOString();
6708
+ const userId = this.generateEntityId('user');
6709
+ const displayName = String(input.displayName || (email ? email.split('@')[0] : '') || '').trim()
6710
+ || (email || userId);
6711
+ this.db.prepare(`
6712
+ INSERT INTO users
6713
+ (id, tenant_id, email, email_verified_at, display_name, system_role,
6714
+ beta_access, is_disabled, created_at, updated_at)
6715
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
6716
+ `).run(userId, this.tenantId, email,
6717
+ // OAuth providers that attest email_verified give us a verified email up-front
6718
+ (email && input.emailVerified) ? now : null, displayName, assignFirstSystemAdmin ? 'system_admin' : 'user', betaAccess ? 1 : 0, now, now);
6719
+ const identityId = this.identityService.linkProviderIdentity({
6720
+ userId,
6721
+ provider: input.provider,
6722
+ providerSubject: input.providerSubject,
6723
+ providerEmail: email,
6724
+ providerEmailVerified: input.emailVerified ?? null,
6725
+ displayNameSnapshot: input.displayName ?? null,
6726
+ avatarUrlSnapshot: input.avatarUrl ?? null,
6727
+ });
6728
+ this.transitionCanonicalCommercialState({
6729
+ accountId: userId,
6730
+ planVersionId: onboardingOutcome.planVersionId,
6731
+ state: onboardingOutcome.commercialState,
6732
+ billingMirrorPlanId: onboardingOutcome.planId,
6733
+ source: 'registration',
6734
+ updatedBy: 'registration',
6735
+ createdAt: now,
6736
+ updatedAt: now
6737
+ });
6738
+ return {
6739
+ userId,
6740
+ workspaceId: null,
6741
+ role: 'member',
6742
+ identityId,
6743
+ planSelectionRequired: onboardingOutcome.requiresPlanSelection,
6744
+ checkoutPending: onboardingOutcome.checkoutPending,
6745
+ commercialState: onboardingOutcome.commercialState,
6746
+ };
6747
+ }
6748
+ /**
6749
+ * Looks up a user by email address (exact, case-insensitive).
6750
+ * Returns { userId, email } or null.
6751
+ */
6752
+ findUserByEmail(email) {
6753
+ const normalized = String(email || '').trim().toLowerCase();
6754
+ if (!normalized)
6755
+ return null;
6756
+ const row = this.db.prepare(`
6757
+ SELECT id, email FROM users
6758
+ WHERE tenant_id = ? AND email = ? AND COALESCE(is_disabled, 0) = 0
6759
+ LIMIT 1
6760
+ `).get(this.tenantId, normalized);
6761
+ if (!row?.id)
6762
+ return null;
6763
+ return { userId: row.id, email: row.email || normalized };
6764
+ }
5645
6765
  createOneTimeAuthToken(userId, purpose, ttlSeconds, workspaceId) {
5646
6766
  return this.authTokenService.createOneTimeAuthToken(userId, purpose, ttlSeconds, workspaceId);
5647
6767
  }
@@ -5667,6 +6787,9 @@ export class TaskforceCore {
5667
6787
  revokeMcpAccessToken(input) {
5668
6788
  return this.mcpTokenService.revokeMcpAccessToken(input);
5669
6789
  }
6790
+ deleteMcpAccessToken(input) {
6791
+ return this.mcpTokenService.deleteMcpAccessToken(input);
6792
+ }
5670
6793
  regenerateMcpAccessToken(input) {
5671
6794
  return this.mcpTokenService.regenerateMcpAccessToken(input);
5672
6795
  }
@@ -5700,6 +6823,9 @@ export class TaskforceCore {
5700
6823
  authenticateMcpOAuthAccessToken(token, workspaceId) {
5701
6824
  return this.mcpTokenService.authenticateMcpOAuthAccessToken(token, workspaceId);
5702
6825
  }
6826
+ bindMcpOAuthConnectorGrantAiProfile(input) {
6827
+ return this.mcpTokenService.bindMcpOAuthConnectorGrantAiProfile(input);
6828
+ }
5703
6829
  recordMcpOAuthConnectorGrantUsage(input) {
5704
6830
  return this.mcpTokenService.recordMcpOAuthConnectorGrantUsage(input);
5705
6831
  }
@@ -5744,19 +6870,23 @@ export class TaskforceCore {
5744
6870
  if (!inspection.userId)
5745
6871
  return inspection;
5746
6872
  const user = this.db.prepare(`
5747
- SELECT email, password_hash
5748
- FROM users
5749
- WHERE tenant_id = ? AND id = ? LIMIT 1
6873
+ SELECT email FROM users WHERE tenant_id = ? AND id = ? LIMIT 1
5750
6874
  `).get(this.tenantId, inspection.userId);
5751
6875
  const workspaceId = String(inspection.workspaceId || '').trim();
5752
- const hasPassword = Boolean(String(user?.password_hash || '').trim());
6876
+ const hasIdentity = this.identityService.hasAnyActiveIdentity(inspection.userId);
6877
+ const inviteeState = hasIdentity ? 'existing_account' : 'pending_setup';
6878
+ const identities = this.identityService.listIdentitiesForUser(inspection.userId);
6879
+ const availableMethods = identities.map(i => i.provider);
5753
6880
  return {
5754
6881
  state: inspection.state,
5755
6882
  email: user?.email,
5756
6883
  userId: inspection.userId,
5757
6884
  workspaceId: workspaceId || 'default',
5758
- inviteeKind: hasPassword ? 'existing_user' : 'new_user',
5759
- passwordRequired: !hasPassword
6885
+ inviteeState,
6886
+ availableMethods,
6887
+ // Backward-compatible fields
6888
+ inviteeKind: hasIdentity ? 'existing_user' : 'new_user',
6889
+ passwordRequired: !hasIdentity
5760
6890
  };
5761
6891
  }
5762
6892
  completeInviteWorkspaceActivation(input) {
@@ -5813,14 +6943,20 @@ export class TaskforceCore {
5813
6943
  role: this.normalizeAccessRole(membership?.role || 'member', 'member')
5814
6944
  };
5815
6945
  }
5816
- acceptInviteWithToken(input) {
6946
+ /**
6947
+ * Claims an invite by linking an OAuth provider identity to the invited user.
6948
+ *
6949
+ * Used when an invited user with `inviteeState: 'pending_setup'` completes an
6950
+ * OAuth flow with `flow_mode: 'invite_claim'`. The invite token must still be
6951
+ * valid and the user must have zero active identities (pending setup).
6952
+ *
6953
+ * Returns the same shape as `acceptInviteWithToken` so callers can issue a
6954
+ * session cookie on the same path.
6955
+ */
6956
+ claimInviteWithOAuthProvider(input) {
5817
6957
  const token = String(input.token || '').trim();
5818
- const password = String(input.password || '');
5819
- if (!token || !password) {
5820
- throw new TaskforceRuleError('Token and password are required', 400, 'TOKEN_PASSWORD_REQUIRED');
5821
- }
5822
- if (password.length < 8) {
5823
- throw new TaskforceRuleError('Password must be at least 8 characters', 400, 'PASSWORD_TOO_SHORT');
6958
+ if (!token) {
6959
+ throw new TaskforceRuleError('Invite token is required', 400, 'TOKEN_REQUIRED');
5824
6960
  }
5825
6961
  const consumed = this.consumeOneTimeAuthToken(token, 'invite_accept');
5826
6962
  if (!consumed?.userId) {
@@ -5841,38 +6977,111 @@ export class TaskforceCore {
5841
6977
  throw new TaskforceRuleError('Invite token is missing workspace context', 400, 'INVITE_WORKSPACE_REQUIRED');
5842
6978
  }
5843
6979
  const user = this.db.prepare(`
5844
- SELECT id, email, password_hash
6980
+ SELECT id, email FROM users WHERE tenant_id = ? AND id = ? LIMIT 1
6981
+ `).get(this.tenantId, consumed.userId);
6982
+ if (!user?.id || !user.email) {
6983
+ throw new TaskforceRuleError('User not found', 404, 'USER_NOT_FOUND');
6984
+ }
6985
+ if (this.identityService.hasAnyActiveIdentity(consumed.userId)) {
6986
+ throw new TaskforceRuleError('Existing users must sign in before joining this workspace', 409, 'INVITE_SIGN_IN_REQUIRED');
6987
+ }
6988
+ const now = new Date().toISOString();
6989
+ const identityId = this.identityService.linkProviderIdentity({
6990
+ userId: consumed.userId,
6991
+ provider: input.provider,
6992
+ providerSubject: input.providerSubject,
6993
+ providerEmail: input.providerEmail ?? null,
6994
+ providerEmailVerified: input.providerEmailVerified ?? null,
6995
+ displayNameSnapshot: input.displayNameSnapshot ?? null,
6996
+ avatarUrlSnapshot: input.avatarUrlSnapshot ?? null,
6997
+ });
6998
+ // Mark email verified if the provider attested it
6999
+ if (input.providerEmailVerified) {
7000
+ this.db.prepare(`
7001
+ UPDATE users
7002
+ SET email_verified_at = COALESCE(email_verified_at, ?), is_disabled = 0, updated_at = ?
7003
+ WHERE tenant_id = ? AND id = ?
7004
+ `).run(now, now, this.tenantId, consumed.userId);
7005
+ }
7006
+ else {
7007
+ this.db.prepare(`
7008
+ UPDATE users SET is_disabled = 0, updated_at = ? WHERE tenant_id = ? AND id = ?
7009
+ `).run(now, this.tenantId, consumed.userId);
7010
+ }
7011
+ this.ensureDefaultOnboardingEntitlement(consumed.userId, {
7012
+ source: 'invite-accept',
7013
+ updatedBy: 'invite-accept',
7014
+ now
7015
+ });
7016
+ const activation = this.completeInviteWorkspaceActivation({
7017
+ userId: consumed.userId,
7018
+ workspaceId: inviteWorkspaceId
7019
+ });
7020
+ return { ...activation, identityId };
7021
+ }
7022
+ acceptInviteWithToken(input) {
7023
+ const token = String(input.token || '').trim();
7024
+ const password = String(input.password || '');
7025
+ if (!token || !password) {
7026
+ throw new TaskforceRuleError('Token and password are required', 400, 'TOKEN_PASSWORD_REQUIRED');
7027
+ }
7028
+ const inspected = this.inspectOneTimeAuthToken(token, 'invite_accept');
7029
+ if (inspected.state !== 'valid' || !inspected.userId) {
7030
+ if (inspected.state === 'expired') {
7031
+ throw new TaskforceRuleError('Invite token expired', 400, 'INVITE_TOKEN_EXPIRED');
7032
+ }
7033
+ if (inspected.state === 'revoked') {
7034
+ throw new TaskforceRuleError('Invite token revoked', 400, 'INVITE_TOKEN_REVOKED');
7035
+ }
7036
+ if (inspected.state === 'used') {
7037
+ throw new TaskforceRuleError('Invite token already used', 400, 'INVITE_TOKEN_USED');
7038
+ }
7039
+ throw new TaskforceRuleError('Invalid invite token', 400, 'INVITE_TOKEN_INVALID');
7040
+ }
7041
+ const inviteWorkspaceId = String(inspected.workspaceId || '').trim();
7042
+ if (!inviteWorkspaceId) {
7043
+ throw new TaskforceRuleError('Invite token is missing workspace context', 400, 'INVITE_WORKSPACE_REQUIRED');
7044
+ }
7045
+ const user = this.db.prepare(`
7046
+ SELECT id, email, display_name
5845
7047
  FROM users
5846
7048
  WHERE tenant_id = ? AND id = ?
5847
7049
  LIMIT 1
5848
- `).get(this.tenantId, consumed.userId);
7050
+ `).get(this.tenantId, inspected.userId);
5849
7051
  if (!user?.id || !user.email) {
5850
7052
  throw new TaskforceRuleError('User not found', 404, 'USER_NOT_FOUND');
5851
7053
  }
5852
- if (String(user.password_hash || '').trim()) {
7054
+ assertPasswordPolicy(password, {
7055
+ email: user.email,
7056
+ displayName: user.display_name || null
7057
+ });
7058
+ if (this.identityService.hasAnyActiveIdentity(inspected.userId)) {
5853
7059
  throw new TaskforceRuleError('Existing users must sign in before joining this workspace', 409, 'INVITE_SIGN_IN_REQUIRED');
5854
7060
  }
7061
+ const consumed = this.consumeOneTimeAuthToken(token, 'invite_accept');
7062
+ if (!consumed?.userId) {
7063
+ throw new TaskforceRuleError('Invalid invite token', 400, 'INVITE_TOKEN_INVALID');
7064
+ }
5855
7065
  const now = new Date().toISOString();
7066
+ const identityId = this.identityService.setPasswordIdentity(consumed.userId, password);
5856
7067
  this.db.prepare(`
5857
7068
  UPDATE users
5858
- SET
5859
- password_hash = ?,
5860
- auth_provider = ?,
5861
- external_auth_id = ?,
5862
- email_verified_at = COALESCE(email_verified_at, ?),
5863
- is_disabled = 0,
5864
- updated_at = ?
7069
+ SET email_verified_at = COALESCE(email_verified_at, ?), is_disabled = 0, updated_at = ?
5865
7070
  WHERE tenant_id = ? AND id = ?
5866
- `).run(hashPassword(password), 'password', user.email, now, now, this.tenantId, consumed.userId);
7071
+ `).run(now, now, this.tenantId, consumed.userId);
5867
7072
  this.ensureDefaultOnboardingEntitlement(consumed.userId, {
5868
7073
  source: 'invite-accept',
5869
7074
  updatedBy: 'invite-accept',
5870
7075
  now
5871
7076
  });
5872
- return this.completeInviteWorkspaceActivation({
7077
+ const activation = this.completeInviteWorkspaceActivation({
5873
7078
  userId: consumed.userId,
5874
7079
  workspaceId: inviteWorkspaceId
5875
7080
  });
7081
+ return {
7082
+ ...activation,
7083
+ identityId
7084
+ };
5876
7085
  }
5877
7086
  joinInviteWithAuthenticatedUser(input) {
5878
7087
  const token = String(input.token || '').trim();
@@ -5896,7 +7105,7 @@ export class TaskforceCore {
5896
7105
  }
5897
7106
  throw new TaskforceRuleError('Invalid invite token', 400, 'INVITE_TOKEN_INVALID');
5898
7107
  }
5899
- if (details.inviteeKind !== 'existing_user') {
7108
+ if (details.inviteeState !== 'existing_account') {
5900
7109
  throw new TaskforceRuleError('This invite requires account setup before joining', 409, 'INVITE_PASSWORD_SETUP_REQUIRED');
5901
7110
  }
5902
7111
  if (details.userId !== authenticatedUserId) {
@@ -5930,32 +7139,31 @@ export class TaskforceCore {
5930
7139
  const workspaceId = String(input.workspaceId || 'default').trim() || 'default';
5931
7140
  if (!email || !password)
5932
7141
  return { ok: false, code: 'INVALID_CREDENTIALS' };
5933
- const row = this.db.prepare(`
5934
- SELECT id, password_hash, email_verified_at
5935
- FROM users
5936
- WHERE tenant_id = ? AND email = ?
5937
- LIMIT 1
5938
- `).get(this.tenantId, email);
5939
- if (!row?.id || !row.password_hash)
5940
- return { ok: false, code: 'INVALID_CREDENTIALS' };
5941
- if (!verifyPassword(password, row.password_hash))
7142
+ const authResult = this.identityService.authenticatePasswordIdentity(email, password);
7143
+ if (!authResult)
5942
7144
  return { ok: false, code: 'INVALID_CREDENTIALS' };
5943
- if (input.requireVerifiedEmail && !row.email_verified_at) {
5944
- return { ok: false, code: 'EMAIL_NOT_VERIFIED' };
7145
+ if (input.requireVerifiedEmail) {
7146
+ const userRow = this.db.prepare(`
7147
+ SELECT email_verified_at FROM users WHERE tenant_id = ? AND id = ? LIMIT 1
7148
+ `).get(this.tenantId, authResult.userId);
7149
+ if (!userRow?.email_verified_at) {
7150
+ return { ok: false, code: 'EMAIL_NOT_VERIFIED' };
7151
+ }
5945
7152
  }
5946
- const resolvedWorkspaceId = this.resolvePreferredUserWorkspaceId(row.id, {
7153
+ const resolvedWorkspaceId = this.resolvePreferredUserWorkspaceId(authResult.userId, {
5947
7154
  preferredWorkspaceId: workspaceId
5948
7155
  }) || workspaceId;
5949
- const access = this.resolveUserAccess(row.id, resolvedWorkspaceId, input.fallbackRole || 'member');
7156
+ const access = this.resolveUserAccess(authResult.userId, resolvedWorkspaceId, input.fallbackRole || 'member');
5950
7157
  if (!access) {
5951
7158
  // Allow authenticated users without workspace memberships.
5952
7159
  // Provisioning flow will create/switch to a workspace after login.
5953
7160
  return {
5954
7161
  ok: true,
5955
7162
  access: {
5956
- userId: row.id,
7163
+ userId: authResult.userId,
5957
7164
  workspaceId: 'default',
5958
- role: this.normalizeAccessRole(input.fallbackRole || 'member', 'member')
7165
+ role: this.normalizeAccessRole(input.fallbackRole || 'member', 'member'),
7166
+ identityId: authResult.identityId
5959
7167
  }
5960
7168
  };
5961
7169
  }
@@ -5964,7 +7172,8 @@ export class TaskforceCore {
5964
7172
  access: {
5965
7173
  userId: access.userId,
5966
7174
  workspaceId: access.workspaceId,
5967
- role: access.role
7175
+ role: access.role,
7176
+ identityId: authResult.identityId
5968
7177
  }
5969
7178
  };
5970
7179
  }
@@ -6027,9 +7236,47 @@ export class TaskforceCore {
6027
7236
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
6028
7237
  return this.listTaskEventsInternal(taskId, normalizedWorkspaceId);
6029
7238
  }
7239
+ taskExistsForSync(id, workspaceId) {
7240
+ return this.taskExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7241
+ }
7242
+ initiativeExistsForSync(id, workspaceId) {
7243
+ return this.initiativeExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7244
+ }
7245
+ workstreamExistsForSync(id, workspaceId) {
7246
+ return this.workstreamExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7247
+ }
7248
+ recordTaskEvent(input) {
7249
+ this.addTaskEvent({
7250
+ taskId: input.taskId,
7251
+ workspaceId: this.normalizeWorkspaceId(input.workspaceId || undefined),
7252
+ action: input.action,
7253
+ actor: input.actor || 'system',
7254
+ createdAt: input.createdAt,
7255
+ details: input.details,
7256
+ });
7257
+ }
6030
7258
  listTaskEventsForSync(input) {
6031
7259
  const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
6032
- return listWorkspaceTaskEventsService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7260
+ return listWorkspaceTaskEventsForSyncService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7261
+ }
7262
+ getTaskEventForSync(input) {
7263
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7264
+ return getTaskEventForSyncService(this.getTaskActivityServiceDeps(), {
7265
+ workspaceId: normalizedWorkspaceId,
7266
+ taskId: input.taskId,
7267
+ eventId: input.eventId,
7268
+ });
7269
+ }
7270
+ listEntityEventsForSync(input) {
7271
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7272
+ return listWorkspaceEntityEventsForSyncService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7273
+ }
7274
+ getEntityEventForSync(input) {
7275
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7276
+ return getEntityEventForSyncService(this.getTaskActivityServiceDeps(), {
7277
+ workspaceId: normalizedWorkspaceId,
7278
+ eventId: input.eventId,
7279
+ });
6033
7280
  }
6034
7281
  getScheduleBoard(weekKey, showWeekends = false, workspaceId) {
6035
7282
  const parsedWeek = this.parseIsoWeekKeyToDates(weekKey);
@@ -6293,6 +7540,16 @@ export class TaskforceCore {
6293
7540
  workstreamId: task.workstreamId || null
6294
7541
  }
6295
7542
  });
7543
+ if (task.workstreamId) {
7544
+ this.addWorkstreamTaskMembershipEvents({
7545
+ task,
7546
+ workspaceId: normalizedWorkspaceId,
7547
+ actor: options?.actorRef || createdBy || 'system',
7548
+ createdAt: task.updatedAt || task.createdAt,
7549
+ previousWorkstreamId: null,
7550
+ nextWorkstreamId: task.workstreamId,
7551
+ });
7552
+ }
6296
7553
  }
6297
7554
  if (persistBackup) {
6298
7555
  this.persistBackup();
@@ -6614,6 +7871,16 @@ export class TaskforceCore {
6614
7871
  createdAt: nextTask.updatedAt,
6615
7872
  details: { changes }
6616
7873
  });
7874
+ if (changes.workstreamId) {
7875
+ this.addWorkstreamTaskMembershipEvents({
7876
+ task: nextTask,
7877
+ workspaceId: normalizedWorkspaceId,
7878
+ actor: options?.actorRef || 'system',
7879
+ createdAt: nextTask.updatedAt,
7880
+ previousWorkstreamId: existing.workstreamId || null,
7881
+ nextWorkstreamId: nextTask.workstreamId || null,
7882
+ });
7883
+ }
6617
7884
  }
6618
7885
  }
6619
7886
  return this.getTask(taskId, normalizedWorkspaceId);
@@ -6633,7 +7900,11 @@ export class TaskforceCore {
6633
7900
  }
6634
7901
  addComment(id, text, author, workspaceId, options) {
6635
7902
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
6636
- const existing = this.db.prepare('SELECT id FROM tasks WHERE tenant_id = ? AND workspace_id = ? AND id = ?').get(this.tenantId, normalizedWorkspaceId, id);
7903
+ const existing = this.db.prepare(`
7904
+ SELECT id, updated_at, created_at
7905
+ FROM tasks
7906
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
7907
+ `).get(this.tenantId, normalizedWorkspaceId, id);
6637
7908
  if (!existing)
6638
7909
  return null;
6639
7910
  const submittedAuthor = String(author || '').trim() || 'user';
@@ -6658,7 +7929,13 @@ export class TaskforceCore {
6658
7929
  submitted_author: comment.submittedAuthor,
6659
7930
  timestamp: comment.timestamp
6660
7931
  });
6661
- this.db.prepare('UPDATE tasks SET updated_at = ? WHERE tenant_id = ? AND workspace_id = ? AND id = ?').run(comment.timestamp, this.tenantId, normalizedWorkspaceId, id);
7932
+ const existingWatermark = String(existing.updated_at || existing.created_at || '').trim();
7933
+ const existingWatermarkMs = Date.parse(existingWatermark);
7934
+ const commentTimestampMs = Date.parse(comment.timestamp);
7935
+ const nextTaskUpdatedAt = Number.isFinite(existingWatermarkMs) && existingWatermarkMs >= commentTimestampMs
7936
+ ? new Date(existingWatermarkMs + 1).toISOString()
7937
+ : comment.timestamp;
7938
+ this.db.prepare('UPDATE tasks SET updated_at = ? WHERE tenant_id = ? AND workspace_id = ? AND id = ?').run(nextTaskUpdatedAt, this.tenantId, normalizedWorkspaceId, id);
6662
7939
  if (options?.skipTaskEvents !== true) {
6663
7940
  this.addTaskEvent({
6664
7941
  taskId: id,