@taskforcehq/taskforce 0.3.305 → 0.3.306

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 (337) hide show
  1. package/dist/Taskforce.module.css +295 -89
  2. package/dist/TaskforceCore.js +215 -43
  3. package/dist/TaskforceCore.test.js +433 -28
  4. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  5. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  8. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  10. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  11. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  12. package/dist/compat/workspaceSyncCompat.js +46 -2
  13. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  14. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  15. package/dist/components/context/ContextAttachmentManager.js +549 -0
  16. package/dist/components/features/AgentsModule.d.ts +10 -1
  17. package/dist/components/features/AgentsModule.js +260 -15
  18. package/dist/components/features/AgentsModule.test.js +255 -5
  19. package/dist/components/features/DocumentViewer.d.ts +5 -1
  20. package/dist/components/features/DocumentViewer.js +60 -3
  21. package/dist/components/features/DocumentWorkspace.js +1 -1
  22. package/dist/components/features/DocumentWorkspace.test.js +33 -0
  23. package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
  24. package/dist/components/task/TaskCard.js +19 -28
  25. package/dist/components/task/TaskCard.test.js +38 -3
  26. package/dist/components/task/TaskContextUpload.js +4 -526
  27. package/dist/components/task/TaskForm.js +85 -90
  28. package/dist/components/task/TaskForm.test.js +110 -8
  29. package/dist/components/task/TaskKanban.d.ts +2 -1
  30. package/dist/components/task/TaskKanban.js +94 -13
  31. package/dist/components/task/TaskKanban.test.js +36 -0
  32. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  33. package/dist/components/ui/EditableAvatarButton.js +18 -0
  34. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  35. package/dist/components/views/PlanComparisonPage.js +70 -14
  36. package/dist/components/views/PlanComparisonPage.test.js +238 -0
  37. package/dist/components/views/PlansPage.js +127 -36
  38. package/dist/components/views/PlansPage.test.js +294 -12
  39. package/dist/components/views/StandaloneLayout.js +283 -533
  40. package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
  41. package/dist/components/views/panels/PlanningDrawer.js +18 -11
  42. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  43. package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
  44. package/dist/components/views/planningScope.d.ts +20 -0
  45. package/dist/components/views/planningScope.js +25 -0
  46. package/dist/components/views/planningScope.test.d.ts +1 -0
  47. package/dist/components/views/planningScope.test.js +60 -0
  48. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
  49. package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
  50. package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
  51. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  52. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  53. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  54. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  55. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  56. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  57. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  58. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  59. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  60. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  61. package/dist/core/AiProfileService.d.ts +39 -2
  62. package/dist/core/AiProfileService.js +639 -73
  63. package/dist/core/AiProfileService.test.js +19 -1
  64. package/dist/core/AiProfiles.test.js +566 -7
  65. package/dist/core/AttachmentLinkService.js +57 -15
  66. package/dist/core/AuthIdentityService.d.ts +107 -0
  67. package/dist/core/AuthIdentityService.js +284 -0
  68. package/dist/core/AuthTokenService.d.ts +4 -0
  69. package/dist/core/AuthTokenService.js +34 -6
  70. package/dist/core/AuthTokenService.test.js +3 -3
  71. package/dist/core/EntitlementsPolicy.test.js +2 -2
  72. package/dist/core/GlobalSettingsService.js +115 -6
  73. package/dist/core/PlanEntitlementService.d.ts +23 -2
  74. package/dist/core/PlanEntitlementService.js +343 -57
  75. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  76. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  77. package/dist/core/PlanVersionPolicy.test.js +35 -2
  78. package/dist/core/PlanningEntities.test.js +52 -0
  79. package/dist/core/SignupMonetizationSettings.test.js +199 -27
  80. package/dist/core/SyncReconciliationService.js +2 -65
  81. package/dist/core/SystemAdmin.test.js +42 -42
  82. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  83. package/dist/core/Taskforce.d.ts +225 -14
  84. package/dist/core/Taskforce.js +765 -192
  85. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  86. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  87. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  88. package/dist/core/WorkspaceLifecycleService.js +0 -5
  89. package/dist/core/WorkspacePermissions.test.js +25 -1
  90. package/dist/core/WorkspacePlanMode.test.js +117 -16
  91. package/dist/core/shared.d.ts +1 -1
  92. package/dist/core/shared.js +5 -1
  93. package/dist/core/types.d.ts +68 -2
  94. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
  95. package/dist/hooks/sync/bootstrap.js +2 -0
  96. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  97. package/dist/hooks/sync/controlPlane.js +78 -0
  98. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  99. package/dist/hooks/sync/controlPlane.test.js +121 -0
  100. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  101. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  102. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  103. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  104. package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
  105. package/dist/hooks/sync/orchestratorShared.js +68 -47
  106. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  107. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  108. package/dist/hooks/sync/pullLifecycle.js +24 -0
  109. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  110. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  111. package/dist/hooks/sync/recovery.d.ts +16 -2
  112. package/dist/hooks/sync/recovery.js +171 -53
  113. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  114. package/dist/hooks/sync/recovery.test.js +292 -0
  115. package/dist/hooks/sync/transfers.d.ts +15 -3
  116. package/dist/hooks/sync/transfers.js +179 -62
  117. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  118. package/dist/hooks/sync/transfers.test.js +396 -0
  119. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  120. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  121. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  122. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  123. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  124. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  125. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  126. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  127. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  128. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  129. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  130. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  131. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  132. package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
  133. package/dist/hooks/useSyncOrchestrator.js +359 -178
  134. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
  135. package/dist/hooks/useTaskData.js +8 -3
  136. package/dist/hooks/useTaskData.test.js +45 -0
  137. package/dist/hooks/useTaskforce.d.ts +60 -2
  138. package/dist/hooks/useTaskforce.js +549 -240
  139. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  140. package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
  141. package/dist/hooks/useTaskforce.sync-behavior.test.js +2380 -212
  142. package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
  143. package/dist/hooks/useWorkspaceSyncController.js +6 -1
  144. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  145. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
  146. package/dist/localization/locales/en-US.d.ts +0 -1
  147. package/dist/localization/locales/en-US.js +0 -1
  148. package/dist/localization/locales/es-419.js +0 -1
  149. package/dist/localization/locales/pt-BR.js +0 -1
  150. package/dist/mcp/documentAssetRegistrar.js +3 -3
  151. package/dist/mcp/runtime.d.ts +2 -0
  152. package/dist/mcp/runtime.js +131 -64
  153. package/dist/mcp/runtime.test.js +291 -12
  154. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  155. package/dist/migrations/taskSchemaMigrations.js +171 -61
  156. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  157. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  158. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  159. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  160. package/dist/resources/templates/workflows/plan.yaml +1 -1
  161. package/dist/resources/templates/workflows/review.yaml +2 -2
  162. package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
  163. package/dist/server/auth/providers/apple.d.ts +33 -0
  164. package/dist/server/auth/providers/apple.js +82 -0
  165. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  166. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  167. package/dist/server/auth/providers/github.d.ts +26 -0
  168. package/dist/server/auth/providers/github.js +86 -0
  169. package/dist/server/auth/providers/google.d.ts +21 -0
  170. package/dist/server/auth/providers/google.js +56 -0
  171. package/dist/server/auth/providers/types.d.ts +21 -0
  172. package/dist/server/auth/providers/types.js +1 -0
  173. package/dist/server/auth.d.ts +2 -0
  174. package/dist/server/auth.js +6 -3
  175. package/dist/server/documentReviewRoutes.test.js +36 -3
  176. package/dist/server/index.cookieProxy.test.js +1 -0
  177. package/dist/server/index.d.ts +12 -0
  178. package/dist/server/index.js +120 -9
  179. package/dist/server/index.rateLimit.test.js +3 -0
  180. package/dist/server/index.test.js +106 -3
  181. package/dist/server/routes/admin.d.ts +3 -0
  182. package/dist/server/routes/admin.js +272 -19
  183. package/dist/server/routes/auth.d.ts +15 -2
  184. package/dist/server/routes/auth.js +994 -57
  185. package/dist/server/routes/authSupport.d.ts +1 -1
  186. package/dist/server/routes/authSupport.js +1 -1
  187. package/dist/server/routes/billing.js +361 -35
  188. package/dist/server/routes/billing.test.js +592 -12
  189. package/dist/server/routes/documents.d.ts +1 -0
  190. package/dist/server/routes/documents.js +172 -70
  191. package/dist/server/routes/shared.d.ts +28 -2
  192. package/dist/server/routes/shared.js +69 -4
  193. package/dist/server/routes/sync.integration.test.js +437 -39
  194. package/dist/server/routes/sync.js +61 -605
  195. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  196. package/dist/server/routes/syncAuxRoutes.js +91 -0
  197. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  198. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  199. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  200. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  201. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  202. package/dist/server/routes/syncPushRoutes.js +212 -0
  203. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  204. package/dist/server/routes/syncRouteGuards.js +67 -0
  205. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  206. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  207. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  208. package/dist/server/routes/syncRouteTypes.js +1 -0
  209. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  210. package/dist/server/routes/syncSnapshotRoutes.js +180 -0
  211. package/dist/server/routes/tasks.js +43 -1
  212. package/dist/server/routes/workspaces.js +36 -5
  213. package/dist/server/routes.js +60 -3
  214. package/dist/server/routes.test.js +1824 -171
  215. package/dist/services/workflowExportSnapshots.test.js +2 -0
  216. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  217. package/dist/shared/aiProfileSeatScope.js +21 -0
  218. package/dist/shared/passwordPolicy.d.ts +13 -0
  219. package/dist/shared/passwordPolicy.js +84 -0
  220. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  221. package/dist/shared/passwordPolicy.test.js +34 -0
  222. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  223. package/dist/storage/workspaceAssetStore.js +132 -44
  224. package/dist/storage/workspaceAssetStore.test.js +93 -6
  225. package/dist/styles/fonts.css +70 -0
  226. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  227. package/dist/sync/collaborationSyncPayload.js +17 -11
  228. package/dist/sync/collaborationSyncPayload.test.js +13 -1
  229. package/dist/sync/contentSyncState.d.ts +4 -8
  230. package/dist/sync/contentSyncState.js +17 -70
  231. package/dist/sync/syncApplyHandlers.d.ts +7 -0
  232. package/dist/sync/syncApplyHandlers.js +28 -13
  233. package/dist/sync/syncApplyHandlers.test.js +70 -3
  234. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  235. package/dist/sync/syncFieldSemantics.js +55 -0
  236. package/dist/sync/syncResourceAdapters.d.ts +44 -0
  237. package/dist/sync/syncResourceAdapters.js +77 -0
  238. package/dist/sync/syncService.d.ts +5 -0
  239. package/dist/sync/syncService.js +52 -0
  240. package/dist/sync/syncService.test.js +57 -3
  241. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  242. package/dist/sync/taskSyncChangeKey.js +143 -0
  243. package/dist/sync/taskSyncPayload.js +3 -8
  244. package/dist/sync/workspacePullFeed.js +4 -3
  245. package/dist/sync/workspacePullFeed.test.js +43 -0
  246. package/dist/sync/workspaceRepair.js +5 -2
  247. package/dist/sync/workspaceSyncModel.d.ts +45 -0
  248. package/dist/sync/workspaceSyncModel.js +183 -28
  249. package/dist/sync/workspaceSyncModel.test.js +291 -12
  250. package/dist/sync/workspaceSyncState.d.ts +17 -0
  251. package/dist/sync/workspaceSyncState.js +2 -1
  252. package/dist/sync/workspaceSyncSurface.js +3 -8
  253. package/dist/types.d.ts +17 -0
  254. package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
  255. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BG6P_GVW.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
  256. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  257. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  258. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  259. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  260. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
  261. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
  262. package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-9E6ParrY.js} +1 -1
  263. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  264. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  265. package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
  266. package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
  267. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  268. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  269. package/dist/ui/assets/{TaskSettings-ln0aF7xc.js → TaskSettings-DZX4jk7e.js} +1 -1
  270. package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-BA7jcEpH.js} +1 -1
  271. package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
  272. package/dist/ui/assets/index-C6k75jr8.js +6 -0
  273. package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-DuEd_65c.js} +1 -1
  274. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  275. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  276. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  277. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  278. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  279. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  280. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  281. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  282. package/dist/ui/fonts/Noir_medium.otf +0 -0
  283. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  284. package/dist/ui/fonts/Noir_regular.otf +0 -0
  285. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  286. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  287. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  288. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  289. package/dist/ui/index.html +3 -3
  290. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  291. package/dist/utils/assignees.d.ts +2 -0
  292. package/dist/utils/assignees.js +2 -0
  293. package/dist/utils/avatarUpload.d.ts +27 -0
  294. package/dist/utils/avatarUpload.js +132 -0
  295. package/dist/utils/avatarUpload.test.d.ts +1 -0
  296. package/dist/utils/avatarUpload.test.js +40 -0
  297. package/dist/utils/bootstrapTrace.d.ts +7 -0
  298. package/dist/utils/bootstrapTrace.js +29 -0
  299. package/dist/utils/commercialLifecyclePresentation.js +3 -0
  300. package/dist/utils/contextAssetEvents.d.ts +2 -0
  301. package/dist/utils/syncEventDetails.d.ts +3 -0
  302. package/dist/utils/syncEventDetails.js +64 -0
  303. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  304. package/dist/utils/syncEventDetails.test.js +23 -0
  305. package/dist/utils/syncEventPresentation.d.ts +15 -0
  306. package/dist/utils/syncEventPresentation.js +129 -0
  307. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  308. package/dist/utils/syncEventPresentation.test.js +64 -0
  309. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  310. package/dist/utils/syncStatusPresentation.js +217 -0
  311. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  312. package/dist/utils/syncStatusPresentation.test.js +164 -0
  313. package/dist/utils/taskActivity.d.ts +2 -0
  314. package/dist/utils/taskActivity.js +228 -22
  315. package/dist/utils/taskActivity.test.js +119 -8
  316. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  317. package/dist/utils/workspaceSyncPresentation.js +68 -2
  318. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  319. package/package.json +3 -2
  320. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  321. package/scripts/run-e2e-realtime.sh +17 -3
  322. package/scripts/run-smoke.sh +17 -5
  323. package/scripts/run-soak.sh +17 -5
  324. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  325. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  326. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  327. package/src/resources/templates/workflows/plan.yaml +1 -1
  328. package/src/resources/templates/workflows/review.yaml +2 -2
  329. package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
  330. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
  331. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
  332. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  333. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
  334. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  335. package/dist/ui/assets/index-C2-OXZV7.css +0 -1
  336. package/dist/ui/assets/index-DcSI5F86.js +0 -6
  337. package/scripts/migrate-planning-model.ts +0 -233
