@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
@@ -138,6 +138,7 @@ vi.mock('../core/Taskforce', () => {
138
138
  listMcpAccessTokens = vi.fn().mockReturnValue({ items: [], total: 0, page: 1, pageSize: 20 });
139
139
  createMcpAccessToken = vi.fn();
140
140
  revokeMcpAccessToken = vi.fn();
141
+ deleteMcpAccessToken = vi.fn();
141
142
  regenerateMcpAccessToken = vi.fn();
142
143
  inspectMcpAccessToken = vi.fn();
143
144
  authenticateMcpAccessToken = vi.fn();
@@ -164,12 +165,14 @@ vi.mock('../core/Taskforce', () => {
164
165
  countAiProfileUsage = vi.fn().mockReturnValue({ used: 0, limit: null, remaining: null });
165
166
  mergeAiProfiles = vi.fn();
166
167
  archiveAiProfile = vi.fn();
168
+ updateAiProfileAvatar = vi.fn();
167
169
  replaceWorkspaceUsersForSync = vi.fn().mockReturnValue([]);
168
170
  listDocumentReviewSessionsForSync = vi.fn().mockReturnValue([]);
169
171
  listDocumentReviewCommentsForSync = vi.fn().mockReturnValue([]);
170
172
  listAnnotatedAttachmentSessionsForSync = vi.fn().mockReturnValue([]);
171
173
  listTaskEventsForSync = vi.fn().mockReturnValue([]);
172
174
  listTaskEvents = vi.fn().mockReturnValue([]);
175
+ recordTaskEvent = vi.fn();
173
176
  getDocumentReviewSession = vi.fn().mockReturnValue(null);
174
177
  upsertAiProfileForSync = vi.fn();
175
178
  upsertDocumentReviewSessionForSync = vi.fn();
@@ -829,13 +832,15 @@ describe('Server Routes', () => {
829
832
  }));
830
833
  expect(res.end).toHaveBeenCalledWith();
831
834
  });
832
- it('POST /mcp should accept authenticated initialize requests and record token usage', async () => {
835
+ it('POST /mcp should accept authenticated initialize requests, record token usage, and close the request runtime', async () => {
836
+ const serverClose = vi.fn().mockResolvedValue(undefined);
833
837
  const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
834
838
  server: {
835
839
  connect: vi.fn().mockResolvedValue(undefined),
836
- close: vi.fn().mockResolvedValue(undefined)
840
+ close: serverClose
837
841
  }
838
842
  });
843
+ const closeTransportSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'close').mockResolvedValue(undefined);
839
844
  const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockImplementation(async (_req, res, parsedBody) => {
840
845
  res.writeHead(200, { 'Content-Type': 'application/json' });
841
846
  res.end(JSON.stringify({
@@ -878,6 +883,8 @@ describe('Server Routes', () => {
878
883
  expect(match).not.toBeNull();
879
884
  await match.handler(req, res, match.params);
880
885
  expect(core.authenticateMcpAccessToken).toHaveBeenCalledWith('tfmcp_test_token');
886
+ const runtimeOptions = createServerSpy.mock.calls[0]?.[0];
887
+ expect(runtimeOptions?.core).toBe(core);
881
888
  expect(createServerSpy).toHaveBeenCalledWith(expect.objectContaining({
882
889
  workspaceId: 'workspace-1',
883
890
  runtimeMode: 'cloud',
@@ -907,6 +914,62 @@ describe('Server Routes', () => {
907
914
  })
908
915
  })
909
916
  }));
917
+ expect(closeTransportSpy).not.toHaveBeenCalled();
918
+ expect(serverClose).toHaveBeenCalledTimes(1);
919
+ closeTransportSpy.mockRestore();
920
+ handleRequestSpy.mockRestore();
921
+ createServerSpy.mockRestore();
922
+ });
923
+ it('POST /mcp should distinguish transport failures from tool backend failures', async () => {
924
+ const serverClose = vi.fn().mockResolvedValue(undefined);
925
+ const createServerSpy = vi.spyOn(mcpRuntime, 'createTaskforceMcpServer').mockReturnValue({
926
+ server: {
927
+ connect: vi.fn().mockResolvedValue(undefined),
928
+ close: serverClose
929
+ }
930
+ });
931
+ const handleRequestSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'handleRequest').mockRejectedValue(new Error('transport broke'));
932
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
933
+ core.authenticateMcpAccessToken.mockReturnValue({
934
+ tokenId: 'token-transport-1',
935
+ userId: 'user-1',
936
+ workspaceId: 'workspace-1',
937
+ scopes: ['tasks:read', 'workspace:read'],
938
+ status: 'active'
939
+ });
940
+ const match = matchRoute('POST', '/mcp', routes);
941
+ const req = createMockReq('POST', '/mcp', {
942
+ jsonrpc: '2.0',
943
+ id: 2,
944
+ method: 'tools/call',
945
+ params: {
946
+ name: 'get_workstream',
947
+ arguments: { id: 'WS-1' }
948
+ }
949
+ }, {
950
+ 'x-taskforce-runtime-mode': 'cloud',
951
+ authorization: 'Bearer tfmcp_transport_token',
952
+ accept: 'application/json, text/event-stream',
953
+ 'content-type': 'application/json',
954
+ 'x-request-id': 'req-mcp-transport'
955
+ });
956
+ const res = createMockRes();
957
+ expect(match).not.toBeNull();
958
+ await match.handler(req, res, match.params);
959
+ expect(res.statusCode).toBe(500);
960
+ expect(JSON.parse(res.__getBody())).toEqual(expect.objectContaining({
961
+ jsonrpc: '2.0',
962
+ error: expect.objectContaining({
963
+ message: 'MCP transport failure.',
964
+ data: expect.objectContaining({
965
+ kind: 'tool_transport_failure',
966
+ retryable: true,
967
+ requestId: 'req-mcp-transport'
968
+ })
969
+ })
970
+ }));
971
+ expect(serverClose).toHaveBeenCalledTimes(1);
972
+ consoleErrorSpy.mockRestore();
910
973
  handleRequestSpy.mockRestore();
911
974
  createServerSpy.mockRestore();
912
975
  });
@@ -1090,7 +1153,10 @@ describe('Server Routes', () => {
1090
1153
  clientId: 'client-1',
1091
1154
  provider: 'chatgpt',
1092
1155
  scopes: ['tasks:read'],
1093
- status: 'active'
1156
+ status: 'active',
1157
+ aiProfileId: 'ai-profile-1',
1158
+ aiProfileToken: 'tfp_connector_codex',
1159
+ aiProfileName: 'Codex Connector'
1094
1160
  });
1095
1161
  const match = matchRoute('POST', '/mcp', routes);
1096
1162
  const req = createMockReq('POST', '/mcp', {
@@ -1118,7 +1184,9 @@ describe('Server Routes', () => {
1118
1184
  scopes: ['tasks:read'],
1119
1185
  actorType: 'oauth',
1120
1186
  tokenId: 'grant-1',
1121
- userId: 'user-oauth'
1187
+ userId: 'user-oauth',
1188
+ aiProfileToken: 'tfp_connector_codex',
1189
+ aiProfileName: 'Codex Connector'
1122
1190
  })
1123
1191
  }));
1124
1192
  handleRequestSpy.mockRestore();
@@ -1572,6 +1640,43 @@ describe('Server Routes', () => {
1572
1640
  items: expect.any(Array)
1573
1641
  }));
1574
1642
  });
