@taskforcehq/taskforce 0.3.306 → 0.3.308

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 (232) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.d.ts +20 -15
  38. package/dist/components/views/PlanComparisonPage.js +221 -82
  39. package/dist/components/views/PlanComparisonPage.test.js +268 -14
  40. package/dist/components/views/PlansPage.js +16 -10
  41. package/dist/components/views/PlansPage.test.js +4 -2
  42. package/dist/components/views/StandaloneLayout.js +256 -69
  43. package/dist/components/views/WidgetView.js +10 -8
  44. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  45. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  46. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  47. package/dist/config/envSchema.js +1 -0
  48. package/dist/core/AiProfileService.d.ts +7 -0
  49. package/dist/core/AiProfileService.js +460 -35
  50. package/dist/core/AiProfiles.test.js +648 -2
  51. package/dist/core/AttachmentLinkService.d.ts +3 -0
  52. package/dist/core/AttachmentLinkService.js +58 -13
  53. package/dist/core/GlobalSettingsService.js +30 -0
  54. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  55. package/dist/core/GlobalSettingsService.test.js +45 -0
  56. package/dist/core/McpTokenService.d.ts +12 -0
  57. package/dist/core/McpTokenService.js +44 -1
  58. package/dist/core/PlanEntitlementService.d.ts +20 -0
  59. package/dist/core/PlanEntitlementService.js +215 -19
  60. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  61. package/dist/core/PlanningEntities.test.js +366 -4
  62. package/dist/core/TaskActivityService.d.ts +28 -1
  63. package/dist/core/TaskActivityService.js +227 -46
  64. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  65. package/dist/core/TaskActivitySync.test.js +90 -0
  66. package/dist/core/TaskAuditTimeline.test.js +96 -0
  67. package/dist/core/Taskforce.d.ts +204 -26
  68. package/dist/core/Taskforce.ids.test.js +58 -0
  69. package/dist/core/Taskforce.js +781 -71
  70. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  71. package/dist/core/types.d.ts +10 -0
  72. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  73. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  74. package/dist/hooks/bootstrapTimeouts.js +8 -0
  75. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  76. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  77. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  78. package/dist/hooks/sync/orchestratorShared.js +61 -1
  79. package/dist/hooks/sync/recovery.js +8 -0
  80. package/dist/hooks/sync/recovery.test.js +5 -5
  81. package/dist/hooks/sync/transfers.d.ts +2 -1
  82. package/dist/hooks/sync/transfers.js +36 -9
  83. package/dist/hooks/sync/transfers.test.js +534 -2
  84. package/dist/hooks/useFilterSort.js +13 -13
  85. package/dist/hooks/useFilterSort.test.js +87 -1
  86. package/dist/hooks/useSyncOrchestrator.js +50 -5
  87. package/dist/hooks/useTaskforce.d.ts +85 -50
  88. package/dist/hooks/useTaskforce.js +44 -14
  89. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  90. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  91. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  92. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  93. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  95. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  96. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  97. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  98. package/dist/mcp/clientIntegrations.d.ts +1 -0
  99. package/dist/mcp/clientIntegrations.js +25 -0
  100. package/dist/mcp/clientIntegrations.test.js +14 -3
  101. package/dist/mcp/clientProfileDefaults.js +3 -3
  102. package/dist/mcp/collaborationRegistrar.js +7 -7
  103. package/dist/mcp/documentAssetRegistrar.js +113 -7
  104. package/dist/mcp/runtime.d.ts +1 -0
  105. package/dist/mcp/runtime.js +969 -122
  106. package/dist/mcp/runtime.test.js +1083 -11
  107. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  108. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  109. package/dist/mcp/toolRegistry.d.ts +56 -0
  110. package/dist/mcp/toolRegistry.js +8 -0
  111. package/dist/mcp/toolRegistry.test.js +4 -0
  112. package/dist/migrations/taskSchemaMigrations.js +58 -15
  113. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  114. package/dist/server/aiProfileAvatarAssets.js +319 -0
  115. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  116. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  117. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  118. package/dist/server/index.js +4 -0
  119. package/dist/server/index.test.js +6 -1
  120. package/dist/server/routes/admin.js +328 -9
  121. package/dist/server/routes/annotatedAttachments.js +51 -0
  122. package/dist/server/routes/auth.d.ts +11 -0
  123. package/dist/server/routes/auth.js +189 -27
  124. package/dist/server/routes/authSupport.d.ts +5 -0
  125. package/dist/server/routes/authSupport.js +13 -1
  126. package/dist/server/routes/billing.d.ts +1 -0
  127. package/dist/server/routes/billing.js +116 -63
  128. package/dist/server/routes/billing.test.js +162 -11
  129. package/dist/server/routes/documents.js +10 -0
  130. package/dist/server/routes/shared.js +0 -10
  131. package/dist/server/routes/sync.integration.test.js +221 -0
  132. package/dist/server/routes/sync.js +13 -3
  133. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  134. package/dist/server/routes/tasks.js +12 -9
  135. package/dist/server/routes.js +87 -8
  136. package/dist/server/routes.test.js +972 -23
  137. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  138. package/dist/shared/aiProfileSurfaceType.js +10 -8
  139. package/dist/storage/objectStorage.d.ts +1 -0
  140. package/dist/storage/objectStorage.js +2 -1
  141. package/dist/storage/objectStorageClient.d.ts +1 -0
  142. package/dist/storage/objectStorageClient.js +41 -8
  143. package/dist/storage/objectStorageClient.test.js +26 -1
  144. package/dist/storage/objectStorageConfig.js +3 -1
  145. package/dist/storage/objectStorageConfig.test.js +13 -1
  146. package/dist/sync/collaborationSyncPayload.js +2 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -3
  149. package/dist/sync/collaborationSyncState.js +19 -12
  150. package/dist/sync/contentSyncPayload.d.ts +5 -1
  151. package/dist/sync/contentSyncPayload.js +9 -0
  152. package/dist/sync/contentSyncPayload.test.js +39 -0
  153. package/dist/sync/contentSyncState.d.ts +5 -1
  154. package/dist/sync/contentSyncState.js +231 -37
  155. package/dist/sync/planningSyncPayload.js +5 -0
  156. package/dist/sync/planningSyncPayload.test.js +24 -0
  157. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  158. package/dist/sync/syncApplyHandlers.js +89 -31
  159. package/dist/sync/syncApplyHandlers.test.js +184 -4
  160. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  161. package/dist/sync/syncResourceAdapters.js +2 -0
  162. package/dist/sync/syncService.d.ts +1 -0
  163. package/dist/sync/syncService.js +13 -1
  164. package/dist/sync/syncService.test.js +39 -0
  165. package/dist/sync/workspacePullFeed.d.ts +4 -0
  166. package/dist/sync/workspacePullFeed.js +129 -70
  167. package/dist/sync/workspacePullFeed.test.js +49 -0
  168. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  169. package/dist/sync/workspaceSyncModel.js +159 -45
  170. package/dist/sync/workspaceSyncModel.test.js +269 -3
  171. package/dist/types.d.ts +33 -2
  172. package/dist/ui/agent-logos/ChatGPT.png +0 -0
  173. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  174. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  175. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  176. package/dist/ui/agent-logos/codex.jpeg +0 -0
  177. package/dist/ui/agent-logos/cursor.png +0 -0
  178. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  179. package/dist/ui/agent-logos/windsurf.png +0 -0
  180. package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
  181. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  182. package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
  183. package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
  184. package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
  185. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  186. package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
  187. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  188. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
  189. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  190. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  191. package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
  192. package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
  193. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
  194. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  195. package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
  196. package/dist/ui/assets/index-MAHKvFqp.js +4 -0
  197. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  198. package/dist/ui/index.html +3 -3
  199. package/dist/ui/og-image.png +0 -0
  200. package/dist/ui/product/agent manager.png +0 -0
  201. package/dist/ui/product/agent_manager_02.png +0 -0
  202. package/dist/utils/aiProfileEvents.d.ts +7 -0
  203. package/dist/utils/aiProfileEvents.js +6 -0
  204. package/dist/utils/assignees.d.ts +6 -0
  205. package/dist/utils/assignees.js +8 -1
  206. package/dist/utils/assignees.test.d.ts +1 -0
  207. package/dist/utils/assignees.test.js +25 -0
  208. package/dist/utils/avatarDisplay.d.ts +1 -0
  209. package/dist/utils/avatarDisplay.js +15 -0
  210. package/dist/utils/contextFiles.d.ts +3 -1
  211. package/dist/utils/contextFiles.js +2 -1
  212. package/dist/utils/taskActivity.d.ts +7 -1
  213. package/dist/utils/taskActivity.js +63 -1
  214. package/dist/utils/taskActivity.test.js +23 -1
  215. package/dist/utils/taskSearch.d.ts +22 -0
  216. package/dist/utils/taskSearch.js +87 -0
  217. package/dist/utils/taskSearch.test.d.ts +1 -0
  218. package/dist/utils/taskSearch.test.js +69 -0
  219. package/dist/utils/taskUtils.js +10 -3
  220. package/dist/utils/taskUtils.test.d.ts +1 -0
  221. package/dist/utils/taskUtils.test.js +19 -0
  222. package/package.json +1 -1
  223. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  224. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  225. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  226. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  227. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  228. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  229. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  230. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  231. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  232. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -22,6 +22,17 @@ function normalizeOptionalProfileField(value) {
22
22
  function normalizeAiProfileAvatarUrl(value) {
23
23
  return normalizeSyncSameOriginAssetUrl(value);
24
24
  }
25
+ function normalizeAiProfileAvatarRevision(value) {
26
+ if (value === undefined || value === null || value === '')
27
+ return 0;
28
+ const normalized = typeof value === 'number' ? value : Number(value);
29
+ if (!Number.isFinite(normalized))
30
+ return 0;
31
+ return Math.max(0, Math.floor(normalized));
32
+ }
33
+ function hasAiProfileAvatarState(avatarUrl, avatarSourceUrl) {
34
+ return Boolean(avatarUrl || avatarSourceUrl);
35
+ }
25
36
  function compareIsoStrings(left, right) {
26
37
  const normalizedLeft = normalizeNullableIsoString(left);
27
38
  const normalizedRight = normalizeNullableIsoString(right);
@@ -53,16 +64,73 @@ function normalizeAiProfileSurfaceTypeInput(deps, value) {
53
64
  if (value === null)
54
65
  return null;
55
66
  if (typeof value !== 'string') {
56
- throw deps.createRuleError('AI profile surfaceType must be one of chat_app, ide, coding_tool, agent, or other', 400, 'AI_PROFILE_SURFACE_TYPE_INVALID');
67
+ throw deps.createRuleError('AI profile surfaceType must be one of chat_app, ide, cli, coding_tool, or agent', 400, 'AI_PROFILE_SURFACE_TYPE_INVALID');
57
68
  }
58
69
  const normalized = value.trim();
59
70
  if (!normalized)
60
71
  return null;
72
+ if (normalized === 'other')
73
+ return 'agent';
61
74
  if (!isAiProfileSurfaceType(normalized)) {
62
- throw deps.createRuleError('AI profile surfaceType must be one of chat_app, ide, coding_tool, agent, or other', 400, 'AI_PROFILE_SURFACE_TYPE_INVALID');
75
+ throw deps.createRuleError('AI profile surfaceType must be one of chat_app, ide, cli, coding_tool, or agent', 400, 'AI_PROFILE_SURFACE_TYPE_INVALID');
63
76
  }
64
77
  return normalized;
65
78
  }
79
+ function readAiProfileProviderMetadataJson(value) {
80
+ if (typeof value !== 'string' || value.trim().length === 0)
81
+ return null;
82
+ try {
83
+ const parsed = JSON.parse(value);
84
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
85
+ ? parsed
86
+ : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ function hasManualSurfaceTypeOverride(profile) {
93
+ const taskforceMetadata = profile?.providerMetadata?.taskforce;
94
+ return Boolean(taskforceMetadata
95
+ && typeof taskforceMetadata === 'object'
96
+ && !Array.isArray(taskforceMetadata)
97
+ && taskforceMetadata.surfaceTypeManuallySet === true);
98
+ }
99
+ function markManualSurfaceTypeOverride(row) {
100
+ const metadata = readAiProfileProviderMetadataJson(row.provider_metadata_json) ?? {};
101
+ const taskforceMetadata = metadata.taskforce && typeof metadata.taskforce === 'object' && !Array.isArray(metadata.taskforce)
102
+ ? metadata.taskforce
103
+ : {};
104
+ return JSON.stringify({
105
+ ...metadata,
106
+ taskforce: {
107
+ ...taskforceMetadata,
108
+ surfaceTypeManuallySet: true
109
+ }
110
+ });
111
+ }
112
+ function clearManualSurfaceTypeOverride(row) {
113
+ const metadata = readAiProfileProviderMetadataJson(row.provider_metadata_json);
114
+ if (!metadata)
115
+ return null;
116
+ const taskforceMetadata = metadata.taskforce && typeof metadata.taskforce === 'object' && !Array.isArray(metadata.taskforce)
117
+ ? { ...metadata.taskforce }
118
+ : null;
119
+ if (!taskforceMetadata || taskforceMetadata.surfaceTypeManuallySet !== true) {
120
+ return JSON.stringify(metadata);
121
+ }
122
+ delete taskforceMetadata.surfaceTypeManuallySet;
123
+ const nextMetadata = { ...metadata };
124
+ if (Object.keys(taskforceMetadata).length > 0) {
125
+ nextMetadata.taskforce = taskforceMetadata;
126
+ }
127
+ else {
128
+ delete nextMetadata.taskforce;
129
+ }
130
+ return Object.keys(nextMetadata).length > 0
131
+ ? JSON.stringify(nextMetadata)
132
+ : null;
133
+ }
66
134
  function normalizeAiProfileSeatScopeInput(deps, value) {
67
135
  if (value === undefined)
68
136
  return undefined;
@@ -145,6 +213,66 @@ function filterAiProfileRowsForTrustedSeatScope(deps, rows, trustedSeatScope) {
145
213
  return rows;
146
214
  return rows.filter((row) => aiProfileMatchesTrustedSeatScope(deps, row, trustedSeatScope));
147
215
  }
216
+ function normalizeAiProfileIdentityValue(value) {
217
+ return String(value || '').trim().toLowerCase();
218
+ }
219
+ function latestAiProfileActivityValue(row) {
220
+ return normalizeNullableIsoString(row.last_active_at)
221
+ ?? normalizeNullableIsoString(row.updated_at)
222
+ ?? normalizeNullableIsoString(row.created_at)
223
+ ?? '';
224
+ }
225
+ function rankNullableIdentityMatch(existingValue, incomingValue) {
226
+ const existing = normalizeAiProfileIdentityValue(existingValue);
227
+ if (incomingValue) {
228
+ if (existing === incomingValue)
229
+ return 2;
230
+ if (!existing)
231
+ return 1;
232
+ return 0;
233
+ }
234
+ return existing ? 0 : 1;
235
+ }
236
+ function selectAiProfileIdentityMatch(deps, rows, input, trustedSeatScope) {
237
+ const scopedRows = filterAiProfileRowsForTrustedSeatScope(deps, rows, trustedSeatScope);
238
+ const incomingProvider = normalizeAiProfileIdentityValue(input.provider) || null;
239
+ const incomingModel = normalizeAiProfileIdentityValue(input.model) || null;
240
+ const incomingSurfaceType = input.surfaceTypeProvided
241
+ ? normalizeAiProfileIdentityValue(input.surfaceType) || null
242
+ : null;
243
+ const ranked = scopedRows
244
+ .map((row) => {
245
+ const providerRank = rankNullableIdentityMatch(row.provider, incomingProvider);
246
+ const surfaceRank = input.surfaceTypeProvided
247
+ ? rankNullableIdentityMatch(row.surface_type, incomingSurfaceType)
248
+ : 0;
249
+ const modelRank = incomingModel && normalizeAiProfileIdentityValue(row.model) === incomingModel ? 1 : 0;
250
+ return {
251
+ row,
252
+ providerRank,
253
+ surfaceRank,
254
+ modelRank,
255
+ activity: latestAiProfileActivityValue(row),
256
+ };
257
+ })
258
+ .filter((entry) => Boolean(entry))
259
+ .sort((left, right) => {
260
+ if (right.providerRank !== left.providerRank)
261
+ return right.providerRank - left.providerRank;
262
+ if (right.surfaceRank !== left.surfaceRank)
263
+ return right.surfaceRank - left.surfaceRank;
264
+ if (right.modelRank !== left.modelRank)
265
+ return right.modelRank - left.modelRank;
266
+ const activityDelta = right.activity.localeCompare(left.activity);
267
+ if (activityDelta !== 0)
268
+ return activityDelta;
269
+ return String(left.row.id || '').localeCompare(String(right.row.id || ''));
270
+ });
271
+ return {
272
+ row: ranked[0]?.row,
273
+ matchCount: ranked.length,
274
+ };
275
+ }
148
276
  function requiresCloudSeatReservation(existing, nextSeatScope) {
149
277
  if (nextSeatScope !== 'cloud_metered')
150
278
  return false;
@@ -194,6 +322,12 @@ function inferAiProfileMetadataDefaults(name, surfaceType) {
194
322
  role: 'Coding Agent',
195
323
  ...inferredProviderModel
196
324
  };
325
+ case 'cli':
326
+ return {
327
+ description: `${name} CLI coding agent connected through MCP.`,
328
+ role: 'CLI Coding Agent',
329
+ ...inferredProviderModel
330
+ };
197
331
  case 'chat_app':
198
332
  return {
199
333
  description: `${name} conversational AI connected through Taskforce.`,
@@ -206,12 +340,6 @@ function inferAiProfileMetadataDefaults(name, surfaceType) {
206
340
  role: 'Coding Assistant',
207
341
  ...inferredProviderModel
208
342
  };
209
- case 'other':
210
- return {
211
- description: `${name} AI profile connected through Taskforce.`,
212
- role: 'AI Assistant',
213
- ...inferredProviderModel
214
- };
215
343
  default:
216
344
  return {};
217
345
  }
@@ -227,7 +355,9 @@ function resolveOptionalProfileField(inputValue, existingValue, fallbackValue) {
227
355
  }
228
356
  function resolveAiProfileMetadataFields(deps, input, existing) {
229
357
  const surfaceTypeInput = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
358
+ const manuallyOverridden = hasManualSurfaceTypeOverride(existing);
230
359
  const effectiveSurfaceType = surfaceTypeInput === undefined
360
+ || manuallyOverridden
231
361
  ? existing?.surfaceType ?? null
232
362
  : surfaceTypeInput;
233
363
  const inferredDefaults = inferAiProfileMetadataDefaults(input.name, effectiveSurfaceType);
@@ -237,7 +367,7 @@ function resolveAiProfileMetadataFields(deps, input, existing) {
237
367
  provider: resolveOptionalProfileField(input.provider, existing?.provider, inferredDefaults.provider) ?? null,
238
368
  model: resolveOptionalProfileField(input.model, existing?.model, inferredDefaults.model) ?? null,
239
369
  surfaceType: effectiveSurfaceType,
240
- surfaceTypeProvided: surfaceTypeInput !== undefined,
370
+ surfaceTypeProvided: surfaceTypeInput !== undefined && !manuallyOverridden,
241
371
  };
242
372
  }
243
373
  function applyAiProfileMetadataDefaults(record) {
@@ -250,7 +380,167 @@ function applyAiProfileMetadataDefaults(record) {
250
380
  model: resolveOptionalProfileField(undefined, record.model, inferredDefaults.model) ?? null,
251
381
  };
252
382
  }
383
+ function backfillMissingAiProfileAvatarFromDuplicate(deps, row) {
384
+ const id = String(row.id || '').trim();
385
+ const workspaceId = String(row.workspace_id || '').trim();
386
+ const name = String(row.name || '').trim();
387
+ const username = String(row.username || '').trim();
388
+ if (!id || !workspaceId || (!name && !username))
389
+ return;
390
+ const currentAvatarUrl = normalizeAiProfileAvatarUrl(row.avatar_url);
391
+ const currentAvatarSourceUrl = normalizeAiProfileAvatarUrl(row.avatar_source_url);
392
+ if (hasAiProfileAvatarState(currentAvatarUrl, currentAvatarSourceUrl))
393
+ return;
394
+ const currentRevision = normalizeAiProfileAvatarRevision(row.avatar_revision);
395
+ const donor = deps.db.prepare(`
396
+ SELECT avatar_url, avatar_source_url, avatar_revision, avatar_updated_at, updated_at
397
+ FROM ai_profiles
398
+ WHERE tenant_id = ?
399
+ AND workspace_id = ?
400
+ AND id <> ?
401
+ AND archived_at IS NULL
402
+ AND (
403
+ (? <> '' AND LOWER(name) = LOWER(?))
404
+ OR (? <> '' AND LOWER(username) = LOWER(?))
405
+ )
406
+ AND (
407
+ (avatar_url IS NOT NULL AND TRIM(avatar_url) <> '')
408
+ OR (avatar_source_url IS NOT NULL AND TRIM(avatar_source_url) <> '')
409
+ )
410
+ ORDER BY COALESCE(avatar_revision, 0) DESC,
411
+ COALESCE(avatar_updated_at, updated_at) DESC,
412
+ id ASC
413
+ LIMIT 1
414
+ `).get(deps.tenantId, workspaceId, id, name, name, username, username);
415
+ if (!donor)
416
+ return;
417
+ const donorRevision = normalizeAiProfileAvatarRevision(donor.avatar_revision);
418
+ if (donorRevision < currentRevision)
419
+ return;
420
+ const donorAvatarUrl = normalizeAiProfileAvatarUrl(donor.avatar_url);
421
+ const donorAvatarSourceUrl = normalizeAiProfileAvatarUrl(donor.avatar_source_url);
422
+ if (!hasAiProfileAvatarState(donorAvatarUrl, donorAvatarSourceUrl))
423
+ return;
424
+ const nextRevision = Math.max(currentRevision, donorRevision, 1);
425
+ const nextAvatarUpdatedAt = normalizeNullableIsoString(donor.avatar_updated_at)
426
+ ?? normalizeNullableIsoString(donor.updated_at)
427
+ ?? new Date().toISOString();
428
+ deps.db.prepare(`
429
+ UPDATE ai_profiles
430
+ SET avatar_url = ?,
431
+ avatar_source_url = ?,
432
+ avatar_revision = ?,
433
+ avatar_updated_at = ?,
434
+ updated_at = ?
435
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
436
+ `).run(donorAvatarUrl, donorAvatarSourceUrl, nextRevision, nextAvatarUpdatedAt, nextAvatarUpdatedAt, deps.tenantId, workspaceId, id);
437
+ row.avatar_url = donorAvatarUrl;
438
+ row.avatar_source_url = donorAvatarSourceUrl;
439
+ row.avatar_revision = nextRevision;
440
+ row.avatar_updated_at = nextAvatarUpdatedAt;
441
+ row.updated_at = nextAvatarUpdatedAt;
442
+ }
443
+ function backfillMissingAiProfileAvatarFromAssetIndex(deps, row) {
444
+ const id = String(row.id || '').trim();
445
+ const workspaceId = String(row.workspace_id || '').trim();
446
+ if (!id || !workspaceId)
447
+ return;
448
+ const currentAvatarUrl = normalizeAiProfileAvatarUrl(row.avatar_url);
449
+ const currentAvatarSourceUrl = normalizeAiProfileAvatarUrl(row.avatar_source_url);
450
+ if (hasAiProfileAvatarState(currentAvatarUrl, currentAvatarSourceUrl))
451
+ return;
452
+ if (normalizeAiProfileAvatarRevision(row.avatar_revision) > 0)
453
+ return;
454
+ if (normalizeNullableIsoString(row.avatar_updated_at))
455
+ return;
456
+ const avatarPrefix = `workspaces/${workspaceId}/assets/images/ai-profiles/${id}/avatar/`;
457
+ try {
458
+ const latestCropped = deps.db.prepare(`
459
+ SELECT storage_key, updated_at, created_at
460
+ FROM workspace_assets
461
+ WHERE tenant_id = ?
462
+ AND workspace_id = ?
463
+ AND deleted_at IS NULL
464
+ AND storage_key LIKE ?
465
+ AND (
466
+ LOWER(original_filename) LIKE 'cropped-%'
467
+ OR LOWER(logical_name) = 'ai profile avatar display'
468
+ OR LOWER(storage_key) LIKE '%/display.webp'
469
+ OR LOWER(storage_key) LIKE '%/display.gif'
470
+ OR LOWER(storage_key) LIKE '%/display.png'
471
+ OR LOWER(storage_key) LIKE '%/display.jpg'
472
+ OR LOWER(storage_key) LIKE '%/display.jpeg'
473
+ OR LOWER(original_filename) = 'avatar.webp'
474
+ OR LOWER(original_filename) = 'avatar.png'
475
+ OR LOWER(original_filename) = 'avatar.jpg'
476
+ OR LOWER(original_filename) = 'avatar.jpeg'
477
+ )
478
+ ORDER BY COALESCE(updated_at, created_at) DESC, storage_key DESC
479
+ LIMIT 1
480
+ `).get(deps.tenantId, workspaceId, `${avatarPrefix}%`);
481
+ if (!latestCropped)
482
+ return;
483
+ const latestSource = deps.db.prepare(`
484
+ SELECT storage_key, updated_at, created_at
485
+ FROM workspace_assets
486
+ WHERE tenant_id = ?
487
+ AND workspace_id = ?
488
+ AND deleted_at IS NULL
489
+ AND storage_key LIKE ?
490
+ AND (
491
+ LOWER(original_filename) LIKE 'source-%'
492
+ OR LOWER(logical_name) = 'ai profile avatar source'
493
+ OR LOWER(storage_key) LIKE '%/source.webp'
494
+ OR LOWER(storage_key) LIKE '%/source.gif'
495
+ OR LOWER(storage_key) LIKE '%/source.png'
496
+ OR LOWER(storage_key) LIKE '%/source.jpg'
497
+ OR LOWER(storage_key) LIKE '%/source.jpeg'
498
+ )
499
+ ORDER BY COALESCE(updated_at, created_at) DESC, storage_key DESC
500
+ LIMIT 1
501
+ `).get(deps.tenantId, workspaceId, `${avatarPrefix}%`);
502
+ const avatarStorageKey = String(latestCropped.storage_key || '').trim();
503
+ if (!avatarStorageKey)
504
+ return;
505
+ const sourceStorageKey = String(latestSource?.storage_key || '').trim();
506
+ const avatarUrl = `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`;
507
+ const avatarSourceUrl = sourceStorageKey
508
+ ? `/api/taskforce/context/${encodeURIComponent(sourceStorageKey)}`
509
+ : null;
510
+ const repairedAt = normalizeNullableIsoString(latestCropped.updated_at)
511
+ ?? normalizeNullableIsoString(latestCropped.created_at)
512
+ ?? new Date().toISOString();
513
+ deps.db.prepare(`
514
+ UPDATE ai_profiles
515
+ SET avatar_url = ?,
516
+ avatar_source_url = ?,
517
+ avatar_revision = 1,
518
+ avatar_updated_at = ?,
519
+ updated_at = CASE
520
+ WHEN updated_at IS NULL OR TRIM(updated_at) = '' OR updated_at < ? THEN ?
521
+ ELSE updated_at
522
+ END
523
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
524
+ AND (avatar_url IS NULL OR TRIM(avatar_url) = '')
525
+ AND (avatar_source_url IS NULL OR TRIM(avatar_source_url) = '')
526
+ AND COALESCE(avatar_revision, 0) = 0
527
+ AND (avatar_updated_at IS NULL OR TRIM(avatar_updated_at) = '')
528
+ `).run(avatarUrl, avatarSourceUrl, repairedAt, repairedAt, repairedAt, deps.tenantId, workspaceId, id);
529
+ row.avatar_url = avatarUrl;
530
+ row.avatar_source_url = avatarSourceUrl;
531
+ row.avatar_revision = 1;
532
+ row.avatar_updated_at = repairedAt;
533
+ if (!normalizeNullableIsoString(row.updated_at) || String(row.updated_at) < repairedAt) {
534
+ row.updated_at = repairedAt;
535
+ }
536
+ }
537
+ catch {
538
+ // Older or mocked stores may not have workspace_assets available.
539
+ }
540
+ }
253
541
  function hydrateAiProfileRecord(deps, row, diagnostics) {
542
+ backfillMissingAiProfileAvatarFromDuplicate(deps, row);
543
+ backfillMissingAiProfileAvatarFromAssetIndex(deps, row);
254
544
  const record = deps.aiProfileRowToRecord(row);
255
545
  const hydrated = applyAiProfileMetadataDefaults(record);
256
546
  const metadataChanged = record.description !== hydrated.description
@@ -286,6 +576,8 @@ export function listAiProfilesService(deps, workspaceId, options) {
286
576
  color,
287
577
  avatar_url,
288
578
  avatar_source_url,
579
+ avatar_revision,
580
+ avatar_updated_at,
289
581
  description,
290
582
  role,
291
583
  provider,
@@ -358,6 +650,9 @@ export function upsertAiProfileForSyncService(deps, profile) {
358
650
  const existingProfile = existingRow ? deps.aiProfileRowToRecord(existingRow) : null;
359
651
  const incomingAvatarUrlProvided = Object.prototype.hasOwnProperty.call(profile, 'avatarUrl');
360
652
  const incomingAvatarSourceUrlProvided = Object.prototype.hasOwnProperty.call(profile, 'avatarSourceUrl');
653
+ const incomingAvatarRevisionProvided = Object.prototype.hasOwnProperty.call(profile, 'avatarRevision');
654
+ const incomingAvatarRevision = normalizeAiProfileAvatarRevision(profile.avatarRevision);
655
+ const incomingAvatarUpdatedAt = normalizeNullableIsoString(profile.avatarUpdatedAt);
361
656
  const existingCreatedAt = existingRow
362
657
  ? String(existingRow.created_at || existingRow.updated_at || '').trim()
363
658
  : '';
@@ -386,6 +681,8 @@ export function upsertAiProfileForSyncService(deps, profile) {
386
681
  updatedAt: existingUpdatedAt,
387
682
  })
388
683
  : null;
684
+ const existingAvatarRevision = normalizeAiProfileAvatarRevision(existingRow?.avatar_revision);
685
+ const existingAvatarUpdatedAt = normalizeNullableIsoString(existingRow?.avatar_updated_at);
389
686
  const metadataComparison = compareIsoStrings(normalizedUpdatedAt, existingUpdatedAt);
390
687
  const incomingMetadataWins = !existingRow || metadataComparison > 0;
391
688
  const lifecycleComparison = compareIsoStrings(normalizedRosterStateUpdatedAt, existingRosterStateUpdatedAt);
@@ -447,13 +744,47 @@ export function upsertAiProfileForSyncService(deps, profile) {
447
744
  const mergedUsername = metadataSourceProfile?.username ?? deps.normalizeAiProfileUsername(profile.username, profile.name);
448
745
  const mergedIcon = metadataSourceProfile?.icon ?? deps.normalizeAiProfileIcon(profile.icon);
449
746
  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);
747
+ const existingAvatarUrl = normalizeAiProfileAvatarUrl(existingProfile?.avatarUrl);
748
+ const existingAvatarSourceUrl = normalizeAiProfileAvatarUrl(existingProfile?.avatarSourceUrl);
749
+ const incomingAvatarUrl = incomingAvatarUrlProvided ? normalizeAiProfileAvatarUrl(profile.avatarUrl) : null;
750
+ const incomingAvatarSourceUrl = incomingAvatarSourceUrlProvided ? normalizeAiProfileAvatarUrl(profile.avatarSourceUrl) : null;
751
+ const incomingHasAvatarState = hasAiProfileAvatarState(incomingAvatarUrl, incomingAvatarSourceUrl);
752
+ const existingHasAvatarState = hasAiProfileAvatarState(existingAvatarUrl, existingAvatarSourceUrl);
753
+ let mergedAvatarUrl = existingAvatarUrl;
754
+ let mergedAvatarSourceUrl = existingAvatarSourceUrl;
755
+ let mergedAvatarRevision = existingAvatarRevision;
756
+ let mergedAvatarUpdatedAt = existingAvatarUpdatedAt;
757
+ if (!existingRow) {
758
+ mergedAvatarUrl = incomingAvatarUrlProvided ? incomingAvatarUrl : null;
759
+ mergedAvatarSourceUrl = incomingAvatarSourceUrlProvided ? incomingAvatarSourceUrl : null;
760
+ mergedAvatarRevision = incomingAvatarRevisionProvided ? incomingAvatarRevision : (incomingHasAvatarState ? 1 : 0);
761
+ mergedAvatarUpdatedAt = incomingAvatarUpdatedAt ?? (incomingHasAvatarState ? normalizedUpdatedAt : null);
762
+ }
763
+ else if (incomingAvatarRevisionProvided) {
764
+ if (incomingAvatarRevision > existingAvatarRevision) {
765
+ const incomingClearsAvatar = !incomingHasAvatarState && existingHasAvatarState;
766
+ const clearHasAvatarTimestamp = Boolean(incomingAvatarUpdatedAt);
767
+ if (!incomingClearsAvatar || clearHasAvatarTimestamp) {
768
+ mergedAvatarUrl = incomingAvatarUrlProvided ? incomingAvatarUrl : existingAvatarUrl;
769
+ mergedAvatarSourceUrl = incomingAvatarSourceUrlProvided ? incomingAvatarSourceUrl : existingAvatarSourceUrl;
770
+ mergedAvatarRevision = incomingAvatarRevision;
771
+ mergedAvatarUpdatedAt = incomingAvatarUpdatedAt ?? normalizedUpdatedAt;
772
+ }
773
+ }
774
+ else if (incomingAvatarRevision === existingAvatarRevision) {
775
+ if (!existingHasAvatarState && incomingHasAvatarState) {
776
+ mergedAvatarUrl = incomingAvatarUrl;
777
+ mergedAvatarSourceUrl = incomingAvatarSourceUrl;
778
+ mergedAvatarUpdatedAt = incomingAvatarUpdatedAt ?? existingAvatarUpdatedAt ?? normalizedUpdatedAt;
779
+ }
780
+ }
781
+ }
782
+ else if (!existingHasAvatarState && incomingHasAvatarState) {
783
+ mergedAvatarUrl = incomingAvatarUrl;
784
+ mergedAvatarSourceUrl = incomingAvatarSourceUrl;
785
+ mergedAvatarRevision = Math.max(existingAvatarRevision, 1);
786
+ mergedAvatarUpdatedAt = existingAvatarUpdatedAt ?? normalizedUpdatedAt;
787
+ }
457
788
  const mergedProviderMetadata = incomingMetadataWins
458
789
  ? (profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
459
790
  ? JSON.stringify(profile.providerMetadata)
@@ -474,10 +805,11 @@ export function upsertAiProfileForSyncService(deps, profile) {
474
805
  deps.db.prepare(`
475
806
  INSERT INTO ai_profiles (
476
807
  id, tenant_id, workspace_id, profile_token, name, username, icon, color,
477
- avatar_url, avatar_source_url, description, role, provider, model, surface_type, seat_scope, provider_metadata_json,
808
+ avatar_url, avatar_source_url, avatar_revision, avatar_updated_at,
809
+ description, role, provider, model, surface_type, seat_scope, provider_metadata_json,
478
810
  archived_at, archived_by, archived_reason, merged_into_profile_id,
479
811
  roster_state_updated_at, created_at, updated_at, last_active_at
480
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
812
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
481
813
  ON CONFLICT(id) DO UPDATE SET
482
814
  workspace_id = excluded.workspace_id,
483
815
  profile_token = excluded.profile_token,
@@ -487,6 +819,8 @@ export function upsertAiProfileForSyncService(deps, profile) {
487
819
  color = excluded.color,
488
820
  avatar_url = excluded.avatar_url,
489
821
  avatar_source_url = excluded.avatar_source_url,
822
+ avatar_revision = excluded.avatar_revision,
823
+ avatar_updated_at = excluded.avatar_updated_at,
490
824
  description = excluded.description,
491
825
  role = excluded.role,
492
826
  provider = excluded.provider,
@@ -502,7 +836,7 @@ export function upsertAiProfileForSyncService(deps, profile) {
502
836
  created_at = excluded.created_at,
503
837
  updated_at = excluded.updated_at,
504
838
  last_active_at = excluded.last_active_at
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);
839
+ `).run(profileId, deps.tenantId, normalizedWorkspaceId, mergedProfileToken, mergedName, mergedUsername, mergedIcon, mergedColor, mergedAvatarUrl, mergedAvatarSourceUrl, mergedAvatarRevision, mergedAvatarUpdatedAt, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, resolvedSeatScope.seatScope, mergedProviderMetadata, mergedArchivedAt, mergedArchivedBy, mergedArchivedReason, mergedMergedIntoProfileId, mergedRosterStateUpdatedAt, mergedCreatedAt, mergedUpdatedAt, mergedLastActiveAt);
506
840
  const refreshed = deps.db.prepare(`
507
841
  SELECT *
508
842
  FROM ai_profiles
@@ -534,6 +868,17 @@ export function mergeAiProfilesService(deps, input) {
534
868
  throw deps.createRuleError(`Profile ${mergeId} not found`, 404, 'MERGE_PROFILES_MERGE_NOT_FOUND');
535
869
  const keepRecord = deps.aiProfileRowToRecord(keepProfile);
536
870
  const mergeRecord = deps.aiProfileRowToRecord(mergeProfile);
871
+ const keepSeatScope = keepRecord.seatScope ?? null;
872
+ const mergeSeatScope = mergeRecord.seatScope ?? null;
873
+ if (keepSeatScope && mergeSeatScope && keepSeatScope !== mergeSeatScope) {
874
+ throw deps.createRuleError('Local and cloud AI profiles are separate registrations and cannot be merged. Retire one from the roster instead.', 409, 'AI_PROFILE_SEAT_SCOPE_MISMATCH', {
875
+ workspaceId: normalizedWorkspaceId,
876
+ keepId,
877
+ mergeId,
878
+ keepSeatScope,
879
+ mergeSeatScope,
880
+ });
881
+ }
537
882
  const mergedSeatScope = resolveAiProfileSeatScope(deps, mergeRecord.seatScope, keepRecord);
538
883
  const mergedAt = new Date().toISOString();
539
884
  const doMerge = deps.db.transaction(() => {
@@ -598,14 +943,41 @@ export function updateAiProfileAvatarService(deps, input) {
598
943
  if (typeof input.avatarSourceUrl === 'string' && input.avatarSourceUrl.trim().length > 0 && !avatarSourceUrl) {
599
944
  throw deps.createRuleError('avatarSourceUrl must be a Taskforce context URL', 400, 'AI_PROFILE_AVATAR_SOURCE_URL_INVALID');
600
945
  }
946
+ const currentRevision = normalizeAiProfileAvatarRevision(existing.avatar_revision);
947
+ const nextRevision = currentRevision + 1;
601
948
  const updatedAt = new Date().toISOString();
602
949
  deps.db.prepare(`
603
950
  UPDATE ai_profiles
604
951
  SET avatar_url = ?,
605
952
  avatar_source_url = ?,
953
+ avatar_revision = ?,
954
+ avatar_updated_at = ?,
606
955
  updated_at = ?
607
956
  WHERE tenant_id = ? AND workspace_id = ? AND id = ?
608
- `).run(avatarUrl, avatarSourceUrl, updatedAt, deps.tenantId, normalizedWorkspaceId, profileId);
957
+ `).run(avatarUrl, avatarSourceUrl, nextRevision, updatedAt, updatedAt, deps.tenantId, normalizedWorkspaceId, profileId);
958
+ if (avatarUrl) {
959
+ deps.db.prepare(`
960
+ UPDATE ai_profiles
961
+ SET avatar_url = ?,
962
+ avatar_source_url = ?,
963
+ avatar_revision = CASE
964
+ WHEN COALESCE(avatar_revision, 0) < ? THEN ?
965
+ ELSE COALESCE(avatar_revision, 0)
966
+ END,
967
+ avatar_updated_at = ?,
968
+ updated_at = ?
969
+ WHERE tenant_id = ?
970
+ AND workspace_id = ?
971
+ AND id <> ?
972
+ AND archived_at IS NULL
973
+ AND (
974
+ LOWER(name) = LOWER(?)
975
+ OR LOWER(username) = LOWER(?)
976
+ )
977
+ AND (avatar_url IS NULL OR TRIM(avatar_url) = '')
978
+ AND (avatar_source_url IS NULL OR TRIM(avatar_source_url) = '')
979
+ `).run(avatarUrl, avatarSourceUrl, nextRevision, nextRevision, updatedAt, updatedAt, deps.tenantId, normalizedWorkspaceId, profileId, String(existing.name || '').trim(), deps.normalizeAiProfileUsername(existing.username, String(existing.name || '').trim()));
980
+ }
609
981
  const refreshed = deps.db.prepare(`
610
982
  SELECT *
611
983
  FROM ai_profiles
@@ -617,6 +989,46 @@ export function updateAiProfileAvatarService(deps, input) {
617
989
  }
618
990
  return hydrateAiProfileRecord(deps, refreshed);
619
991
  }
992
+ export function updateAiProfileSurfaceTypeService(deps, input) {
993
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
994
+ const profileId = String(input.profileId || '').trim();
995
+ if (!profileId)
996
+ throw deps.createRuleError('profileId is required', 400, 'AI_PROFILE_ID_REQUIRED');
997
+ const surfaceType = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
998
+ if (surfaceType === undefined) {
999
+ throw deps.createRuleError('surfaceType is required', 400, 'AI_PROFILE_SURFACE_TYPE_REQUIRED');
1000
+ }
1001
+ const existing = deps.db.prepare(`
1002
+ SELECT *
1003
+ FROM ai_profiles
1004
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ? AND archived_at IS NULL
1005
+ LIMIT 1
1006
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
1007
+ if (!existing) {
1008
+ throw deps.createRuleError(`Profile ${profileId} not found`, 404, 'AI_PROFILE_NOT_FOUND');
1009
+ }
1010
+ const providerMetadataJson = surfaceType === null
1011
+ ? clearManualSurfaceTypeOverride(existing)
1012
+ : markManualSurfaceTypeOverride(existing);
1013
+ const updatedAt = new Date().toISOString();
1014
+ deps.db.prepare(`
1015
+ UPDATE ai_profiles
1016
+ SET surface_type = ?,
1017
+ provider_metadata_json = ?,
1018
+ updated_at = ?
1019
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1020
+ `).run(surfaceType, providerMetadataJson, updatedAt, deps.tenantId, normalizedWorkspaceId, profileId);
1021
+ const refreshed = deps.db.prepare(`
1022
+ SELECT *
1023
+ FROM ai_profiles
1024
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1025
+ LIMIT 1
1026
+ `).get(deps.tenantId, normalizedWorkspaceId, profileId);
1027
+ if (!refreshed) {
1028
+ throw deps.createRuleError('Failed to update AI profile category', 500, 'AI_PROFILE_SURFACE_TYPE_UPDATE_FAILED');
1029
+ }
1030
+ return hydrateAiProfileRecord(deps, refreshed);
1031
+ }
620
1032
  export function archiveAiProfileService(deps, input) {
621
1033
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
622
1034
  const profileId = String(input.profileId || '').trim();
@@ -697,12 +1109,7 @@ export function resolveAiProfileService(deps, input, options) {
697
1109
  ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
698
1110
  `).all(deps.tenantId, normalizedWorkspaceId, profileToken);
699
1111
  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 {
1112
+ if (archivedRow) {
706
1113
  const existingProfile = deps.aiProfileRowToRecord(archivedRow);
707
1114
  const nextName = name;
708
1115
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
@@ -769,6 +1176,9 @@ export function resolveAiProfileService(deps, input, options) {
769
1176
  profile: hydrateAiProfileRecord(deps, refreshed)
770
1177
  };
771
1178
  }
1179
+ // Treat token misses as recoverable when the caller also supplied profile
1180
+ // identity metadata; the name/seat-scope resolver below can bind stale
1181
+ // reconnects without minting duplicates.
772
1182
  }
773
1183
  else {
774
1184
  const existingProfile = deps.aiProfileRowToRecord(existingRow);
@@ -833,6 +1243,21 @@ export function resolveAiProfileService(deps, input, options) {
833
1243
  };
834
1244
  }
835
1245
  }
1246
+ const identityDescription = normalizeOptionalProfileField(input.description);
1247
+ const identityRole = normalizeOptionalProfileField(input.role);
1248
+ const inferredIdentity = inferAiProfileProviderModelDefaults(name);
1249
+ const identityProvider = normalizeOptionalProfileField(input.provider)
1250
+ ?? normalizeOptionalProfileField(inferredIdentity.provider);
1251
+ const identityModel = normalizeOptionalProfileField(input.model)
1252
+ ?? normalizeOptionalProfileField(inferredIdentity.model);
1253
+ const identityMetadata = resolveAiProfileMetadataFields(deps, {
1254
+ name,
1255
+ description: identityDescription,
1256
+ role: identityRole,
1257
+ provider: identityProvider,
1258
+ model: identityModel,
1259
+ surfaceType: input.surfaceType,
1260
+ }, null);
836
1261
  const exactMatches = deps.db.prepare(`
837
1262
  SELECT *
838
1263
  FROM ai_profiles
@@ -845,9 +1270,9 @@ export function resolveAiProfileService(deps, input, options) {
845
1270
  )
846
1271
  ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
847
1272
  `).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();
1273
+ const exactMatch = selectAiProfileIdentityMatch(deps, exactMatches, identityMetadata, trustedSeatScope);
1274
+ if (exactMatch.row) {
1275
+ const canonicalId = String(exactMatch.row.id || '').trim();
851
1276
  const nextName = name;
852
1277
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
853
1278
  const nextIcon = deps.normalizeAiProfileIcon(input.icon);
@@ -856,7 +1281,7 @@ export function resolveAiProfileService(deps, input, options) {
856
1281
  const nextRole = normalizeOptionalProfileField(input.role);
857
1282
  const nextProvider = normalizeOptionalProfileField(input.provider);
858
1283
  const nextModel = normalizeOptionalProfileField(input.model);
859
- const existingProfile = deps.aiProfileRowToRecord(scopedExactMatches[0]);
1284
+ const existingProfile = deps.aiProfileRowToRecord(exactMatch.row);
860
1285
  const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
861
1286
  name: nextName,
862
1287
  description: nextDescription,
@@ -903,7 +1328,7 @@ export function resolveAiProfileService(deps, input, options) {
903
1328
  return {
904
1329
  created: false,
905
1330
  profile: hydrateAiProfileRecord(deps, reused),
906
- ...(scopedExactMatches.length > 1 ? {
1331
+ ...(exactMatch.matchCount > 1 ? {
907
1332
  duplicateWarning: {
908
1333
  existingProfileId: canonicalId,
909
1334
  message: `Multiple profiles matched "${name}". Reused ${canonicalId}; merge the remaining duplicates.`
@@ -923,9 +1348,9 @@ export function resolveAiProfileService(deps, input, options) {
923
1348
  )
924
1349
  ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
925
1350
  `).all(deps.tenantId, normalizedWorkspaceId, name, deps.normalizeAiProfileUsername(undefined, name));
926
- const scopedArchivedMatches = filterAiProfileRowsForTrustedSeatScope(deps, archivedMatches, trustedSeatScope);
927
- if (scopedArchivedMatches.length > 0) {
928
- const canonicalId = String(scopedArchivedMatches[0].id || '').trim();
1351
+ const archivedMatch = selectAiProfileIdentityMatch(deps, archivedMatches, identityMetadata, trustedSeatScope);
1352
+ if (archivedMatch.row) {
1353
+ const canonicalId = String(archivedMatch.row.id || '').trim();
929
1354
  const nextName = name;
930
1355
  const nextUsername = deps.normalizeAiProfileUsername(undefined, nextName);
931
1356
  const nextIcon = deps.normalizeAiProfileIcon(input.icon);
@@ -934,7 +1359,7 @@ export function resolveAiProfileService(deps, input, options) {
934
1359
  const nextRole = normalizeOptionalProfileField(input.role);
935
1360
  const nextProvider = normalizeOptionalProfileField(input.provider);
936
1361
  const nextModel = normalizeOptionalProfileField(input.model);
937
- const existingProfile = deps.aiProfileRowToRecord(scopedArchivedMatches[0]);
1362
+ const existingProfile = deps.aiProfileRowToRecord(archivedMatch.row);
938
1363
  const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
939
1364
  name: nextName,
940
1365
  description: nextDescription,