@@ -1,4 +1,16 @@
1
1
  import { isAiProfileSurfaceType } from '../shared/aiProfileSurfaceType.js';
2
+ import { isAiProfileSeatScope } from '../shared/aiProfileSeatScope.js';
3
+ import { normalizeSyncSameOriginAssetUrl } from '../sync/syncFieldSemantics.js';
4
+ function normalizeNullableIsoString(value) {
5
+ if (typeof value !== 'string')
6
+ return null;
7
+ const normalized = value.trim();
8
+ return normalized.length > 0 ? normalized : null;
9
+ }
10
+ function normalizeNullableProfileReference(value) {
11
+ const normalized = String(value || '').trim();
12
+ return normalized.length > 0 ? normalized : null;
13
+ }
2
14
  function normalizeOptionalProfileField(value) {
3
15
  if (value === undefined)
4
16
  return undefined;
@@ -7,6 +19,34 @@ function normalizeOptionalProfileField(value) {
7
19
  const normalized = String(value).trim();
8
20
  return normalized.length > 0 ? normalized : undefined;
9
21
  }
22
+ function normalizeAiProfileAvatarUrl(value) {
23
+ return normalizeSyncSameOriginAssetUrl(value);
24
+ }
25
+ function compareIsoStrings(left, right) {
26
+ const normalizedLeft = normalizeNullableIsoString(left);
27
+ const normalizedRight = normalizeNullableIsoString(right);
28
+ if (!normalizedLeft && !normalizedRight)
29
+ return 0;
30
+ if (!normalizedLeft)
31
+ return -1;
32
+ if (!normalizedRight)
33
+ return 1;
34
+ return normalizedLeft.localeCompare(normalizedRight);
35
+ }
36
+ function deriveRosterStateUpdatedAt(value, fallbacks) {
37
+ const explicitValue = normalizeNullableIsoString(value);
38
+ const archivedAt = normalizeNullableIsoString(fallbacks.archivedAt);
39
+ const createdAt = normalizeNullableIsoString(fallbacks.createdAt);
40
+ if (archivedAt) {
41
+ return explicitValue && compareIsoStrings(explicitValue, archivedAt) >= 0
42
+ ? explicitValue
43
+ : archivedAt;
44
+ }
45
+ // Legacy active snapshots may carry fresh metadata updatedAt values without
46
+ // carrying an intentional roster lifecycle event. Treat creation as the
47
+ // active lifecycle baseline so metadata-only sync cannot resurrect archives.
48
+ return explicitValue ?? createdAt;
49
+ }
10
50
  function normalizeAiProfileSurfaceTypeInput(deps, value) {
11
51
  if (value === undefined)
12
52
  return undefined;
@@ -23,6 +63,106 @@ function normalizeAiProfileSurfaceTypeInput(deps, value) {
23
63
  }
24
64
  return normalized;
25
65
  }
66
+ function normalizeAiProfileSeatScopeInput(deps, value) {
67
+ if (value === undefined)
68
+ return undefined;
69
+ if (value === null)
70
+ return null;
71
+ if (typeof value !== 'string') {
72
+ throw deps.createRuleError('AI profile seatScope must be cloud_metered or local_unmetered', 400, 'AI_PROFILE_SEAT_SCOPE_INVALID');
73
+ }
74
+ const normalized = value.trim();
75
+ if (!normalized)
76
+ return null;
77
+ if (!isAiProfileSeatScope(normalized)) {
78
+ throw deps.createRuleError('AI profile seatScope must be cloud_metered or local_unmetered', 400, 'AI_PROFILE_SEAT_SCOPE_INVALID');
79
+ }
80
+ return normalized;
81
+ }
82
+ function resolveAiProfileSeatScope(deps, inputSeatScope, existing) {
83
+ const incomingSeatScope = normalizeAiProfileSeatScopeInput(deps, inputSeatScope);
84
+ const existingSeatScope = existing?.seatScope ?? null;
85
+ if (incomingSeatScope === undefined) {
86
+ return {
87
+ seatScope: existingSeatScope,
88
+ seatScopeProvided: false,
89
+ };
90
+ }
91
+ if (incomingSeatScope === 'cloud_metered' || existingSeatScope === 'cloud_metered') {
92
+ return {
93
+ seatScope: 'cloud_metered',
94
+ seatScopeProvided: true,
95
+ };
96
+ }
97
+ return {
98
+ seatScope: incomingSeatScope ?? existingSeatScope,
99
+ seatScopeProvided: true,
100
+ };
101
+ }
102
+ function resolveSyncedAiProfileSeatScope(deps, inputSeatScope, existing) {
103
+ const incomingSeatScope = normalizeAiProfileSeatScopeInput(deps, inputSeatScope);
104
+ const existingSeatScope = existing?.seatScope ?? null;
105
+ if (incomingSeatScope === undefined) {
106
+ return {
107
+ seatScope: existingSeatScope,
108
+ seatScopeProvided: false,
109
+ };
110
+ }
111
+ return {
112
+ seatScope: incomingSeatScope ?? existingSeatScope,
113
+ seatScopeProvided: true,
114
+ };
115
+ }
116
+ function resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, existing) {
117
+ const normalizedTrustedSeatScope = normalizeAiProfileSeatScopeInput(deps, trustedSeatScope);
118
+ if (normalizedTrustedSeatScope === undefined) {
119
+ return {
120
+ seatScope: existing?.seatScope ?? null,
121
+ seatScopeProvided: false,
122
+ };
123
+ }
124
+ return {
125
+ seatScope: normalizedTrustedSeatScope,
126
+ seatScopeProvided: true,
127
+ };
128
+ }
129
+ function aiProfileMatchesTrustedSeatScope(deps, row, trustedSeatScope) {
130
+ if (trustedSeatScope === undefined)
131
+ return true;
132
+ const profile = deps.aiProfileRowToRecord(row);
133
+ const profileSeatScope = profile.seatScope ?? null;
134
+ if (profileSeatScope === null)
135
+ return true;
136
+ return profileSeatScope === trustedSeatScope;
137
+ }
138
+ function findFirstAiProfileRowForTrustedSeatScope(deps, rows, trustedSeatScope) {
139
+ if (trustedSeatScope === undefined)
140
+ return rows[0];
141
+ return rows.find((row) => aiProfileMatchesTrustedSeatScope(deps, row, trustedSeatScope));
142
+ }
143
+ function filterAiProfileRowsForTrustedSeatScope(deps, rows, trustedSeatScope) {
144
+ if (trustedSeatScope === undefined)
145
+ return rows;
146
+ return rows.filter((row) => aiProfileMatchesTrustedSeatScope(deps, row, trustedSeatScope));
147
+ }
148
+ function requiresCloudSeatReservation(existing, nextSeatScope) {
149
+ if (nextSeatScope !== 'cloud_metered')
150
+ return false;
151
+ if (existing?.archivedAt)
152
+ return true;
153
+ return existing?.seatScope !== 'cloud_metered';
154
+ }
155
+ function assertAiProfileSeatAvailable(deps, workspaceId) {
156
+ const aiProfileUsage = deps.countAiProfileUsage(workspaceId);
157
+ if (aiProfileUsage.limit !== null && aiProfileUsage.used >= aiProfileUsage.limit) {
158
+ throw deps.createRuleError('No AI seats are available for this workspace. Remove an existing cloud AI profile or upgrade your plan.', 403, 'AI_PROFILE_LIMIT_REACHED', {
159
+ workspaceId,
160
+ limit: aiProfileUsage.limit,
161
+ used: aiProfileUsage.used,
162
+ remaining: aiProfileUsage.remaining
163
+ });
164
+ }
165
+ }
26
166
  function inferAiProfileProviderModelDefaults(name) {
27
167
  const normalized = String(name || '').trim().toLowerCase();
28
168
  if (!normalized)
@@ -110,7 +250,7 @@ function applyAiProfileMetadataDefaults(record) {
110
250
  model: resolveOptionalProfileField(undefined, record.model, inferredDefaults.model) ?? null,
111
251
  };
112
252
  }