1643
+ it('DELETE /api/taskforce/settings/mcp/tokens/:id should delete an inactive token for the signed-in user', async () => {
1644
+ const authConfig = {
1645
+ runtimeMode: 'cloud',
1646
+ enabled: true,
1647
+ requiredForApi: true,
1648
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
1649
+ defaultRole: 'member',
1650
+ defaultWorkspaceId: 'workspace-1',
1651
+ sessionCookieName: 'taskforce_session',
1652
+ sessionSecret: 'test-secret',
1653
+ sessionTtlSeconds: 3600
1654
+ };
1655
+ routes = createRoutes(core, { authConfig });
1656
+ core.deleteMcpAccessToken.mockReturnValue(true);
1657
+ const cookie = createSessionTokenCookie('session-mcp-token-delete', authConfig, {
1658
+ role: 'member',
1659
+ workspaceId: 'workspace-1',
1660
+ userId: 'user-1'
1661
+ });
1662
+ const match = matchRoute('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', routes);
1663
+ const req = createMockReq('DELETE', '/api/taskforce/settings/mcp/tokens/token-1', undefined, {
1664
+ cookie,
1665
+ 'x-taskforce-runtime-mode': 'cloud'
1666
+ });
1667
+ const res = createMockRes();
1668
+ expect(match).not.toBeNull();
1669
+ await match.handler(req, res, { id: 'token-1' });
1670
+ expect(core.deleteMcpAccessToken).toHaveBeenCalledWith({
1671
+ actorUserId: 'user-1',
1672
+ workspaceId: 'workspace-1',
1673
+ tokenId: 'token-1'
1674
+ });
1675
+ expect(JSON.parse(res.__getBody())).toEqual({
1676
+ success: true,
1677
+ deleted: true
1678
+ });
1679
+ });
1575
1680
  it('POST /api/taskforce/global-settings should update global settings', async () => {
1576
1681
  const payload = { theme: 'light', shortcut: 'Ctrl+Shift+L' };
1577
1682
  const match = matchRoute('POST', '/api/taskforce/global-settings', routes);
@@ -1639,6 +1744,49 @@ describe('Server Routes', () => {
1639
1744
  });
1640
1745
  expect(getAfterRes.end).toHaveBeenCalledWith(expect.stringContaining('"sourceOfTruth":"cloud"'));
1641
1746
  });