113
- function hydrateAiProfileRecord(deps, row) {
253
+ function hydrateAiProfileRecord(deps, row, diagnostics) {
114
254
  const record = deps.aiProfileRowToRecord(row);
115
255
  const hydrated = applyAiProfileMetadataDefaults(record);
116
256
  const metadataChanged = record.description !== hydrated.description
@@ -126,11 +266,15 @@ function hydrateAiProfileRecord(deps, row) {
126
266
  model = ?
127
267
  WHERE tenant_id = ? AND id = ?
128
268
  `).run(hydrated.description ?? null, hydrated.role ?? null, hydrated.provider ?? null, hydrated.model ?? null, deps.tenantId, record.id);
269
+ if (diagnostics) {
270
+ diagnostics.metadataBackfillCount = (diagnostics.metadataBackfillCount || 0) + 1;
271
+ }
129
272
  }
130
273
  return hydrated;
131
274
  }
132
- export function listAiProfilesService(deps, workspaceId) {
275
+ export function listAiProfilesService(deps, workspaceId, options) {
133
276
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
277
+ const includeArchived = options?.includeArchived === true;
134
278
  const rows = deps.db.prepare(`
135
279
  SELECT
136
280
  id,
@@ -140,35 +284,48 @@ export function listAiProfilesService(deps, workspaceId) {
140
284
  username,
141
285
  icon,
142
286
  color,
287
+ avatar_url,
288
+ avatar_source_url,
143
289
  description,
144
290
  role,
145
291
  provider,
146
292
  model,
147
293
  surface_type,
294
+ seat_scope,
148
295
  provider_metadata_json,
296
+ archived_at,
297
+ archived_by,
298
+ archived_reason,
299
+ merged_into_profile_id,
300
+ roster_state_updated_at,
149
301
  created_at,
150
302
  updated_at,
151
303
  last_active_at
152
304
  FROM ai_profiles
153
305
  WHERE tenant_id = ?
154
306
  AND workspace_id = ?
307
+ AND (? = 1 OR archived_at IS NULL)
155
308
  ORDER BY COALESCE(updated_at, created_at) DESC, LOWER(name) ASC
156
- `).all(deps.tenantId, normalizedWorkspaceId);
157
- return rows.map((row) => hydrateAiProfileRecord(deps, row));
309
+ `).all(deps.tenantId, normalizedWorkspaceId, includeArchived ? 1 : 0);
310
+ return rows.map((row) => hydrateAiProfileRecord(deps, row, options?.diagnostics));
158
311
  }
159
312
  export function getAiProfileByTokenService(deps, input) {
160
313
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
161
314
  const profileToken = String(input.profileToken || '').trim();
315
+ const includeArchived = input.includeArchived === true;
316
+ const trustedSeatScope = normalizeAiProfileSeatScopeInput(deps, input.seatScope);
162
317
  if (!profileToken)
163
318
  return null;
164
- const row = deps.db.prepare(`
319
+ const rows = deps.db.prepare(`
165
320
  SELECT *
166
321
  FROM ai_profiles
167
322
  WHERE tenant_id = ?
168
323
  AND workspace_id = ?
169
324
  AND profile_token = ?
170
- LIMIT 1
171
- `).get(deps.tenantId, normalizedWorkspaceId, profileToken);
325
+ AND (? = 1 OR archived_at IS NULL)
326
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
327
+ `).all(deps.tenantId, normalizedWorkspaceId, profileToken, includeArchived ? 1 : 0);
328
+ const row = findFirstAiProfileRowForTrustedSeatScope(deps, rows, trustedSeatScope);
172
329
  return row ? hydrateAiProfileRecord(deps, row) : null;
173
330
  }
174
331
  export function upsertAiProfileForSyncService(deps, profile) {
@@ -183,32 +340,144 @@ export function upsertAiProfileForSyncService(deps, profile) {
183
340
  const normalizedLastActiveAt = typeof profile.lastActiveAt === 'string' && profile.lastActiveAt.trim().length > 0
184
341
  ? profile.lastActiveAt.trim()
185
342
  : null;
343
+ const normalizedArchivedAt = normalizeNullableIsoString(profile.archivedAt);
344
+ const normalizedArchivedBy = normalizeNullableProfileReference(profile.archivedBy);
345
+ const normalizedArchivedReason = normalizeOptionalProfileField(profile.archivedReason) ?? null;
346
+ const normalizedMergedIntoProfileId = normalizeNullableProfileReference(profile.mergedIntoProfileId);
347
+ const normalizedRosterStateUpdatedAt = deriveRosterStateUpdatedAt(profile.rosterStateUpdatedAt, {
348
+ archivedAt: normalizedArchivedAt,
349
+ createdAt: normalizedCreatedAt,
350
+ updatedAt: normalizedUpdatedAt,
351
+ });
186
352
  const existingRow = deps.db.prepare(`
187
353
  SELECT *
188
354
  FROM ai_profiles
189
355
  WHERE tenant_id = ? AND workspace_id = ? AND id = ?
190
356
  LIMIT 1
191
357
  `).get(deps.tenantId, normalizedWorkspaceId, profileId);
192
- if (existingRow) {
193
- const existingUpdatedAt = String(existingRow.updated_at || existingRow.created_at || '').trim();
194
- if (existingUpdatedAt && existingUpdatedAt > normalizedUpdatedAt) {
195
- return hydrateAiProfileRecord(deps, existingRow);
196
- }
197
- }
198
358
  const existingProfile = existingRow ? deps.aiProfileRowToRecord(existingRow) : null;
359
+ const incomingAvatarUrlProvided = Object.prototype.hasOwnProperty.call(profile, 'avatarUrl');
360
+ const incomingAvatarSourceUrlProvided = Object.prototype.hasOwnProperty.call(profile, 'avatarSourceUrl');
361
+ const existingCreatedAt = existingRow
362
+ ? String(existingRow.created_at || existingRow.updated_at || '').trim()
363
+ : '';
364
+ const existingUpdatedAt = existingRow
365
+ ? String(existingRow.updated_at || existingRow.created_at || '').trim()
366
+ : '';
367
+ const existingLastActiveAt = existingRow
368
+ ? normalizeNullableIsoString(existingRow.last_active_at)
369
+ : null;
370
+ const existingArchivedAt = existingRow
371
+ ? normalizeNullableIsoString(existingRow.archived_at)
372
+ : null;
373
+ const existingArchivedBy = existingRow
374
+ ? normalizeNullableProfileReference(existingRow.archived_by)
375
+ : null;
376
+ const existingArchivedReason = existingRow
377
+ ? normalizeOptionalProfileField(existingRow.archived_reason) ?? null
378
+ : null;
379
+ const existingMergedIntoProfileId = existingRow
380
+ ? normalizeNullableProfileReference(existingRow.merged_into_profile_id)
381
+ : null;
382
+ const existingRosterStateUpdatedAt = existingRow
383
+ ? deriveRosterStateUpdatedAt(existingRow.roster_state_updated_at, {
384
+ archivedAt: existingArchivedAt,
385
+ createdAt: existingCreatedAt,
386
+ updatedAt: existingUpdatedAt,
387
+ })
388
+ : null;
389
+ const metadataComparison = compareIsoStrings(normalizedUpdatedAt, existingUpdatedAt);
390
+ const incomingMetadataWins = !existingRow || metadataComparison > 0;
391
+ const lifecycleComparison = compareIsoStrings(normalizedRosterStateUpdatedAt, existingRosterStateUpdatedAt);
392
+ const incomingArchived = normalizedArchivedAt !== null;
393
+ const existingArchived = existingArchivedAt !== null;
394
+ const incomingLifecycleWins = !existingRow
395
+ || lifecycleComparison > 0
396
+ || (lifecycleComparison === 0 && incomingArchived && !existingArchived);
397
+ const metadataSourceProfile = incomingMetadataWins
398
+ ? {
399
+ id: profileId,
400
+ workspaceId: normalizedWorkspaceId,
401
+ profileToken: String(profile.profileToken || '').trim(),
402
+ name: deps.normalizeAiProfileName(profile.name),
403
+ username: deps.normalizeAiProfileUsername(profile.username, profile.name),
404
+ icon: deps.normalizeAiProfileIcon(profile.icon),
405
+ color: deps.normalizeAiProfileColor(profile.color),
406
+ avatarUrl: incomingAvatarUrlProvided
407
+ ? normalizeAiProfileAvatarUrl(profile.avatarUrl)
408
+ : normalizeAiProfileAvatarUrl(existingProfile?.avatarUrl),
409
+ avatarSourceUrl: incomingAvatarSourceUrlProvided
410
+ ? normalizeAiProfileAvatarUrl(profile.avatarSourceUrl)
411
+ : normalizeAiProfileAvatarUrl(existingProfile?.avatarSourceUrl),
412
+ description: profile.description ?? null,
413
+ role: profile.role ?? null,
414
+ provider: profile.provider ?? null,
415
+ model: profile.model ?? null,
416
+ surfaceType: profile.surfaceType ?? null,
417
+ seatScope: profile.seatScope ?? null,
418
+ providerMetadata: profile.providerMetadata ?? null,
419
+ archivedAt: normalizedArchivedAt,
420
+ archivedBy: normalizedArchivedBy,
421
+ archivedReason: normalizedArchivedReason,
422
+ mergedIntoProfileId: normalizedMergedIntoProfileId,
423
+ rosterStateUpdatedAt: normalizedRosterStateUpdatedAt,
424
+ createdAt: normalizedCreatedAt,
425
+ updatedAt: normalizedUpdatedAt,
426
+ lastActiveAt: normalizedLastActiveAt
427
+ }
428
+ : existingProfile;
199
429
  const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
200
- name: profile.name,
201
- description: profile.description,
202
- role: profile.role,
203
- provider: profile.provider,
204
- model: profile.model,
205
- surfaceType: profile.surfaceType,
430
+ name: metadataSourceProfile?.name ?? deps.normalizeAiProfileName(profile.name),
431
+ description: metadataSourceProfile?.description,
432
+ role: metadataSourceProfile?.role,
433
+ provider: metadataSourceProfile?.provider,
434
+ model: metadataSourceProfile?.model,
435
+ surfaceType: metadataSourceProfile?.surfaceType,
206
436
  }, existingProfile);
437
+ const resolvedSeatScope = incomingMetadataWins
438
+ ? resolveSyncedAiProfileSeatScope(deps, profile.seatScope, existingProfile)
439
+ : {
440
+ seatScope: existingProfile?.seatScope ?? null,
441
+ seatScopeProvided: existingProfile?.seatScope !== undefined,
442
+ };
443
+ const mergedProfileToken = incomingMetadataWins
444
+ ? String(profile.profileToken || '').trim()
445
+ : String(existingRow?.profile_token || '').trim();
446
+ const mergedName = metadataSourceProfile?.name ?? deps.normalizeAiProfileName(profile.name);
447
+ const mergedUsername = metadataSourceProfile?.username ?? deps.normalizeAiProfileUsername(profile.username, profile.name);
448
+ const mergedIcon = metadataSourceProfile?.icon ?? deps.normalizeAiProfileIcon(profile.icon);
449
+ const mergedColor = metadataSourceProfile?.color ?? deps.normalizeAiProfileColor(profile.color);
450
+ const incomingAvatarFieldsWin = incomingMetadataWins || metadataComparison === 0;
451
+ const mergedAvatarUrl = incomingAvatarUrlProvided && incomingAvatarFieldsWin
452
+ ? normalizeAiProfileAvatarUrl(profile.avatarUrl)
453
+ : normalizeAiProfileAvatarUrl(existingProfile?.avatarUrl);
454
+ const mergedAvatarSourceUrl = incomingAvatarSourceUrlProvided && incomingAvatarFieldsWin
455
+ ? normalizeAiProfileAvatarUrl(profile.avatarSourceUrl)
456
+ : normalizeAiProfileAvatarUrl(existingProfile?.avatarSourceUrl);
457
+ const mergedProviderMetadata = incomingMetadataWins
458
+ ? (profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
459
+ ? JSON.stringify(profile.providerMetadata)
460
+ : null)
461
+ : (typeof existingRow?.provider_metadata_json === 'string' ? existingRow.provider_metadata_json : null);
462
+ const mergedArchivedAt = incomingLifecycleWins ? normalizedArchivedAt : existingArchivedAt;
463
+ const mergedArchivedBy = incomingLifecycleWins ? normalizedArchivedBy : existingArchivedBy;
464
+ const mergedArchivedReason = incomingLifecycleWins ? normalizedArchivedReason : existingArchivedReason;
465
+ const mergedMergedIntoProfileId = incomingLifecycleWins ? normalizedMergedIntoProfileId : existingMergedIntoProfileId;
466
+ const mergedRosterStateUpdatedAt = incomingLifecycleWins
467
+ ? normalizedRosterStateUpdatedAt
468
+ : existingRosterStateUpdatedAt;
469
+ const mergedCreatedAt = existingCreatedAt || normalizedCreatedAt;
470
+ const mergedUpdatedAt = incomingMetadataWins ? normalizedUpdatedAt : (existingUpdatedAt || normalizedUpdatedAt);
471
+ const mergedLastActiveAt = compareIsoStrings(normalizedLastActiveAt, existingLastActiveAt) > 0
472
+ ? normalizedLastActiveAt
473
+ : existingLastActiveAt;
207
474
  deps.db.prepare(`
208
475
  INSERT INTO ai_profiles (
209
476
  id, tenant_id, workspace_id, profile_token, name, username, icon, color,
210
- description, role, provider, model, surface_type, provider_metadata_json, created_at, updated_at, last_active_at
211
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
477
+ avatar_url, avatar_source_url, description, role, provider, model, surface_type, seat_scope, provider_metadata_json,
478
+ archived_at, archived_by, archived_reason, merged_into_profile_id,
479
+ roster_state_updated_at, created_at, updated_at, last_active_at
480
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
212
481
  ON CONFLICT(id) DO UPDATE SET
213
482
  workspace_id = excluded.workspace_id,
214
483
  profile_token = excluded.profile_token,
@@ -216,18 +485,24 @@ export function upsertAiProfileForSyncService(deps, profile) {
216
485
  username = excluded.username,
217
486
  icon = excluded.icon,
218
487
  color = excluded.color,
488
+ avatar_url = excluded.avatar_url,
489
+ avatar_source_url = excluded.avatar_source_url,
219
490
  description = excluded.description,
220
491
  role = excluded.role,
221
492
  provider = excluded.provider,
222
493
  model = excluded.model,
223
494
  surface_type = excluded.surface_type,
495
+ seat_scope = excluded.seat_scope,
224
496
  provider_metadata_json = excluded.provider_metadata_json,
497
+ archived_at = excluded.archived_at,
498
+ archived_by = excluded.archived_by,
499
+ archived_reason = excluded.archived_reason,
500
+ merged_into_profile_id = excluded.merged_into_profile_id,
501
+ roster_state_updated_at = excluded.roster_state_updated_at,
225
502
  created_at = excluded.created_at,
226
503
  updated_at = excluded.updated_at,
227
504
  last_active_at = excluded.last_active_at
228
- `).run(profileId, deps.tenantId, normalizedWorkspaceId, String(profile.profileToken || '').trim(), deps.normalizeAiProfileName(profile.name), deps.normalizeAiProfileUsername(profile.username, profile.name), deps.normalizeAiProfileIcon(profile.icon), deps.normalizeAiProfileColor(profile.color), resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
229
- ? JSON.stringify(profile.providerMetadata)
230
- : null, normalizedCreatedAt, normalizedUpdatedAt, normalizedLastActiveAt);
505
+ `).run(profileId, deps.tenantId, normalizedWorkspaceId, mergedProfileToken, mergedName, mergedUsername, mergedIcon, mergedColor, mergedAvatarUrl, mergedAvatarSourceUrl, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, resolvedSeatScope.seatScope, mergedProviderMetadata, mergedArchivedAt, mergedArchivedBy, mergedArchivedReason, mergedMergedIntoProfileId, mergedRosterStateUpdatedAt, mergedCreatedAt, mergedUpdatedAt, mergedLastActiveAt);
231
506
  const refreshed = deps.db.prepare(`
232
507
  SELECT *
233
508
  FROM ai_profiles
@@ -248,15 +523,18 @@ export function mergeAiProfilesService(deps, input) {
248
523
  if (keepId === mergeId)
249
524
  throw deps.createRuleError('keepId and mergeId must be different profiles', 400, 'MERGE_PROFILES_SAME_ID');
250
525
  const keepProfile = deps.db.prepare(`
251
- SELECT * FROM ai_profiles WHERE tenant_id = ? AND workspace_id = ? AND id = ? LIMIT 1
526
+ SELECT * FROM ai_profiles WHERE tenant_id = ? AND workspace_id = ? AND id = ? AND archived_at IS NULL LIMIT 1
252
527
  `).get(deps.tenantId, normalizedWorkspaceId, keepId);
253
528
  if (!keepProfile)
254
529
  throw deps.createRuleError(`Profile ${keepId} not found`, 404, 'MERGE_PROFILES_KEEP_NOT_FOUND');
255
530
  const mergeProfile = deps.db.prepare(`
256
- SELECT * FROM ai_profiles WHERE tenant_id = ? AND workspace_id = ? AND id = ? LIMIT 1
531
+ SELECT * FROM ai_profiles WHERE tenant_id = ? AND workspace_id = ? AND id = ? AND archived_at IS NULL LIMIT 1
257
532
  `).get(deps.tenantId, normalizedWorkspaceId, mergeId);
258
533
  if (!mergeProfile)
259
534
  throw deps.createRuleError(`Profile ${mergeId} not found`, 404, 'MERGE_PROFILES_MERGE_NOT_FOUND');
535
+ const keepRecord = deps.aiProfileRowToRecord(keepProfile);
536
+ const mergeRecord = deps.aiProfileRowToRecord(mergeProfile);
537
+ const mergedSeatScope = resolveAiProfileSeatScope(deps, mergeRecord.seatScope, keepRecord);
260
538
  const mergedAt = new Date().toISOString();
261
539
  const doMerge = deps.db.transaction(() => {
262
540
  deps.db.prepare(`
@@ -267,13 +545,28 @@ export function mergeAiProfilesService(deps, input) {
267
545
  UPDATE tasks SET created_by = ?, updated_at = ?
268
546
  WHERE tenant_id = ? AND workspace_id = ? AND created_by = ?
269
547
  `).run(keepId, mergedAt, deps.tenantId, normalizedWorkspaceId, mergeId);
548
+ deps.db.prepare(`
549
+ UPDATE ai_profiles
550
+ SET seat_scope = ?,
551
+ avatar_url = COALESCE(avatar_url, ?),
552
+ avatar_source_url = COALESCE(avatar_source_url, ?),
553
+ updated_at = ?
554
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
555
+ `).run(mergedSeatScope.seatScope, normalizeAiProfileAvatarUrl(mergeRecord.avatarUrl), normalizeAiProfileAvatarUrl(mergeRecord.avatarSourceUrl), mergedAt, deps.tenantId, normalizedWorkspaceId, keepId);
270
556
  deps.db.prepare(`
271
557
  UPDATE comments SET author = ?
272
558
  WHERE tenant_id = ? AND workspace_id = ? AND author = ?
273
559
  `).run(keepId, deps.tenantId, normalizedWorkspaceId, mergeId);
274
560
  deps.db.prepare(`
275
- DELETE FROM ai_profiles WHERE tenant_id = ? AND workspace_id = ? AND id = ?
276
- `).run(deps.tenantId, normalizedWorkspaceId, mergeId);
561
+ UPDATE ai_profiles
562
+ SET archived_at = ?,
563
+ archived_by = ?,
564
+ archived_reason = ?,
565
+ merged_into_profile_id = ?,
566
+ roster_state_updated_at = ?,
567
+ updated_at = ?
568
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
569
+ `).run(mergedAt, keepId, 'merged_duplicate', keepId, mergedAt, mergedAt, deps.tenantId, normalizedWorkspaceId, mergeId);
277
570
  });
278
571
  doMerge();
279
572
  const refreshed = deps.db.prepare(`
@@ -281,36 +574,289 @@ export function mergeAiProfilesService(deps, input) {
281
574
  `).get(deps.tenantId, keepId);
282
575
  return hydrateAiProfileRecord(deps, refreshed);
283
576
  }
284
- export function resolveAiProfileService(deps, input) {
577
+ export function updateAiProfileAvatarService(deps, input) {
578
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
579
+ const profileId = String(input.profileId || '').trim();
580
+ if (!profileId)
581
+ throw deps.createRuleError('profileId is required', 400, 'AI_PROFILE_ID_REQUIRED');
582
+ const existing = deps.db.prepare(`
583
+ SELECT *
584
+ FROM ai_profiles
585
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ? AND archived_at IS NULL
586
+ LIMIT 1
587
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
588
+ if (!existing) {
589
+ throw deps.createRuleError(`Profile ${profileId} not found`, 404, 'AI_PROFILE_NOT_FOUND');
590
+ }
591
+ const avatarUrl = normalizeAiProfileAvatarUrl(input.avatarUrl);
592
+ if (typeof input.avatarUrl === 'string' && input.avatarUrl.trim().length > 0 && !avatarUrl) {
593
+ throw deps.createRuleError('avatarUrl must be a Taskforce context URL', 400, 'AI_PROFILE_AVATAR_URL_INVALID');
594
+ }
595
+ const avatarSourceUrl = input.avatarSourceUrl === undefined
596
+ ? normalizeAiProfileAvatarUrl(existing.avatar_source_url)
597
+ : normalizeAiProfileAvatarUrl(input.avatarSourceUrl);
598
+ if (typeof input.avatarSourceUrl === 'string' && input.avatarSourceUrl.trim().length > 0 && !avatarSourceUrl) {
599
+ throw deps.createRuleError('avatarSourceUrl must be a Taskforce context URL', 400, 'AI_PROFILE_AVATAR_SOURCE_URL_INVALID');
600
+ }
601
+ const updatedAt = new Date().toISOString();
602
+ deps.db.prepare(`
603
+ UPDATE ai_profiles
604
+ SET avatar_url = ?,
605
+ avatar_source_url = ?,
606
+ updated_at = ?
607
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
608
+ `).run(avatarUrl, avatarSourceUrl, updatedAt, deps.tenantId, normalizedWorkspaceId, profileId);
609
+ const refreshed = deps.db.prepare(`
610
+ SELECT *
611
+ FROM ai_profiles
612
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
613
+ LIMIT 1
614
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
615
+ if (!refreshed) {
616
+ throw deps.createRuleError('Failed to update AI profile avatar', 500, 'AI_PROFILE_AVATAR_UPDATE_FAILED');
617
+ }
618
+ return hydrateAiProfileRecord(deps, refreshed);
619
+ }
620
+ export function archiveAiProfileService(deps, input) {
621
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
622
+ const profileId = String(input.profileId || '').trim();
623
+ if (!profileId)
624
+ throw deps.createRuleError('profileId is required', 400, 'ARCHIVE_AI_PROFILE_INVALID_INPUT');
625
+ const existingRow = deps.db.prepare(`
626
+ SELECT *
627
+ FROM ai_profiles
628
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ? AND archived_at IS NULL
629
+ LIMIT 1
630
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
631
+ if (!existingRow) {
632
+ throw deps.createRuleError(`Profile ${profileId} not found`, 404, 'ARCHIVE_AI_PROFILE_NOT_FOUND');
633
+ }
634
+ const openTaskRow = deps.db.prepare(`
635
+ SELECT COUNT(*) AS c
636
+ FROM tasks
637
+ WHERE tenant_id = ?
638
+ AND workspace_id = ?
639
+ AND assignee = ?
640
+ AND COALESCE(is_archived, 0) = 0
641
+ AND LOWER(COALESCE(status, 'task')) NOT IN ('done', 'cancelled')
642
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
643
+ const openTaskCount = Math.max(0, Number(openTaskRow?.c || 0));
644
+ if (openTaskCount > 0) {
645
+ throw deps.createRuleError('This AI profile still owns open tasks. Reassign or close them before removing it from the roster.', 409, 'AI_PROFILE_HAS_OPEN_TASKS', { workspaceId: normalizedWorkspaceId, profileId, openTaskCount });
646
+ }
647
+ const archivedAt = new Date().toISOString();
648
+ deps.db.prepare(`
649
+ UPDATE ai_profiles
650
+ SET archived_at = ?,
651
+ archived_by = ?,
652
+ archived_reason = ?,
653
+ merged_into_profile_id = NULL,
654
+ roster_state_updated_at = ?,
655
+ updated_at = ?
656
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
657
+ `).run(archivedAt, normalizeNullableProfileReference(input.archivedBy), normalizeOptionalProfileField(input.reason) ?? 'manual_archive', archivedAt, archivedAt, deps.tenantId, normalizedWorkspaceId, profileId);
658
+ const archivedRow = deps.db.prepare(`
659
+ SELECT *
660
+ FROM ai_profiles
661
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
662
+ LIMIT 1
663
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
664
+ if (!archivedRow) {
665
+ throw deps.createRuleError('Failed to archive AI profile', 500, 'ARCHIVE_AI_PROFILE_FAILED');
666
+ }
667
+ return {
668
+ profile: hydrateAiProfileRecord(deps, archivedRow),
669
+ openTaskCount,
670
+ };
671
+ }
672
+ export function resolveAiProfileService(deps, input, options) {
285
673
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
286
674
  const name = deps.normalizeAiProfileName(input.name);
287
675
  const profileToken = String(input.profileToken || '').trim();
288
676
  const now = new Date().toISOString();
677
+ const trustedSeatScope = normalizeAiProfileSeatScopeInput(deps, options?.trustedSeatScope);
289
678
  if (profileToken) {
290
- const existingRow = deps.db.prepare(`
679
+ const existingRows = deps.db.prepare(`
291
680
  SELECT *
292
681
  FROM ai_profiles
293
682
  WHERE tenant_id = ?
294
683
  AND workspace_id = ?
295
684
  AND profile_token = ?
296
- LIMIT 1
297
- `).get(deps.tenantId, normalizedWorkspaceId, profileToken);
685
+ AND archived_at IS NULL
686
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
687
+ `).all(deps.tenantId, normalizedWorkspaceId, profileToken);
688
+ const existingRow = findFirstAiProfileRowForTrustedSeatScope(deps, existingRows, trustedSeatScope);
298
689
  if (!existingRow) {
299
- throw deps.createRuleError('AI profile token is invalid for this workspace', 404, 'AI_PROFILE_NOT_FOUND');
690
+ const archivedRows = deps.db.prepare(`
691
+ SELECT *
692
+ FROM ai_profiles
693
+ WHERE tenant_id = ?
694
+ AND workspace_id = ?
695
+ AND profile_token = ?
696
+ AND archived_at IS NOT NULL
697
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
698
+ `).all(deps.tenantId, normalizedWorkspaceId, profileToken);
699
+ const archivedRow = findFirstAiProfileRowForTrustedSeatScope(deps, archivedRows, trustedSeatScope);
700
+ if (!archivedRow) {
701
+ if (existingRows.length === 0 && archivedRows.length === 0) {
702
+ throw deps.createRuleError('AI profile token is invalid for this workspace', 404, 'AI_PROFILE_NOT_FOUND');
703
+ }
704
+ }
705
+ else {
706
+ const existingProfile = deps.aiProfileRowToRecord(archivedRow);
707
+ const nextName = name;
708
+ const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
709
+ const nextIcon = input.icon === undefined
710
+ ? existingProfile.icon
711
+ : deps.normalizeAiProfileIcon(input.icon);
712
+ const nextColor = input.color === undefined
713
+ ? existingProfile.color
714
+ : deps.normalizeAiProfileColor(input.color);
715
+ const nextDescription = normalizeOptionalProfileField(input.description);
716
+ const nextRole = normalizeOptionalProfileField(input.role);
717
+ const nextProvider = normalizeOptionalProfileField(input.provider);
718
+ const nextModel = normalizeOptionalProfileField(input.model);
719
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
720
+ name: nextName,
721
+ description: nextDescription,
722
+ role: nextRole,
723
+ provider: nextProvider,
724
+ model: nextModel,
725
+ surfaceType: input.surfaceType,
726
+ }, existingProfile);
727
+ const resolvedSeatScope = resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, existingProfile);
728
+ if (requiresCloudSeatReservation(existingProfile, resolvedSeatScope.seatScope)) {
729
+ assertAiProfileSeatAvailable(deps, normalizedWorkspaceId);
730
+ }
731
+ deps.db.prepare(`
732
+ UPDATE ai_profiles
733
+ SET name = ?,
734
+ username = ?,
735
+ icon = ?,
736
+ color = ?,
737
+ description = COALESCE(?, description),
738
+ role = COALESCE(?, role),
739
+ provider = COALESCE(?, provider),
740
+ model = COALESCE(?, model),
741
+ surface_type = CASE
742
+ WHEN ? = 1 THEN ?
743
+ ELSE surface_type
744
+ END,
745
+ seat_scope = CASE
746
+ WHEN ? = 1 THEN ?
747
+ ELSE seat_scope
748
+ END,
749
+ archived_at = NULL,
750
+ archived_by = NULL,
751
+ archived_reason = NULL,
752
+ merged_into_profile_id = NULL,
753
+ roster_state_updated_at = ?,
754
+ last_active_at = ?,
755
+ updated_at = ?
756
+ WHERE tenant_id = ? AND id = ?
757
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, resolvedSeatScope.seatScopeProvided ? 1 : 0, resolvedSeatScope.seatScope, now, now, now, deps.tenantId, String(archivedRow.id || '').trim());
758
+ const refreshed = deps.db.prepare(`
759
+ SELECT *
760
+ FROM ai_profiles
761
+ WHERE tenant_id = ? AND id = ?
762
+ LIMIT 1
763
+ `).get(deps.tenantId, String(archivedRow.id || '').trim());
764
+ if (!refreshed) {
765
+ throw deps.createRuleError('Failed to refresh AI profile', 500, 'AI_PROFILE_REFRESH_FAILED');
766
+ }
767
+ return {
768
+ created: false,
769
+ profile: hydrateAiProfileRecord(deps, refreshed)
770
+ };
771
+ }
300
772
  }
301
- const existingProfile = deps.aiProfileRowToRecord(existingRow);
773
+ else {
774
+ const existingProfile = deps.aiProfileRowToRecord(existingRow);
775
+ const nextName = name;
776
+ const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
777
+ const nextIcon = input.icon === undefined
778
+ ? existingProfile.icon
779
+ : deps.normalizeAiProfileIcon(input.icon);
780
+ const nextColor = input.color === undefined
781
+ ? existingProfile.color
782
+ : deps.normalizeAiProfileColor(input.color);
783
+ const nextDescription = normalizeOptionalProfileField(input.description);
784
+ const nextRole = normalizeOptionalProfileField(input.role);
785
+ const nextProvider = normalizeOptionalProfileField(input.provider);
786
+ const nextModel = normalizeOptionalProfileField(input.model);
787
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
788
+ name: nextName,
789
+ description: nextDescription,
790
+ role: nextRole,
791
+ provider: nextProvider,
792
+ model: nextModel,
793
+ surfaceType: input.surfaceType,
794
+ }, existingProfile);
795
+ const resolvedSeatScope = resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, existingProfile);
796
+ if (requiresCloudSeatReservation(existingProfile, resolvedSeatScope.seatScope)) {
797
+ assertAiProfileSeatAvailable(deps, normalizedWorkspaceId);
798
+ }
799
+ deps.db.prepare(`
800
+ UPDATE ai_profiles
801
+ SET name = ?,
802
+ username = ?,
803
+ icon = ?,
804
+ color = ?,
805
+ description = COALESCE(?, description),
806
+ role = COALESCE(?, role),
807
+ provider = COALESCE(?, provider),
808
+ model = COALESCE(?, model),
809
+ surface_type = CASE
810
+ WHEN ? = 1 THEN ?
811
+ ELSE surface_type
812
+ END,
813
+ seat_scope = CASE
814
+ WHEN ? = 1 THEN ?
815
+ ELSE seat_scope
816
+ END,
817
+ last_active_at = ?,
818
+ updated_at = ?
819
+ WHERE tenant_id = ? AND id = ?
820
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, resolvedSeatScope.seatScopeProvided ? 1 : 0, resolvedSeatScope.seatScope, now, now, deps.tenantId, String(existingRow.id || '').trim());
821
+ const refreshed = deps.db.prepare(`
822
+ SELECT *
823
+ FROM ai_profiles
824
+ WHERE tenant_id = ? AND id = ?
825
+ LIMIT 1
826
+ `).get(deps.tenantId, String(existingRow.id || '').trim());
827
+ if (!refreshed) {
828
+ throw deps.createRuleError('Failed to refresh AI profile', 500, 'AI_PROFILE_REFRESH_FAILED');
829
+ }
830
+ return {
831
+ created: false,
832
+ profile: hydrateAiProfileRecord(deps, refreshed)
833
+ };
834
+ }
835
+ }
836
+ const exactMatches = deps.db.prepare(`
837
+ SELECT *
838
+ FROM ai_profiles
839
+ WHERE tenant_id = ?
840
+ AND workspace_id = ?
841
+ AND archived_at IS NULL
842
+ AND (
843
+ LOWER(name) = LOWER(?)
844
+ OR LOWER(username) = LOWER(?)
845
+ )
846
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
847
+ `).all(deps.tenantId, normalizedWorkspaceId, name, deps.normalizeAiProfileUsername(undefined, name));
848
+ const scopedExactMatches = filterAiProfileRowsForTrustedSeatScope(deps, exactMatches, trustedSeatScope);
849
+ if (scopedExactMatches.length > 0) {
850
+ const canonicalId = String(scopedExactMatches[0].id || '').trim();
302
851
  const nextName = name;
303
852
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
304
- const nextIcon = input.icon === undefined
305
- ? existingProfile.icon
306
- : deps.normalizeAiProfileIcon(input.icon);
307
- const nextColor = input.color === undefined
308
- ? existingProfile.color
309
- : deps.normalizeAiProfileColor(input.color);
853
+ const nextIcon = deps.normalizeAiProfileIcon(input.icon);
854
+ const nextColor = deps.normalizeAiProfileColor(input.color);
310
855
  const nextDescription = normalizeOptionalProfileField(input.description);
311
856
  const nextRole = normalizeOptionalProfileField(input.role);
312
857
  const nextProvider = normalizeOptionalProfileField(input.provider);
313
858
  const nextModel = normalizeOptionalProfileField(input.model);
859
+ const existingProfile = deps.aiProfileRowToRecord(scopedExactMatches[0]);
314
860
  const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
315
861
  name: nextName,
316
862
  description: nextDescription,
@@ -319,6 +865,10 @@ export function resolveAiProfileService(deps, input) {
319
865
  model: nextModel,
320
866
  surfaceType: input.surfaceType,
321
867
  }, existingProfile);
868
+ const resolvedSeatScope = resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, existingProfile);
869
+ if (requiresCloudSeatReservation(existingProfile, resolvedSeatScope.seatScope)) {
870
+ assertAiProfileSeatAvailable(deps, normalizedWorkspaceId);
871
+ }
322
872
  deps.db.prepare(`
323
873
  UPDATE ai_profiles
324
874
  SET name = ?,
@@ -329,41 +879,53 @@ export function resolveAiProfileService(deps, input) {
329
879
  role = COALESCE(?, role),
330
880
  provider = COALESCE(?, provider),
331
881
  model = COALESCE(?, model),
882
+ last_active_at = ?,
883
+ updated_at = ?,
332
884
  surface_type = CASE
333
885
  WHEN ? = 1 THEN ?
334
886
  ELSE surface_type
335
887
  END,
336
- last_active_at = ?,
337
- updated_at = ?
888
+ seat_scope = CASE
889
+ WHEN ? = 1 THEN ?
890
+ ELSE seat_scope
891
+ END
338
892
  WHERE tenant_id = ? AND id = ?
339
- `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, now, now, deps.tenantId, String(existingRow.id || '').trim());
340
- const refreshed = deps.db.prepare(`
893
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, now, now, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, resolvedSeatScope.seatScopeProvided ? 1 : 0, resolvedSeatScope.seatScope, deps.tenantId, canonicalId);
894
+ const reused = deps.db.prepare(`
341
895
  SELECT *
342
896
  FROM ai_profiles
343
897
  WHERE tenant_id = ? AND id = ?
344
898
  LIMIT 1
345
- `).get(deps.tenantId, String(existingRow.id || '').trim());
346
- if (!refreshed) {
899
+ `).get(deps.tenantId, canonicalId);
900
+ if (!reused) {
347
901
  throw deps.createRuleError('Failed to refresh AI profile', 500, 'AI_PROFILE_REFRESH_FAILED');
348
902
  }
349
903
  return {
350
904
  created: false,
351
- profile: hydrateAiProfileRecord(deps, refreshed)
905
+ profile: hydrateAiProfileRecord(deps, reused),
906
+ ...(scopedExactMatches.length > 1 ? {
907
+ duplicateWarning: {
908
+ existingProfileId: canonicalId,
909
+ message: `Multiple profiles matched "${name}". Reused ${canonicalId}; merge the remaining duplicates.`
910
+ }
911
+ } : {})
352
912
  };
353
913
  }
354
- const exactMatches = deps.db.prepare(`
914
+ const archivedMatches = deps.db.prepare(`
355
915
  SELECT *
356
916
  FROM ai_profiles
357
917
  WHERE tenant_id = ?
358
918
  AND workspace_id = ?
919
+ AND archived_at IS NOT NULL
359
920
  AND (
360
921
  LOWER(name) = LOWER(?)
361
922
  OR LOWER(username) = LOWER(?)
362
923
  )
363
924
  ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
364
925
  `).all(deps.tenantId, normalizedWorkspaceId, name, deps.normalizeAiProfileUsername(undefined, name));
365
- if (exactMatches.length > 0) {
366
- const canonicalId = String(exactMatches[0].id || '').trim();
926
+ const scopedArchivedMatches = filterAiProfileRowsForTrustedSeatScope(deps, archivedMatches, trustedSeatScope);
927
+ if (scopedArchivedMatches.length > 0) {
928
+ const canonicalId = String(scopedArchivedMatches[0].id || '').trim();
367
929
  const nextName = name;
368
930
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
369
931
  const nextIcon = deps.normalizeAiProfileIcon(input.icon);
@@ -372,7 +934,7 @@ export function resolveAiProfileService(deps, input) {
372
934
  const nextRole = normalizeOptionalProfileField(input.role);
373
935
  const nextProvider = normalizeOptionalProfileField(input.provider);
374
936
  const nextModel = normalizeOptionalProfileField(input.model);
375
- const existingProfile = deps.aiProfileRowToRecord(exactMatches[0]);
937
+ const existingProfile = deps.aiProfileRowToRecord(scopedArchivedMatches[0]);
376
938
  const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
377
939
  name: nextName,
378
940
  description: nextDescription,
@@ -381,6 +943,10 @@ export function resolveAiProfileService(deps, input) {
381
943
  model: nextModel,
382
944
  surfaceType: input.surfaceType,
383
945
  }, existingProfile);
946
+ const resolvedSeatScope = resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, existingProfile);
947
+ if (requiresCloudSeatReservation(existingProfile, resolvedSeatScope.seatScope)) {
948
+ assertAiProfileSeatAvailable(deps, normalizedWorkspaceId);
949
+ }
384
950
  deps.db.prepare(`
385
951
  UPDATE ai_profiles
386
952
  SET name = ?,
@@ -393,41 +959,35 @@ export function resolveAiProfileService(deps, input) {
393
959
  model = COALESCE(?, model),
394
960
  last_active_at = ?,
395
961
  updated_at = ?,
962
+ archived_at = NULL,
963
+ archived_by = NULL,
964
+ archived_reason = NULL,
965
+ merged_into_profile_id = NULL,
966
+ roster_state_updated_at = ?,
396
967
  surface_type = CASE
397
968
  WHEN ? = 1 THEN ?
398
969
  ELSE surface_type
970
+ END,
971
+ seat_scope = CASE
972
+ WHEN ? = 1 THEN ?
973
+ ELSE seat_scope
399
974
  END
400
975
  WHERE tenant_id = ? AND id = ?
401
- `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, now, now, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, deps.tenantId, canonicalId);
402
- const reused = deps.db.prepare(`
976
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, now, now, now, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, resolvedSeatScope.seatScopeProvided ? 1 : 0, resolvedSeatScope.seatScope, deps.tenantId, canonicalId);
977
+ const reactivated = deps.db.prepare(`
403
978
  SELECT *
404
979
  FROM ai_profiles
405
980
  WHERE tenant_id = ? AND id = ?
406
981
  LIMIT 1
407
982
  `).get(deps.tenantId, canonicalId);
408
- if (!reused) {
983
+ if (!reactivated) {
409
984
  throw deps.createRuleError('Failed to refresh AI profile', 500, 'AI_PROFILE_REFRESH_FAILED');
410
985
  }
411
986
  return {
412
987
  created: false,
413
- profile: hydrateAiProfileRecord(deps, reused),
414
- ...(exactMatches.length > 1 ? {
415
- duplicateWarning: {
416
- existingProfileId: canonicalId,
417
- message: `Multiple profiles matched "${name}". Reused ${canonicalId}; merge the remaining duplicates.`
418
- }
419
- } : {})
988
+ profile: hydrateAiProfileRecord(deps, reactivated)
420
989
  };
421
990
  }
422
- const aiProfileUsage = deps.countAiProfileUsage(normalizedWorkspaceId);
423
- if (aiProfileUsage.limit !== null && aiProfileUsage.used >= aiProfileUsage.limit) {
424
- throw deps.createRuleError('No AI seats are available for this workspace. Remove an existing AI profile or upgrade your plan.', 403, 'AI_PROFILE_LIMIT_REACHED', {
425
- workspaceId: normalizedWorkspaceId,
426
- limit: aiProfileUsage.limit,
427
- used: aiProfileUsage.used,
428
- remaining: aiProfileUsage.remaining
429
- });
430
- }
431
991
  const id = deps.generateEntityId('ai-profile');
432
992
  const issuedProfileToken = deps.issueProfileToken();
433
993
  const username = deps.normalizeAiProfileUsername(undefined, name);
@@ -445,15 +1005,21 @@ export function resolveAiProfileService(deps, input) {
445
1005
  model,
446
1006
  surfaceType: input.surfaceType,
447
1007
  }, null);
1008
+ const resolvedSeatScope = resolveTrustedAiProfileSeatScope(deps, trustedSeatScope, null);
1009
+ if (resolvedSeatScope.seatScope === 'cloud_metered') {
1010
+ assertAiProfileSeatAvailable(deps, normalizedWorkspaceId);
1011
+ }
448
1012
  const providerMetadata = input.providerMetadata && typeof input.providerMetadata === 'object'
449
1013
  ? input.providerMetadata
450
1014
  : null;
451
1015
  deps.db.prepare(`
452
1016
  INSERT INTO ai_profiles (
453
1017
  id, tenant_id, workspace_id, profile_token, name, username, icon, color,
454
- description, role, provider, model, surface_type, provider_metadata_json, created_at, updated_at, last_active_at
455
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
456
- `).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, providerMetadata ? JSON.stringify(providerMetadata) : null, now, now, now);
1018
+ avatar_url, avatar_source_url, description, role, provider, model, surface_type, seat_scope, provider_metadata_json,
1019
+ archived_at, archived_by, archived_reason, merged_into_profile_id,
1020
+ roster_state_updated_at, created_at, updated_at, last_active_at
1021
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1022
+ `).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, null, null, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, resolvedSeatScope.seatScope, providerMetadata ? JSON.stringify(providerMetadata) : null, null, null, null, null, now, now, now, now);
457
1023
  const created = deps.db.prepare(`
458
1024
  SELECT *
459
1025
  FROM ai_profiles