1747
+ it('POST /api/taskforce/ui-state should accept large workspace-sync baselines', async () => {
1748
+ const match = matchRoute('POST', '/api/taskforce/ui-state', routes);
1749
+ const largeWatermark = 'x'.repeat(1024 * 1024 + 1);
1750
+ const req = createMockReq('POST', '/api/taskforce/ui-state', {
1751
+ stateKey: 'workspace-sync',
1752
+ workspaceId: 'workspace-large-baseline',
1753
+ patch: {
1754
+ version: 2,
1755
+ enabled: true,
1756
+ phase: 'active',
1757
+ pushBaseline: {
1758
+ taskIds: ['task-1'],
1759
+ taskWatermarks: [['task-1', largeWatermark]],
1760
+ taskChangeKeys: [],
1761
+ initiativeWatermarks: [],
1762
+ workstreamWatermarks: [],
1763
+ aiProfileWatermarks: [],
1764
+ aiProfileChangeKeys: [],
1765
+ documentWatermarks: [],
1766
+ assetWatermarks: [],
1767
+ documentReviewSessionWatermarks: [],
1768
+ documentReviewCommentWatermarks: [],
1769
+ annotatedAttachmentSessionWatermarks: [],
1770
+ taskEventWatermarks: [],
1771
+ taxonomyStateFingerprint: ''
1772
+ }
1773
+ }
1774
+ }, {
1775
+ 'x-taskforce-runtime-mode': 'local',
1776
+ 'x-taskforce-workspace-id': 'workspace-current'
1777
+ });
1778
+ const res = createMockRes();
1779
+ await match?.handler(req, res, {});
1780
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1781
+ expect(core.updateUiState).toHaveBeenCalledWith('workspace-large-baseline', 'anonymous', {
1782
+ stateKey: 'workspace-sync',
1783
+ patch: expect.objectContaining({
1784
+ pushBaseline: expect.objectContaining({
1785
+ taskWatermarks: [['task-1', largeWatermark]]
1786
+ })
1787
+ })
1788
+ });
1789
+ });
1642
1790
  it('GET /api/taskforce/ui-state should ignore query workspace overrides in cloud mode', async () => {
1643
1791
  core.getUiState.mockReturnValue({ activeWorkspaceModule: 'tasks' });
1644
1792
  const match = matchRoute('GET', '/api/taskforce/ui-state', routes);
@@ -2242,6 +2390,50 @@ describe('Server Routes', () => {
2242
2390
  expect(String(fetchMock.mock.calls[0][0])).toContain('https://example.r2.cloudflarestorage.com/taskforce-docs/taskforce/users/user-1/profile/avatar/avatar-draft-1-headshot.webp');
2243
2391
  vi.unstubAllGlobals();
2244
2392
  });
2393
+ it('GET /api/taskforce/context/:filename should serve synced local AI avatars when R2 object is missing', async () => {
2394
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-ai-avatar-r2-fallback-'));
2395
+ const projectRoot = fs.realpathSync(tmpRoot);
2396
+ const basePath = path.join(projectRoot, '.taskforce');
2397
+ const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
2398
+ const absolutePath = path.join(basePath, relativePath);
2399
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
2400
+ fs.writeFileSync(absolutePath, Buffer.from('synced-ai-avatar'));
2401
+ core.getPaths.mockReturnValue({ projectRoot, basePath });
2402
+ const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 404 }));
2403
+ vi.stubGlobal('fetch', fetchMock);
2404
+ try {
2405
+ const routesWithStorage = createRoutes(core, {
2406
+ objectStorage: {
2407
+ provider: 'r2',
2408
+ r2: {
2409
+ accountId: 'acct-1',
2410
+ bucket: 'taskforce-docs',
2411
+ endpoint: 'https://example.r2.cloudflarestorage.com',
2412
+ accessKeyId: 'access-key-1',
2413
+ secretAccessKey: 'secret-key-1',
2414
+ keyPrefix: 'taskforce',
2415
+ signedUploadTtlSeconds: 600,
2416
+ signedDownloadTtlSeconds: 300
2417
+ }
2418
+ }
2419
+ });
2420
+ const encoded = encodeURIComponent(relativePath);
2421
+ const req = createMockReq('GET', `/api/taskforce/context/${encoded}`);
2422
+ const res = createMockRes();
2423
+ await matchRoute('GET', `/api/taskforce/context/${encoded}`, routesWithStorage)?.handler(req, res, { filename: encoded });
2424
+ expect(fetchMock).toHaveBeenCalled();
2425
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
2426
+ 'Content-Type': 'image/webp',
2427
+ 'Content-Disposition': 'inline'
2428
+ }));
2429
+ expect(String(res.getHeader('location') || '')).toBe('');
2430
+ expect(res.__getBody()).toBe('synced-ai-avatar');
2431
+ }
2432
+ finally {
2433
+ vi.unstubAllGlobals();
2434
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
2435
+ }
2436
+ });
2245
2437
  it('GET /api/taskforce/context/:filename should proxy missing cloud-owned user avatars in local runtime', async () => {
2246
2438
  const previousCloudProxyBaseUrl = process.env.TASKFORCE_CLOUD_PROXY_BASE_URL;
2247
2439
  const previousClientId = process.env.TASKFORCE_CF_ACCESS_CLIENT_ID;
@@ -3215,6 +3407,53 @@ describe('Server Routes', () => {
3215
3407
  vi.unstubAllGlobals();
3216
3408
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3217
3409
  });
3410
+ it('POST /api/taskforce/context-upload/finalize should record direct AI avatar uploads instead of fast-finalizing', async () => {
3411
+ const fetchMock = vi.fn().mockResolvedValue({
3412
+ ok: true,
3413
+ status: 200,
3414
+ statusText: 'OK',
3415
+ headers: { get: () => 'image/webp' },
3416
+ arrayBuffer: async () => new TextEncoder().encode('avatar-bytes').buffer
3417
+ });
3418
+ vi.stubGlobal('fetch', fetchMock);
3419
+ const routesWithStorage = createRoutes(core, {
3420
+ objectStorage: {
3421
+ provider: 'r2',
3422
+ r2: {
3423
+ accountId: 'acct-1',
3424
+ bucket: 'taskforce-docs',
3425
+ endpoint: 'https://example.r2.cloudflarestorage.com',
3426
+ accessKeyId: 'access-key-1',
3427
+ secretAccessKey: 'secret-key-1',
3428
+ keyPrefix: 'taskforce',
3429
+ signedUploadTtlSeconds: 600,
3430
+ signedDownloadTtlSeconds: 300
3431
+ }
3432
+ }
3433
+ });
3434
+ const relativePath = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-avatar.webp';
3435
+ const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
3436
+ const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
3437
+ relativePath,
3438
+ mimeType: 'image/webp',
3439
+ originalName: 'avatar.webp',
3440
+ fastFinalize: true
3441
+ }, {
3442
+ 'x-taskforce-workspace-id': 'workspace-1'
3443
+ });
3444
+ const res = createMockRes();
3445
+ await match?.handler(req, res, {});
3446
+ expect(fetchMock).toHaveBeenCalled();
3447
+ expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
3448
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
3449
+ expect(payload).toEqual(expect.objectContaining({
3450
+ success: true,
3451
+ fsPath: relativePath,
3452
+ path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
3453
+ }));
3454
+ expect(payload.mode).not.toBe('direct-fast');
3455
+ vi.unstubAllGlobals();
3456
+ });
3218
3457
  it('POST /api/taskforce/context-upload/finalize should mirror upload metadata into workspace_assets', async () => {
3219
3458
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-context-finalize-canonical-'));
3220
3459
  core.getPaths.mockReturnValue({
@@ -3248,8 +3487,21 @@ describe('Server Routes', () => {
3248
3487
  }
3249
3488
  });
3250
3489
  const match = matchRoute('POST', '/api/taskforce/context-upload/finalize', routesWithStorage);
3490
+ const attachmentPath = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Ffiles%2Fasset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
3491
+ const attachmentFsPath = 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf';
3492
+ core.getTask
3493
+ .mockImplementationOnce(() => ({ id: 'task-1', attachments: [] }))
3494
+ .mockImplementationOnce(() => ({
3495
+ id: 'task-1',
3496
+ attachments: [{
3497
+ path: attachmentPath,
3498
+ fsPath: attachmentFsPath,
3499
+ caption: 'spec.pdf',
3500
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
3501
+ }]
3502
+ }));
3251
3503
  const req = createMockReq('POST', '/api/taskforce/context-upload/finalize', {
3252
- relativePath: 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3504
+ relativePath: attachmentFsPath,
3253
3505
  mimeType: 'application/pdf',
3254
3506
  taskId: 'task-1',
3255
3507
  originalName: 'spec.pdf'
@@ -3261,8 +3513,8 @@ describe('Server Routes', () => {
3261
3513
  const payload = JSON.parse(res.end.mock.calls[0][0]);
3262
3514
  expect(payload).toEqual(expect.objectContaining({
3263
3515
  success: true,
3264
- path: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Ffiles%2Fasset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3265
- fsPath: 'workspaces/workspace-1/assets/files/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-spec.pdf',
3516
+ path: attachmentPath,
3517
+ fsPath: attachmentFsPath,
3266
3518
  taskId: 'task-1'
3267
3519
  }));
3268
3520
  const assets = store.listByWorkspace('workspace-1', { includeDeleted: true });
@@ -3276,6 +3528,25 @@ describe('Server Routes', () => {
3276
3528
  expect(store.listLinksForTask('task-1', 'workspace-1')).toEqual([
3277
3529
  expect.objectContaining({ role: 'attachment' })
3278
3530
  ]);
3531
+ expect(core.recordTaskEvent).toHaveBeenCalledWith(expect.objectContaining({
3532
+ taskId: 'task-1',
3533
+ workspaceId: 'workspace-1',
3534
+ action: 'task-attachments-changed',
3535
+ actor: 'user',
3536
+ createdAt: expect.any(String),
3537
+ details: {
3538
+ changes: {
3539
+ attachments: {
3540
+ from: [],
3541
+ to: [expect.objectContaining({
3542
+ path: attachmentPath,
3543
+ fsPath: attachmentFsPath,
3544
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
3545
+ })],
3546
+ },
3547
+ },
3548
+ },
3549
+ }));
3279
3550
  vi.unstubAllGlobals();
3280
3551
  fs.rmSync(dbRoot, { recursive: true, force: true });
3281
3552
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -3782,6 +4053,8 @@ describe('Server Routes', () => {
3782
4053
  fs.rmSync(tmpRoot, { recursive: true, force: true });
3783
4054
  });
3784
4055
  it('POST /api/taskforce/documents/:assetId/attach should attach a canonical document to a task target', async () => {
4056
+ vi.useFakeTimers();
4057
+ vi.setSystemTime(new Date('2026-03-18T14:45:00.000Z'));
3785
4058
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-documents-attach-task-'));
3786
4059
  core.getPaths.mockReturnValue({
3787
4060
  projectRoot: tmpRoot,
@@ -3817,7 +4090,12 @@ describe('Server Routes', () => {
3817
4090
  'x-taskforce-workspace-id': 'default'
3818
4091
  });
3819
4092
  const res = createMockRes();
3820
- await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
4093
+ try {
4094
+ await match?.handler(req, res, { assetId: 'asset-doc-attach-route-1' });
4095
+ }
4096
+ finally {
4097
+ vi.useRealTimers();
4098
+ }
3821
4099
  expect(res.writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' });
3822
4100
  expect(core.updateTask).toHaveBeenCalledWith('task-attach-1', expect.objectContaining({
3823
4101
  attachments: [
@@ -3825,6 +4103,7 @@ describe('Server Routes', () => {
3825
4103
  assetId: 'asset-doc-attach-route-1',
3826
4104
  taskId: 'task-attach-1',
3827
4105
  path: '/api/taskforce/documents/asset-doc-attach-route-1/content',
4106
+ timestamp: '2026-03-18T14:45:00.000Z',
3828
4107
  })
3829
4108
  ]
3830
4109
  }), 'default', expect.objectContaining({
@@ -6443,6 +6722,58 @@ describe('Server Routes', () => {
6443
6722
  ]));
6444
6723
  fs.rmSync(dbRoot, { recursive: true, force: true });
6445
6724
  });
6725
+ it('GET /api/taskforce/sync/workspace/assets should include AI profile avatar files referenced without canonical metadata', async () => {
6726
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-ai-avatar-assets-'));
6727
+ const projectRoot = fs.realpathSync(tmpRoot);
6728
+ const basePath = path.join(tmpRoot, '.taskforce');
6729
+ const avatarStorageKey = 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
6730
+ const avatarDir = path.join(basePath, 'workspaces/workspace-local/assets/images/ai-profiles/ai-profile-codex/avatar');
6731
+ fs.mkdirSync(avatarDir, { recursive: true });
6732
+ const fileBuffer = Buffer.from('ai-avatar-bytes');
6733
+ fs.writeFileSync(path.join(avatarDir, 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp'), fileBuffer);
6734
+ const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
6735
+ core.getPaths.mockReturnValue({
6736
+ projectRoot,
6737
+ basePath,
6738
+ });
6739
+ core.resolvePreferredWorkspaceId.mockReturnValue('workspace-local');
6740
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
6741
+ core.getTenantId = vi.fn().mockReturnValue('default');
6742
+ core.listAiProfiles.mockReturnValue([
6743
+ {
6744
+ id: 'ai-profile-codex',
6745
+ workspaceId: 'workspace-local',
6746
+ name: 'Codex',
6747
+ username: 'codex',
6748
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
6749
+ avatarSourceUrl: null,
6750
+ createdAt: '2026-03-17T09:00:00.000Z',
6751
+ updatedAt: '2026-03-17T10:00:00.000Z'
6752
+ }
6753
+ ]);
6754
+ routes = createRoutes(core, { authConfig: { runtimeMode: 'local', enabled: false } });
6755
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/assets', routes);
6756
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/assets?workspaceId=workspace-local', undefined, {
6757
+ 'x-taskforce-workspace-id': 'workspace-local'
6758
+ });
6759
+ const res = createMockRes();
6760
+ await match?.handler(req, res, {});
6761
+ const payloadRaw = res.end.mock.calls[0]?.[0];
6762
+ const payload = JSON.parse(String(payloadRaw || '{}'));
6763
+ expect(payload.knownPaths).toContain(avatarStorageKey);
6764
+ expect(payload.assets).toEqual(expect.arrayContaining([
6765
+ expect.objectContaining({
6766
+ path: avatarStorageKey,
6767
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
6768
+ kind: 'image',
6769
+ mimeType: 'image/webp',
6770
+ originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
6771
+ contentBase64: fileBuffer.toString('base64')
6772
+ })
6773
+ ]));
6774
+ fs.rmSync(dbRoot, { recursive: true, force: true });
6775
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
6776
+ });
6446
6777
  it('GET /api/taskforce/sync/workspace/assets should ignore malformed external workspace asset rows', async () => {
6447
6778
  const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-assets-external-'));
6448
6779
  const projectRoot = fs.realpathSync(tmpRoot);
@@ -7109,6 +7440,82 @@ describe('Server Routes', () => {
7109
7440
  ]));
7110
7441
  fs.rmSync(dbRoot, { recursive: true, force: true });
7111
7442
  });
7443
+ it('GET /api/taskforce/sync/workspace/pull should include referenced AI profile avatar assets without canonical metadata', async () => {
7444
+ const authConfig = {
7445
+ runtimeMode: 'cloud',
7446
+ enabled: true,
7447
+ requiredForApi: true,
7448
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
7449
+ defaultRole: 'member',
7450
+ defaultWorkspaceId: 'default',
7451
+ sessionCookieName: 'taskforce_session',
7452
+ sessionSecret: 'test-secret',
7453
+ sessionTtlSeconds: 3600
7454
+ };
7455
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-sync-pull-ai-avatar-'));
7456
+ const basePath = path.join(tmpRoot, '.taskforce');
7457
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-modesty/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
7458
+ const avatarPath = path.join(basePath, avatarStorageKey);
7459
+ const avatarBuffer = Buffer.from('ai-avatar-bytes');
7460
+ fs.mkdirSync(path.dirname(avatarPath), { recursive: true });
7461
+ fs.writeFileSync(avatarPath, avatarBuffer);
7462
+ const { tmpRoot: dbRoot, db } = createWorkspaceAssetTestStore();
7463
+ core.getPaths.mockReturnValue({
7464
+ projectRoot: tmpRoot,
7465
+ basePath,
7466
+ });
7467
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
7468
+ core.getTenantId = vi.fn().mockReturnValue('default');
7469
+ core.listAiProfiles.mockReturnValue([
7470
+ {
7471
+ id: 'ai-profile-modesty',
7472
+ workspaceId: 'workspace-1',
7473
+ name: 'Modesty',
7474
+ username: 'modesty',
7475
+ icon: 'Bot',
7476
+ color: '#0ea5e9',
7477
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
7478
+ avatarSourceUrl: null,
7479
+ createdAt: '2026-03-17T09:00:00.000Z',
7480
+ updatedAt: '2026-03-17T10:00:00.000Z'
7481
+ }
7482
+ ]);
7483
+ routes = createRoutes(core, { authConfig });
7484
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7485
+ const cookie = createSessionTokenCookie('session-sync-pull-ai-avatar', authConfig, {
7486
+ role: 'owner',
7487
+ workspaceId: 'workspace-1',
7488
+ userId: 'user-1'
7489
+ });
7490
+ core.listTasks.mockReturnValueOnce({ tasks: [] });
7491
+ core.listArchive.mockReturnValueOnce({ archived: [] });
7492
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
7493
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=20', undefined, { cookie });
7494
+ const res = createMockRes();
7495
+ await match?.handler(req, res, {});
7496
+ const payload = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
7497
+ expect(payload.changes).toEqual(expect.arrayContaining([
7498
+ expect.objectContaining({
7499
+ op: 'ai-profile-upsert',
7500
+ profile: expect.objectContaining({
7501
+ id: 'ai-profile-modesty',
7502
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`
7503
+ })
7504
+ }),
7505
+ expect.objectContaining({
7506
+ op: 'asset-upsert',
7507
+ path: avatarStorageKey,
7508
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
7509
+ kind: 'image',
7510
+ mimeType: 'image/webp',
7511
+ originalFilename: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp',
7512
+ linkRole: 'image',
7513
+ contentBase64: avatarBuffer.toString('base64')
7514
+ })
7515
+ ]));
7516
+ fs.rmSync(dbRoot, { recursive: true, force: true });
7517
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
7518
+ });
7112
7519
  it('GET /api/taskforce/sync/workspace/pull should include mixed document and binary attachment upserts for the same task', async () => {
7113
7520
  const authConfig = {
7114
7521
  runtimeMode: 'cloud',
@@ -7570,7 +7977,7 @@ describe('Server Routes', () => {
7570
7977
  expect(res.end).toHaveBeenCalledTimes(1);
7571
7978
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
7572
7979
  });
7573
- it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
7980
+ it('GET /api/taskforce/sync/workspace/pull should degrade optional snapshot failures into diagnostics instead of 500', async () => {
7574
7981
  const authConfig = {
7575
7982
  runtimeMode: 'cloud',
7576
7983
  enabled: true,
@@ -7583,9 +7990,63 @@ describe('Server Routes', () => {
7583
7990
  sessionTtlSeconds: 3600
7584
7991
  };
7585
7992
  routes = createRoutes(core, { authConfig });
7586
- const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
7993
+ core.listUserWorkspaces.mockReturnValueOnce([{ id: 'workspace-1', slug: 'workspace-1', name: 'Workspace 1', description: null, role: 'owner', status: 'active', betaAccess: true }]);
7994
+ core.listTasks.mockReturnValueOnce({
7995
+ tasks: [
7996
+ {
7997
+ id: 'task-survives',
7998
+ title: 'Survives partial feed failure',
7999
+ status: 'task',
8000
+ category: 'general',
8001
+ type: 'task',
8002
+ createdAt: '2026-04-10T10:00:00.000Z'
8003
+ }
8004
+ ]
8005
+ });
8006
+ core.listDocumentReviewSessionsForSync.mockImplementationOnce(() => {
8007
+ throw new Error('document review session snapshot exploded');
8008
+ });
8009
+ const cookie = createSessionTokenCookie('session-sync-pull-partial', authConfig, {
7587
8010
  role: 'owner',
7588
- workspaceId: 'workspace-cloud-selected',
8011
+ workspaceId: 'workspace-1',
8012
+ userId: 'user-1'
8013
+ });
8014
+ const match = matchRoute('GET', '/api/taskforce/sync/workspace/pull', routes);
8015
+ const req = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-1&limit=10', undefined, { cookie });
8016
+ const res = createMockRes();
8017
+ await match?.handler(req, res, {});
8018
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
8019
+ const body = JSON.parse(String(res.end.mock.calls[0]?.[0] || '{}'));
8020
+ expect(body.success).toBe(true);
8021
+ expect(body.changes).toEqual(expect.arrayContaining([
8022
+ expect.objectContaining({
8023
+ op: 'upsert',
8024
+ task: expect.objectContaining({ id: 'task-survives' })
8025
+ })
8026
+ ]));
8027
+ expect(body.diagnostics?.snapshotErrors).toEqual(expect.arrayContaining([
8028
+ expect.objectContaining({
8029
+ domain: 'document-review-sessions',
8030
+ error: 'document review session snapshot exploded'
8031
+ })
8032
+ ]));
8033
+ });
8034
+ it('GET /api/taskforce/sync/workspace/pull should reject unauthorized workspace override', async () => {
8035
+ const authConfig = {
8036
+ runtimeMode: 'cloud',
8037
+ enabled: true,
8038
+ requiredForApi: true,
8039
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
8040
+ defaultRole: 'member',
8041
+ defaultWorkspaceId: 'default',
8042
+ sessionCookieName: 'taskforce_session',
8043
+ sessionSecret: 'test-secret',
8044
+ sessionTtlSeconds: 3600
8045
+ };
8046
+ routes = createRoutes(core, { authConfig });
8047
+ const cookie = createSessionTokenCookie('session-sync-pull-forbidden', authConfig, {
8048
+ role: 'owner',
8049
+ workspaceId: 'workspace-cloud-selected',
7589
8050
  userId: 'user-1'
7590
8051
  });
7591
8052
  core.listUserWorkspaces.mockReturnValueOnce([
@@ -10362,6 +10823,8 @@ describe('Server Routes', () => {
10362
10823
  await match?.handler(req, res, {});
10363
10824
  const payload = JSON.parse(res.end.mock.calls[0][0]);
10364
10825
  expect(payload.authenticated).toBe(true);
10826
+ expect(payload.authRequiredForApi).toBe(true);
10827
+ expect(payload.runtimeMode).toBe('cloud');
10365
10828
  expect(payload.betaAccess).toBe(false);
10366
10829
  expect(res.setHeader).toHaveBeenCalledWith('Server-Timing', expect.stringContaining('auth-session'));
10367
10830
  expect(core.getUserBetaAccess).toHaveBeenCalledWith('u-no-workspace');
@@ -11343,7 +11806,8 @@ describe('Server Routes', () => {
11343
11806
  label: null,
11344
11807
  description: null,
11345
11808
  publicLabel: null,
11346
- publicDescription: null
11809
+ publicDescription: null,
11810
+ publicDescriptionVisible: false
11347
11811
  }
11348
11812
  ]);
11349
11813
  }
@@ -11497,6 +11961,7 @@ describe('Server Routes', () => {
11497
11961
  description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
11498
11962
  publicLabel: 'AI teammates',
11499
11963
  publicDescription: 'Add reusable AI teammates to your workspace.',
11964
+ publicDescriptionVisible: false,
11500
11965
  publicDisplayOrder: 20,
11501
11966
  allowedAccessModes: ['enabled', 'limited', 'disabled'],
11502
11967
  configTemplate: { maxAiProfiles: 1 }
@@ -11505,6 +11970,7 @@ describe('Server Routes', () => {
11505
11970
  const req = createMockReq('PATCH', '/api/platform/features/collaboration.ai_profiles', {
11506
11971
  publicLabel: 'AI teammates',
11507
11972
  publicDescription: 'Add reusable AI teammates to your workspace.',
11973
+ publicDescriptionVisible: false,
11508
11974
  publicDisplayOrder: 20
11509
11975
  }, {
11510
11976
  'x-taskforce-runtime-mode': 'cloud',
@@ -11514,13 +11980,65 @@ describe('Server Routes', () => {
11514
11980
  await match?.handler(req, res, { featureKey: 'collaboration.ai_profiles' });
11515
11981
  expect(core.updatePlanFeatureCatalogMarketingCopy).toHaveBeenCalledWith({
11516
11982
  featureKey: 'collaboration.ai_profiles',
11983
+ label: undefined,
11984
+ description: undefined,
11517
11985
  publicLabel: 'AI teammates',
11518
11986
  publicDescription: 'Add reusable AI teammates to your workspace.',
11987
+ publicDescriptionVisible: false,
11519
11988
  publicDisplayOrder: 20
11520
11989
  });
11521
11990
  expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
11522
11991
  expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"publicLabel":"AI teammates"'));
11523
11992
  });
11993
+ it('POST /api/platform/features should create a custom feature catalog entry', async () => {
11994
+ core.createCustomPlanFeatureCatalogEntry = vi.fn().mockReturnValue({
11995
+ featureKey: 'marketing.unlimited_local_workspaces',
11996
+ label: 'Unlimited Local Workspaces',
11997
+ description: 'Run unlimited local workspaces on your device.',
11998
+ isCustom: true,
11999
+ publicLabel: null,
12000
+ publicDescription: null,
12001
+ publicDescriptionVisible: true,
12002
+ publicDisplayOrder: 12,
12003
+ allowedAccessModes: ['enabled', 'disabled'],
12004
+ configTemplate: {}
12005
+ });
12006
+ const match = matchRoute('POST', '/api/platform/features', routes);
12007
+ const req = createMockReq('POST', '/api/platform/features', {
12008
+ label: 'Unlimited Local Workspaces',
12009
+ description: 'Run unlimited local workspaces on your device.',
12010
+ publicDescriptionVisible: true,
12011
+ publicDisplayOrder: 12
12012
+ }, {
12013
+ 'x-taskforce-runtime-mode': 'cloud',
12014
+ 'x-taskforce-system-role': 'system_admin'
12015
+ });
12016
+ const res = createMockRes();
12017
+ await match?.handler(req, res, {});
12018
+ expect(core.createCustomPlanFeatureCatalogEntry).toHaveBeenCalledWith({
12019
+ label: 'Unlimited Local Workspaces',
12020
+ description: 'Run unlimited local workspaces on your device.',
12021
+ publicLabel: undefined,
12022
+ publicDescription: undefined,
12023
+ publicDescriptionVisible: true,
12024
+ publicDisplayOrder: 12
12025
+ });
12026
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12027
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"featureKey":"marketing.unlimited_local_workspaces"'));
12028
+ });
12029
+ it('DELETE /api/platform/features/:featureKey should remove a custom feature catalog entry', async () => {
12030
+ core.deleteCustomPlanFeatureCatalogEntry = vi.fn();
12031
+ const match = matchRoute('DELETE', '/api/platform/features/marketing.unlimited_local_workspaces', routes);
12032
+ const req = createMockReq('DELETE', '/api/platform/features/marketing.unlimited_local_workspaces', undefined, {
12033
+ 'x-taskforce-runtime-mode': 'cloud',
12034
+ 'x-taskforce-system-role': 'system_admin'
12035
+ });
12036
+ const res = createMockRes();
12037
+ await match?.handler(req, res, { featureKey: 'marketing.unlimited_local_workspaces' });
12038
+ expect(core.deleteCustomPlanFeatureCatalogEntry).toHaveBeenCalledWith('marketing.unlimited_local_workspaces');
12039
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12040
+ expect(res.end).toHaveBeenCalledWith(expect.stringContaining('"success":true'));
12041
+ });
11524
12042
  it('PATCH /api/platform/plans/:planId/versions/:versionId/features should return normalized dependency state', async () => {
11525
12043
  core.updatePlanVersionFeatures.mockReturnValueOnce({
11526
12044
  planVersionId: 'collab-v1',
@@ -11953,6 +12471,8 @@ describe('Server Routes', () => {
11953
12471
  expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
11954
12472
  expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
11955
12473
  expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
12474
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar/upload', routes)).not.toBeNull();
12475
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/metadata', routes)).toBeNull();
11956
12476
  });
11957
12477
  it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
11958
12478
  const authConfig = createCloudAuthConfig();
@@ -12382,6 +12902,8 @@ describe('Server Routes', () => {
12382
12902
  color: '#ef6c00',
12383
12903
  avatarUrl: null,
12384
12904
  avatarSourceUrl: null,
12905
+ avatarRevision: 0,
12906
+ avatarUpdatedAt: null,
12385
12907
  kind: 'agent',
12386
12908
  description: 'Builds product code',
12387
12909
  role: 'Implementer',
@@ -12396,7 +12918,86 @@ describe('Server Routes', () => {
12396
12918
  }
12397
12919
  ]);
12398
12920
  });
12399
- it('GET /api/taskforce/workspace/assignee-options should include ai profile tokens for workspace admins', async () => {
12921
+ it('GET /api/taskforce/workspace/assignee-options should support agent-only roster loads', async () => {
12922
+ const authConfig = {
12923
+ runtimeMode: 'cloud',
12924
+ enabled: true,
12925
+ requiredForApi: true,
12926
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12927
+ defaultRole: 'member',
12928
+ defaultWorkspaceId: 'default',
12929
+ sessionCookieName: 'taskforce_session',
12930
+ sessionSecret: 'test-secret',
12931
+ sessionTtlSeconds: 3600
12932
+ };
12933
+ routes = createRoutes(core, { authConfig });
12934
+ core.listWorkspaceUsers.mockReturnValue([
12935
+ {
12936
+ userId: 'member-1',
12937
+ email: 'member1@example.com',
12938
+ displayName: 'Member One',
12939
+ avatarUrl: null,
12940
+ role: 'member',
12941
+ permissionMode: 'read-write',
12942
+ betaAccess: true,
12943
+ status: 'active',
12944
+ disabled: false
12945
+ }
12946
+ ]);
12947
+ core.listAiProfiles.mockReturnValue([
12948
+ {
12949
+ id: 'ai-profile-1',
12950
+ workspaceId: 'workspace-1',
12951
+ profileToken: 'tfp_codex_sync',
12952
+ name: 'Codex',
12953
+ username: 'codex',
12954
+ icon: 'Bot',
12955
+ color: '#ef6c00',
12956
+ description: 'Builds product code',
12957
+ role: 'Implementer',
12958
+ provider: 'OpenAI',
12959
+ model: 'gpt-5.4',
12960
+ surfaceType: 'coding_tool',
12961
+ seatScope: 'cloud_metered',
12962
+ archivedAt: null,
12963
+ createdAt: '2026-03-01T00:00:00.000Z',
12964
+ updatedAt: '2026-03-01T00:00:00.000Z',
12965
+ lastActiveAt: null
12966
+ }
12967
+ ]);
12968
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
12969
+ const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
12970
+ const cookie = createSessionTokenCookie('session-member-agent-roster', authConfig, {
12971
+ userId: 'member-user',
12972
+ role: 'member',
12973
+ workspaceId: 'workspace-1'
12974
+ });
12975
+ const req = createMockReq('GET', '/api/taskforce/workspace/assignee-options?kind=agent', undefined, {
12976
+ cookie,
12977
+ 'x-taskforce-runtime-mode': 'cloud',
12978
+ 'x-taskforce-role': 'member',
12979
+ 'x-taskforce-user-id': 'member-user',
12980
+ 'x-taskforce-workspace-id': 'workspace-1'
12981
+ });
12982
+ const res = createMockRes();
12983
+ await match?.handler(req, res, {});
12984
+ expect(core.listWorkspaceUsers).not.toHaveBeenCalled();
12985
+ expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
12986
+ diagnostics: expect.any(Object)
12987
+ }));
12988
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
12989
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12990
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12991
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
12992
+ expect(payload.assignees).toEqual([
12993
+ expect.objectContaining({
12994
+ value: 'ai-profile-1',
12995
+ label: 'Codex',
12996
+ kind: 'agent'
12997
+ })
12998
+ ]);
12999
+ });
13000
+ it('GET /api/taskforce/workspace/assignee-options should not include ai profile tokens for workspace admins', async () => {
12400
13001
  const authConfig = {
12401
13002
  runtimeMode: 'cloud',
12402
13003
  enabled: true,
@@ -12450,9 +13051,187 @@ describe('Server Routes', () => {
12450
13051
  expect(payload.assignees).toEqual([
12451
13052
  expect.objectContaining({
12452
13053
  value: 'ai-profile-1',
12453
- profileToken: 'tfp_codex_sync'
13054
+ label: 'Codex'
12454
13055
  })
12455
13056
  ]);
13057
+ expect(payload.assignees[0]).not.toHaveProperty('profileToken');
13058
+ });
13059
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should reject direct avatar URL writes', async () => {
13060
+ const authConfig = {
13061
+ runtimeMode: 'cloud',
13062
+ enabled: true,
13063
+ requiredForApi: true,
13064
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13065
+ defaultRole: 'member',
13066
+ defaultWorkspaceId: 'default',
13067
+ sessionCookieName: 'taskforce_session',
13068
+ sessionSecret: 'test-secret',
13069
+ sessionTtlSeconds: 3600
13070
+ };
13071
+ routes = createRoutes(core, { authConfig });
13072
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13073
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-reject', authConfig, {
13074
+ userId: 'admin-user',
13075
+ role: 'admin',
13076
+ workspaceId: 'workspace-1'
13077
+ });
13078
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13079
+ profileId: 'ai-profile-1',
13080
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Favatar.webp'
13081
+ }, {
13082
+ cookie,
13083
+ 'x-taskforce-runtime-mode': 'cloud',
13084
+ 'x-taskforce-role': 'admin',
13085
+ 'x-taskforce-user-id': 'admin-user',
13086
+ 'x-taskforce-workspace-id': 'workspace-1'
13087
+ });
13088
+ const res = createMockRes();
13089
+ await match?.handler(req, res, {});
13090
+ expect(core.updateAiProfileAvatar).not.toHaveBeenCalled();
13091
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
13092
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13093
+ expect(payload).toEqual({
13094
+ error: 'AI profile avatar updates must use the stable avatar upload endpoint.',
13095
+ code: 'AI_PROFILE_AVATAR_STABLE_UPLOAD_REQUIRED'
13096
+ });
13097
+ });
13098
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should allow clear-only updates and redact profile tokens', async () => {
13099
+ const authConfig = {
13100
+ runtimeMode: 'cloud',
13101
+ enabled: true,
13102
+ requiredForApi: true,
13103
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13104
+ defaultRole: 'member',
13105
+ defaultWorkspaceId: 'default',
13106
+ sessionCookieName: 'taskforce_session',
13107
+ sessionSecret: 'test-secret',
13108
+ sessionTtlSeconds: 3600
13109
+ };
13110
+ routes = createRoutes(core, { authConfig });
13111
+ core.listAiProfiles.mockReturnValue([
13112
+ {
13113
+ id: 'ai-profile-1',
13114
+ workspaceId: 'workspace-1',
13115
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fdisplay.webp',
13116
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fsource.webp'
13117
+ }
13118
+ ]);
13119
+ core.updateAiProfileAvatar.mockReturnValue({
13120
+ id: 'ai-profile-1',
13121
+ workspaceId: 'workspace-1',
13122
+ profileToken: 'tfp_codex_sync',
13123
+ name: 'Codex',
13124
+ username: 'codex',
13125
+ icon: 'Bot',
13126
+ color: '#ef6c00',
13127
+ avatarUrl: null,
13128
+ avatarSourceUrl: null,
13129
+ description: 'Builds product code',
13130
+ role: 'Implementer',
13131
+ provider: 'OpenAI',
13132
+ model: 'gpt-5.4',
13133
+ surfaceType: 'coding_tool',
13134
+ archivedAt: null,
13135
+ createdAt: '2026-03-01T00:00:00.000Z',
13136
+ updatedAt: '2026-04-19T17:00:00.000Z',
13137
+ lastActiveAt: null
13138
+ });
13139
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13140
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-clear', authConfig, {
13141
+ userId: 'admin-user',
13142
+ role: 'admin',
13143
+ workspaceId: 'workspace-1'
13144
+ });
13145
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13146
+ profileId: 'ai-profile-1',
13147
+ avatarUrl: null,
13148
+ avatarSourceUrl: null
13149
+ }, {
13150
+ cookie,
13151
+ 'x-taskforce-runtime-mode': 'cloud',
13152
+ 'x-taskforce-role': 'admin',
13153
+ 'x-taskforce-user-id': 'admin-user',
13154
+ 'x-taskforce-workspace-id': 'workspace-1'
13155
+ });
13156
+ const res = createMockRes();
13157
+ await match?.handler(req, res, {});
13158
+ expect(core.updateAiProfileAvatar).toHaveBeenCalledWith({
13159
+ workspaceId: 'workspace-1',
13160
+ profileId: 'ai-profile-1',
13161
+ avatarUrl: null,
13162
+ avatarSourceUrl: null
13163
+ });
13164
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13165
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13166
+ expect(payload.success).toBe(true);
13167
+ expect(payload.profile).toEqual(expect.objectContaining({
13168
+ id: 'ai-profile-1',
13169
+ avatarUrl: null,
13170
+ avatarSourceUrl: null
13171
+ }));
13172
+ expect(payload.profile).not.toHaveProperty('profileToken');
13173
+ });
13174
+ it('POST /api/taskforce/workspace/ai-profiles/merge should redact profile tokens from admin responses', async () => {
13175
+ const authConfig = {
13176
+ runtimeMode: 'cloud',
13177
+ enabled: true,
13178
+ requiredForApi: true,
13179
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13180
+ defaultRole: 'member',
13181
+ defaultWorkspaceId: 'default',
13182
+ sessionCookieName: 'taskforce_session',
13183
+ sessionSecret: 'test-secret',
13184
+ sessionTtlSeconds: 3600
13185
+ };
13186
+ routes = createRoutes(core, { authConfig });
13187
+ core.mergeAiProfiles.mockReturnValue({
13188
+ id: 'ai-profile-keep',
13189
+ workspaceId: 'workspace-1',
13190
+ profileToken: 'tfp_codex_sync',
13191
+ name: 'Codex',
13192
+ username: 'codex',
13193
+ icon: 'Bot',
13194
+ color: '#ef6c00',
13195
+ description: 'Builds product code',
13196
+ role: 'Implementer',
13197
+ provider: 'OpenAI',
13198
+ model: 'gpt-5.4',
13199
+ surfaceType: 'coding_tool',
13200
+ archivedAt: null,
13201
+ createdAt: '2026-03-01T00:00:00.000Z',
13202
+ updatedAt: '2026-04-19T17:00:00.000Z',
13203
+ lastActiveAt: null
13204
+ });
13205
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', routes);
13206
+ const cookie = createSessionTokenCookie('session-admin-merge-ai-profile', authConfig, {
13207
+ userId: 'admin-user',
13208
+ role: 'admin',
13209
+ workspaceId: 'workspace-1'
13210
+ });
13211
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/merge', {
13212
+ keepId: 'ai-profile-keep',
13213
+ mergeId: 'ai-profile-merge'
13214
+ }, {
13215
+ cookie,
13216
+ 'x-taskforce-runtime-mode': 'cloud',
13217
+ 'x-taskforce-role': 'admin',
13218
+ 'x-taskforce-user-id': 'admin-user',
13219
+ 'x-taskforce-workspace-id': 'workspace-1'
13220
+ });
13221
+ const res = createMockRes();
13222
+ await match?.handler(req, res, {});
13223
+ expect(core.mergeAiProfiles).toHaveBeenCalledWith({
13224
+ workspaceId: 'workspace-1',
13225
+ keepId: 'ai-profile-keep',
13226
+ mergeId: 'ai-profile-merge'
13227
+ });
13228
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13229
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13230
+ expect(payload.profile).toEqual(expect.objectContaining({
13231
+ id: 'ai-profile-keep',
13232
+ name: 'Codex'
13233
+ }));
13234
+ expect(payload.profile).not.toHaveProperty('profileToken');
12456
13235
  });
12457
13236
  it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
12458
13237
  const authConfig = {
@@ -12518,6 +13297,7 @@ describe('Server Routes', () => {
12518
13297
  id: 'ai-profile-1',
12519
13298
  archivedReason: 'manual_archive'
12520
13299
  }));
13300
+ expect(payload.profile).not.toHaveProperty('profileToken');
12521
13301
  });
12522
13302
  it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
12523
13303
  const authConfig = {
@@ -12837,7 +13617,12 @@ describe('Server Routes', () => {
12837
13617
  core.getGlobalSettings.mockReturnValue({
12838
13618
  site: {
12839
13619
  showRunTaskforceLocally: false,
12840
- pricingPageEnabled: false
13620
+ pricingPageEnabled: false,
13621
+ animatedBackgroundEnabled: true,
13622
+ siteTheme: 'light',
13623
+ pricingPageTitle: 'Choose Your Plan',
13624
+ pricingPageDescription: 'Compare plans and pick the right one.',
13625
+ pricingPageShowPublicDescriptions: false
12841
13626
  }
12842
13627
  });
12843
13628
  const match = matchRoute('GET', '/api/taskforce/admin/site-settings', routes);
@@ -12847,14 +13632,29 @@ describe('Server Routes', () => {
12847
13632
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12848
13633
  expect(payload.status.showRunTaskforceLocally).toBe(false);
12849
13634
  expect(payload.status.pricingPageEnabled).toBe(false);
13635
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13636
+ expect(payload.status.siteTheme).toBe('light');
13637
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13638
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
13639
+ expect(payload.status.pricingPageShowPublicDescriptions).toBe(false);
12850
13640
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
12851
13641
  expect(payload.settings.pricingPageEnabled).toBe(false);
13642
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13643
+ expect(payload.settings.siteTheme).toBe('light');
13644
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13645
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
13646
+ expect(payload.settings.pricingPageShowPublicDescriptions).toBe(false);
12852
13647
  });
12853
13648
  it('GET /api/taskforce/admin/site_settings should expose effective website settings for legacy admin bundles', async () => {
12854
13649
  core.getGlobalSettings.mockReturnValue({
12855
13650
  site: {
12856
13651
  showRunTaskforceLocally: false,
12857
- pricingPageEnabled: false
13652
+ pricingPageEnabled: false,
13653
+ animatedBackgroundEnabled: true,
13654
+ siteTheme: 'light',
13655
+ pricingPageTitle: 'Choose Your Plan',
13656
+ pricingPageDescription: 'Compare plans and pick the right one.',
13657
+ pricingPageShowPublicDescriptions: false
12858
13658
  }
12859
13659
  });
12860
13660
  const match = matchRoute('GET', '/api/taskforce/admin/site_settings', routes);
@@ -12864,36 +13664,78 @@ describe('Server Routes', () => {
12864
13664
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12865
13665
  expect(payload.status.showRunTaskforceLocally).toBe(false);
12866
13666
  expect(payload.status.pricingPageEnabled).toBe(false);
13667
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13668
+ expect(payload.status.siteTheme).toBe('light');
13669
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13670
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
13671
+ expect(payload.status.pricingPageShowPublicDescriptions).toBe(false);
12867
13672
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
12868
13673
  expect(payload.settings.pricingPageEnabled).toBe(false);
13674
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13675
+ expect(payload.settings.siteTheme).toBe('light');
13676
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13677
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
13678
+ expect(payload.settings.pricingPageShowPublicDescriptions).toBe(false);
12869
13679
  });
12870
13680
  it('POST /api/taskforce/admin/site-settings should persist website settings', async () => {
12871
13681
  const match = matchRoute('POST', '/api/taskforce/admin/site-settings', routes);
13682
+ const pricingPageDescription = `<p>${'Compare plans and pick the right one. '.repeat(12)}</p>`;
12872
13683
  const req = createMockReq('POST', '/api/taskforce/admin/site-settings', {
12873
13684
  showRunTaskforceLocally: false,
12874
- pricingPageEnabled: false
13685
+ pricingPageEnabled: false,
13686
+ animatedBackgroundEnabled: true,
13687
+ siteTheme: 'light',
13688
+ pricingPageTitle: 'Choose Your Plan',
13689
+ pricingPageDescription,
13690
+ pricingPageShowPublicDescriptions: false
12875
13691
  }, { 'x-taskforce-role': 'admin' });
12876
13692
  const res = createMockRes();
12877
13693
  await match?.handler(req, res, {});
12878
13694
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
12879
13695
  site: {
12880
13696
  showRunTaskforceLocally: false,
12881
- pricingPageEnabled: false
13697
+ pricingPageEnabled: false,
13698
+ animatedBackgroundEnabled: true,
13699
+ siteTheme: 'light',
13700
+ pricingPageTitle: 'Choose Your Plan',
13701
+ pricingPageDescription,
13702
+ pricingPageShowPublicDescriptions: false
12882
13703
  }
12883
13704
  });
12884
13705
  });
13706
+ it('POST /api/taskforce/admin/site-settings should reject pricing page descriptions over 2000 characters', async () => {
13707
+ const match = matchRoute('POST', '/api/taskforce/admin/site-settings', routes);
13708
+ const req = createMockReq('POST', '/api/taskforce/admin/site-settings', {
13709
+ pricingPageDescription: 'x'.repeat(2001)
13710
+ }, { 'x-taskforce-role': 'admin' });
13711
+ const res = createMockRes();
13712
+ await match?.handler(req, res, {});
13713
+ expect(res.writeHead).toHaveBeenCalledWith(400, { 'Content-Type': 'application/json' });
13714
+ expect(res.end.mock.calls[0][0]).toContain('PRICING_PAGE_DESCRIPTION_TOO_LONG');
13715
+ expect(core.updateGlobalSettings).not.toHaveBeenCalled();
13716
+ });
12885
13717
  it('POST /api/taskforce/admin/site_settings should persist website settings for legacy admin bundles', async () => {
12886
13718
  const match = matchRoute('POST', '/api/taskforce/admin/site_settings', routes);
12887
13719
  const req = createMockReq('POST', '/api/taskforce/admin/site_settings', {
12888
13720
  showRunTaskforceLocally: false,
12889
- pricingPageEnabled: false
13721
+ pricingPageEnabled: false,
13722
+ animatedBackgroundEnabled: true,
13723
+ siteTheme: 'light',
13724
+ pricingPageTitle: 'Choose Your Plan',
13725
+ pricingPageDescription: 'Compare plans and pick the right one.',
13726
+ pricingPageShowPublicDescriptions: false
12890
13727
  }, { 'x-taskforce-role': 'admin' });
12891
13728
  const res = createMockRes();
12892
13729
  await match?.handler(req, res, {});
12893
13730
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
12894
13731
  site: {
12895
13732
  showRunTaskforceLocally: false,
12896
- pricingPageEnabled: false
13733
+ pricingPageEnabled: false,
13734
+ animatedBackgroundEnabled: true,
13735
+ siteTheme: 'light',
13736
+ pricingPageTitle: 'Choose Your Plan',
13737
+ pricingPageDescription: 'Compare plans and pick the right one.',
13738
+ pricingPageShowPublicDescriptions: false
12897
13739
  }
12898
13740
  });
12899
13741
  });
@@ -12901,7 +13743,12 @@ describe('Server Routes', () => {
12901
13743
  core.getGlobalSettings.mockReturnValue({
12902
13744
  site: {
12903
13745
  showRunTaskforceLocally: false,
12904
- pricingPageEnabled: false
13746
+ pricingPageEnabled: false,
13747
+ animatedBackgroundEnabled: true,
13748
+ siteTheme: 'light',
13749
+ pricingPageTitle: 'Choose Your Plan',
13750
+ pricingPageDescription: 'Compare plans and pick the right one.',
13751
+ pricingPageShowPublicDescriptions: false
12905
13752
  }
12906
13753
  });
12907
13754
  const match = matchRoute('GET', '/api/taskforce/public/site-config', routes);
@@ -12916,7 +13763,12 @@ describe('Server Routes', () => {
12916
13763
  success: true,
12917
13764
  config: {
12918
13765
  showRunTaskforceLocally: false,
12919
- pricingPageEnabled: false
13766
+ pricingPageEnabled: false,
13767
+ animatedBackgroundEnabled: true,
13768
+ siteTheme: 'light',
13769
+ pricingPageTitle: 'Choose Your Plan',
13770
+ pricingPageDescription: 'Compare plans and pick the right one.',
13771
+ pricingPageShowPublicDescriptions: false
12920
13772
  }
12921
13773
  });
12922
13774
  });
@@ -12924,7 +13776,12 @@ describe('Server Routes', () => {
12924
13776
  core.getGlobalSettings.mockReturnValue({
12925
13777
  site: {
12926
13778
  showRunTaskforceLocally: false,
12927
- pricingPageEnabled: false
13779
+ pricingPageEnabled: false,
13780
+ animatedBackgroundEnabled: true,
13781
+ siteTheme: 'light',
13782
+ pricingPageTitle: 'Choose Your Plan',
13783
+ pricingPageDescription: 'Compare plans and pick the right one.',
13784
+ pricingPageShowPublicDescriptions: false
12928
13785
  }
12929
13786
  });
12930
13787
  const match = matchRoute('GET', '/api/taskforce/public/site-config-script', routes);
@@ -12937,6 +13794,10 @@ describe('Server Routes', () => {
12937
13794
  }));
12938
13795
  expect(res.end.mock.calls[0][0]).toContain('window.__TASKFORCE_SITE_CONFIG__ = ');
12939
13796
  expect(res.end.mock.calls[0][0]).toContain('"pricingPageEnabled":false');
13797
+ expect(res.end.mock.calls[0][0]).toContain('"animatedBackgroundEnabled":true');
13798
+ expect(res.end.mock.calls[0][0]).toContain('"siteTheme":"light"');
13799
+ expect(res.end.mock.calls[0][0]).toContain('"pricingPageTitle":"Choose Your Plan"');
13800
+ expect(res.end.mock.calls[0][0]).toContain('"pricingPageShowPublicDescriptions":false');
12940
13801
  });
12941
13802
  it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
12942
13803
  core.validateOnboardingPolicy.mockReturnValueOnce({
@@ -13113,6 +13974,94 @@ describe('Server Routes', () => {
13113
13974
  ]);
13114
13975
  fs.rmSync(dbRoot, { recursive: true, force: true });
13115
13976
  });
13977
+ it('GET /api/taskforce/admin/orphaned-assets should not list AI profile avatar assets still referenced by profiles', async () => {
13978
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
13979
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
13980
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
13981
+ core.getTenantId = vi.fn().mockReturnValue('default');
13982
+ core.listAiProfiles.mockReturnValueOnce([
13983
+ {
13984
+ id: 'ai-profile-codex',
13985
+ workspaceId: 'workspace-1',
13986
+ name: 'Codex',
13987
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
13988
+ avatarSourceUrl: null,
13989
+ createdAt: '2026-03-18T17:00:00.000Z',
13990
+ updatedAt: '2026-03-18T17:05:00.000Z'
13991
+ }
13992
+ ]);
13993
+ store.upsertAsset({
13994
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
13995
+ workspaceId: 'workspace-1',
13996
+ kind: 'image',
13997
+ originalFilename: 'avatar.webp',
13998
+ mimeType: 'image/webp',
13999
+ storageProvider: 'r2',
14000
+ storageKey: avatarStorageKey,
14001
+ contentSha256: 'sha-avatar',
14002
+ sizeBytes: 42,
14003
+ createdAt: '2026-03-18T17:00:00.000Z',
14004
+ updatedAt: '2026-03-18T17:05:00.000Z'
14005
+ });
14006
+ routes = createRoutes(core);
14007
+ const match = matchRoute('GET', '/api/taskforce/admin/orphaned-assets', routes);
14008
+ const req = createMockReq('GET', '/api/taskforce/admin/orphaned-assets?workspaceId=workspace-1', undefined, {
14009
+ 'x-taskforce-role': 'admin',
14010
+ 'x-taskforce-workspace-id': 'workspace-1'
14011
+ });
14012
+ const res = createMockRes();
14013
+ await match?.handler(req, res, {});
14014
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
14015
+ expect(payload.success).toBe(true);
14016
+ expect(payload.assets).toEqual([]);
14017
+ fs.rmSync(dbRoot, { recursive: true, force: true });
14018
+ });
14019
+ it('POST /api/taskforce/admin/orphaned-assets/delete should reject AI profile avatar assets still referenced by profiles', async () => {
14020
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
14021
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-cropped.webp';
14022
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
14023
+ core.getTenantId = vi.fn().mockReturnValue('default');
14024
+ core.listAiProfiles.mockReturnValueOnce([
14025
+ {
14026
+ id: 'ai-profile-codex',
14027
+ workspaceId: 'workspace-1',
14028
+ name: 'Codex',
14029
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
14030
+ avatarSourceUrl: null,
14031
+ createdAt: '2026-03-18T17:00:00.000Z',
14032
+ updatedAt: '2026-03-18T17:05:00.000Z'
14033
+ }
14034
+ ]);
14035
+ store.upsertAsset({
14036
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
14037
+ workspaceId: 'workspace-1',
14038
+ kind: 'image',
14039
+ originalFilename: 'avatar.webp',
14040
+ mimeType: 'image/webp',
14041
+ storageProvider: 'r2',
14042
+ storageKey: avatarStorageKey,
14043
+ contentSha256: 'sha-avatar',
14044
+ sizeBytes: 42,
14045
+ createdAt: '2026-03-18T17:00:00.000Z',
14046
+ updatedAt: '2026-03-18T17:05:00.000Z'
14047
+ });
14048
+ routes = createRoutes(core);
14049
+ const match = matchRoute('POST', '/api/taskforce/admin/orphaned-assets/delete', routes);
14050
+ const req = createMockReq('POST', '/api/taskforce/admin/orphaned-assets/delete', {
14051
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
14052
+ workspaceId: 'workspace-1'
14053
+ }, {
14054
+ 'x-taskforce-role': 'admin',
14055
+ 'x-taskforce-workspace-id': 'workspace-1'
14056
+ });
14057
+ const res = createMockRes();
14058
+ await match?.handler(req, res, {});
14059
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
14060
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
14061
+ expect(payload.error).toMatch(/referenced by an AI profile avatar/i);
14062
+ expect(store.get('asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'workspace-1')?.deletedAt).toBeNull();
14063
+ fs.rmSync(dbRoot, { recursive: true, force: true });
14064
+ });
13116
14065
  it('POST /api/taskforce/admin/object-storage-settings should persist sanitized values', async () => {
13117
14066
  const match = matchRoute('POST', '/api/taskforce/admin/object-storage-settings', routes);
13118
14067
  const req = createMockReq('POST', '/api/taskforce/admin/object-storage-settings', {