@taskforcehq/taskforce 0.3.306 → 0.3.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) 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.js +9 -1
  38. package/dist/components/views/PlanComparisonPage.test.js +19 -0
  39. package/dist/components/views/PlansPage.js +1 -1
  40. package/dist/components/views/StandaloneLayout.js +256 -69
  41. package/dist/components/views/WidgetView.js +10 -8
  42. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  43. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  44. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  45. package/dist/config/envSchema.js +1 -0
  46. package/dist/core/AiProfileService.d.ts +7 -0
  47. package/dist/core/AiProfileService.js +460 -35
  48. package/dist/core/AiProfiles.test.js +648 -2
  49. package/dist/core/AttachmentLinkService.d.ts +3 -0
  50. package/dist/core/AttachmentLinkService.js +58 -13
  51. package/dist/core/GlobalSettingsService.js +12 -0
  52. package/dist/core/McpTokenService.d.ts +12 -0
  53. package/dist/core/McpTokenService.js +44 -1
  54. package/dist/core/PlanningEntities.test.js +366 -4
  55. package/dist/core/TaskActivityService.d.ts +28 -1
  56. package/dist/core/TaskActivityService.js +227 -46
  57. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  58. package/dist/core/TaskActivitySync.test.js +90 -0
  59. package/dist/core/TaskAuditTimeline.test.js +96 -0
  60. package/dist/core/Taskforce.d.ts +190 -26
  61. package/dist/core/Taskforce.ids.test.js +58 -0
  62. package/dist/core/Taskforce.js +775 -71
  63. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  64. package/dist/core/types.d.ts +7 -0
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  66. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  67. package/dist/hooks/bootstrapTimeouts.js +8 -0
  68. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  69. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  70. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  71. package/dist/hooks/sync/orchestratorShared.js +61 -1
  72. package/dist/hooks/sync/recovery.js +8 -0
  73. package/dist/hooks/sync/recovery.test.js +5 -5
  74. package/dist/hooks/sync/transfers.d.ts +2 -1
  75. package/dist/hooks/sync/transfers.js +36 -9
  76. package/dist/hooks/sync/transfers.test.js +534 -2
  77. package/dist/hooks/useFilterSort.js +13 -13
  78. package/dist/hooks/useFilterSort.test.js +87 -1
  79. package/dist/hooks/useSyncOrchestrator.js +50 -5
  80. package/dist/hooks/useTaskforce.d.ts +85 -50
  81. package/dist/hooks/useTaskforce.js +44 -14
  82. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  83. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  84. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  85. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  86. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  87. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  88. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  89. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  90. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  91. package/dist/mcp/clientIntegrations.d.ts +1 -0
  92. package/dist/mcp/clientIntegrations.js +25 -0
  93. package/dist/mcp/clientIntegrations.test.js +14 -3
  94. package/dist/mcp/clientProfileDefaults.js +3 -3
  95. package/dist/mcp/collaborationRegistrar.js +7 -7
  96. package/dist/mcp/documentAssetRegistrar.js +113 -7
  97. package/dist/mcp/runtime.d.ts +1 -0
  98. package/dist/mcp/runtime.js +969 -122
  99. package/dist/mcp/runtime.test.js +1083 -11
  100. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  101. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  102. package/dist/mcp/toolRegistry.d.ts +56 -0
  103. package/dist/mcp/toolRegistry.js +8 -0
  104. package/dist/mcp/toolRegistry.test.js +4 -0
  105. package/dist/migrations/taskSchemaMigrations.js +21 -0
  106. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  107. package/dist/server/aiProfileAvatarAssets.js +319 -0
  108. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  109. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  110. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/index.test.js +6 -1
  113. package/dist/server/routes/admin.js +187 -9
  114. package/dist/server/routes/annotatedAttachments.js +51 -0
  115. package/dist/server/routes/auth.d.ts +11 -0
  116. package/dist/server/routes/auth.js +189 -27
  117. package/dist/server/routes/authSupport.d.ts +4 -0
  118. package/dist/server/routes/authSupport.js +12 -1
  119. package/dist/server/routes/billing.js +11 -0
  120. package/dist/server/routes/billing.test.js +3 -0
  121. package/dist/server/routes/documents.js +10 -0
  122. package/dist/server/routes/shared.js +0 -10
  123. package/dist/server/routes/sync.integration.test.js +221 -0
  124. package/dist/server/routes/sync.js +13 -3
  125. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  126. package/dist/server/routes/tasks.js +12 -9
  127. package/dist/server/routes.js +87 -8
  128. package/dist/server/routes.test.js +890 -22
  129. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  130. package/dist/shared/aiProfileSurfaceType.js +10 -8
  131. package/dist/storage/objectStorage.d.ts +1 -0
  132. package/dist/storage/objectStorage.js +2 -1
  133. package/dist/storage/objectStorageClient.d.ts +1 -0
  134. package/dist/storage/objectStorageClient.js +41 -8
  135. package/dist/storage/objectStorageClient.test.js +26 -1
  136. package/dist/storage/objectStorageConfig.js +3 -1
  137. package/dist/storage/objectStorageConfig.test.js +13 -1
  138. package/dist/sync/collaborationSyncPayload.js +2 -0
  139. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  140. package/dist/sync/collaborationSyncState.d.ts +3 -3
  141. package/dist/sync/collaborationSyncState.js +19 -12
  142. package/dist/sync/contentSyncPayload.d.ts +5 -1
  143. package/dist/sync/contentSyncPayload.js +9 -0
  144. package/dist/sync/contentSyncPayload.test.js +39 -0
  145. package/dist/sync/contentSyncState.d.ts +5 -1
  146. package/dist/sync/contentSyncState.js +231 -37
  147. package/dist/sync/planningSyncPayload.js +5 -0
  148. package/dist/sync/planningSyncPayload.test.js +24 -0
  149. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  150. package/dist/sync/syncApplyHandlers.js +89 -31
  151. package/dist/sync/syncApplyHandlers.test.js +184 -4
  152. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  153. package/dist/sync/syncResourceAdapters.js +2 -0
  154. package/dist/sync/syncService.d.ts +1 -0
  155. package/dist/sync/syncService.js +13 -1
  156. package/dist/sync/syncService.test.js +39 -0
  157. package/dist/sync/workspacePullFeed.d.ts +4 -0
  158. package/dist/sync/workspacePullFeed.js +129 -70
  159. package/dist/sync/workspacePullFeed.test.js +49 -0
  160. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  161. package/dist/sync/workspaceSyncModel.js +159 -45
  162. package/dist/sync/workspaceSyncModel.test.js +269 -3
  163. package/dist/types.d.ts +33 -2
  164. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  165. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  166. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  167. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  168. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  169. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  170. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  171. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  172. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
  173. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  174. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  175. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  176. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  177. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  178. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  179. package/dist/ui/assets/index-607WPo_X.js +4 -0
  180. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  181. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  182. package/dist/ui/index.html +3 -3
  183. package/dist/ui/product/agent manager.png +0 -0
  184. package/dist/ui/product/agent_manager_02.png +0 -0
  185. package/dist/utils/aiProfileEvents.d.ts +7 -0
  186. package/dist/utils/aiProfileEvents.js +6 -0
  187. package/dist/utils/assignees.d.ts +6 -0
  188. package/dist/utils/assignees.js +8 -1
  189. package/dist/utils/assignees.test.d.ts +1 -0
  190. package/dist/utils/assignees.test.js +25 -0
  191. package/dist/utils/avatarDisplay.d.ts +1 -0
  192. package/dist/utils/avatarDisplay.js +15 -0
  193. package/dist/utils/contextFiles.d.ts +3 -1
  194. package/dist/utils/contextFiles.js +2 -1
  195. package/dist/utils/taskActivity.d.ts +7 -1
  196. package/dist/utils/taskActivity.js +63 -1
  197. package/dist/utils/taskActivity.test.js +23 -1
  198. package/dist/utils/taskSearch.d.ts +22 -0
  199. package/dist/utils/taskSearch.js +87 -0
  200. package/dist/utils/taskSearch.test.d.ts +1 -0
  201. package/dist/utils/taskSearch.test.js +69 -0
  202. package/dist/utils/taskUtils.js +10 -3
  203. package/dist/utils/taskUtils.test.d.ts +1 -0
  204. package/dist/utils/taskUtils.test.js +19 -0
  205. package/package.json +1 -1
  206. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  207. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  208. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  209. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  210. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  211. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  212. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  213. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  214. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  215. 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');
@@ -11953,6 +12416,8 @@ describe('Server Routes', () => {
11953
12416
  expect(matchRoute('PATCH', '/api/taskforce/admin/users/u1/role', routes)).not.toBeNull();
11954
12417
  expect(matchRoute('GET', '/api/taskforce/admin/workspace-audit-logs', routes)).not.toBeNull();
11955
12418
  expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes)).not.toBeNull();
12419
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar/upload', routes)).not.toBeNull();
12420
+ expect(matchRoute('POST', '/api/taskforce/workspace/ai-profiles/metadata', routes)).toBeNull();
11956
12421
  });
11957
12422
  it('POST /api/taskforce/admin/users/invite should authorize against the target workspace', async () => {
11958
12423
  const authConfig = createCloudAuthConfig();
@@ -12382,6 +12847,8 @@ describe('Server Routes', () => {
12382
12847
  color: '#ef6c00',
12383
12848
  avatarUrl: null,
12384
12849
  avatarSourceUrl: null,
12850
+ avatarRevision: 0,
12851
+ avatarUpdatedAt: null,
12385
12852
  kind: 'agent',
12386
12853
  description: 'Builds product code',
12387
12854
  role: 'Implementer',
@@ -12396,7 +12863,86 @@ describe('Server Routes', () => {
12396
12863
  }
12397
12864
  ]);
12398
12865
  });
12399
- it('GET /api/taskforce/workspace/assignee-options should include ai profile tokens for workspace admins', async () => {
12866
+ it('GET /api/taskforce/workspace/assignee-options should support agent-only roster loads', async () => {
12867
+ const authConfig = {
12868
+ runtimeMode: 'cloud',
12869
+ enabled: true,
12870
+ requiredForApi: true,
12871
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
12872
+ defaultRole: 'member',
12873
+ defaultWorkspaceId: 'default',
12874
+ sessionCookieName: 'taskforce_session',
12875
+ sessionSecret: 'test-secret',
12876
+ sessionTtlSeconds: 3600
12877
+ };
12878
+ routes = createRoutes(core, { authConfig });
12879
+ core.listWorkspaceUsers.mockReturnValue([
12880
+ {
12881
+ userId: 'member-1',
12882
+ email: 'member1@example.com',
12883
+ displayName: 'Member One',
12884
+ avatarUrl: null,
12885
+ role: 'member',
12886
+ permissionMode: 'read-write',
12887
+ betaAccess: true,
12888
+ status: 'active',
12889
+ disabled: false
12890
+ }
12891
+ ]);
12892
+ core.listAiProfiles.mockReturnValue([
12893
+ {
12894
+ id: 'ai-profile-1',
12895
+ workspaceId: 'workspace-1',
12896
+ profileToken: 'tfp_codex_sync',
12897
+ name: 'Codex',
12898
+ username: 'codex',
12899
+ icon: 'Bot',
12900
+ color: '#ef6c00',
12901
+ description: 'Builds product code',
12902
+ role: 'Implementer',
12903
+ provider: 'OpenAI',
12904
+ model: 'gpt-5.4',
12905
+ surfaceType: 'coding_tool',
12906
+ seatScope: 'cloud_metered',
12907
+ archivedAt: null,
12908
+ createdAt: '2026-03-01T00:00:00.000Z',
12909
+ updatedAt: '2026-03-01T00:00:00.000Z',
12910
+ lastActiveAt: null
12911
+ }
12912
+ ]);
12913
+ core.countAiProfileUsage.mockReturnValue({ used: 1, limit: 3, remaining: 2 });
12914
+ const match = matchRoute('GET', '/api/taskforce/workspace/assignee-options', routes);
12915
+ const cookie = createSessionTokenCookie('session-member-agent-roster', authConfig, {
12916
+ userId: 'member-user',
12917
+ role: 'member',
12918
+ workspaceId: 'workspace-1'
12919
+ });
12920
+ const req = createMockReq('GET', '/api/taskforce/workspace/assignee-options?kind=agent', undefined, {
12921
+ cookie,
12922
+ 'x-taskforce-runtime-mode': 'cloud',
12923
+ 'x-taskforce-role': 'member',
12924
+ 'x-taskforce-user-id': 'member-user',
12925
+ 'x-taskforce-workspace-id': 'workspace-1'
12926
+ });
12927
+ const res = createMockRes();
12928
+ await match?.handler(req, res, {});
12929
+ expect(core.listWorkspaceUsers).not.toHaveBeenCalled();
12930
+ expect(core.listAiProfiles).toHaveBeenCalledWith('workspace-1', expect.objectContaining({
12931
+ diagnostics: expect.any(Object)
12932
+ }));
12933
+ expect(core.countAiProfileUsage).toHaveBeenCalledWith('workspace-1');
12934
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
12935
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
12936
+ expect(payload.aiProfileSeatUsage).toEqual({ used: 1, limit: 3, remaining: 2 });
12937
+ expect(payload.assignees).toEqual([
12938
+ expect.objectContaining({
12939
+ value: 'ai-profile-1',
12940
+ label: 'Codex',
12941
+ kind: 'agent'
12942
+ })
12943
+ ]);
12944
+ });
12945
+ it('GET /api/taskforce/workspace/assignee-options should not include ai profile tokens for workspace admins', async () => {
12400
12946
  const authConfig = {
12401
12947
  runtimeMode: 'cloud',
12402
12948
  enabled: true,
@@ -12450,9 +12996,187 @@ describe('Server Routes', () => {
12450
12996
  expect(payload.assignees).toEqual([
12451
12997
  expect.objectContaining({
12452
12998
  value: 'ai-profile-1',
12453
- profileToken: 'tfp_codex_sync'
12999
+ label: 'Codex'
12454
13000
  })
12455
13001
  ]);
13002
+ expect(payload.assignees[0]).not.toHaveProperty('profileToken');
13003
+ });
13004
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should reject direct avatar URL writes', async () => {
13005
+ const authConfig = {
13006
+ runtimeMode: 'cloud',
13007
+ enabled: true,
13008
+ requiredForApi: true,
13009
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13010
+ defaultRole: 'member',
13011
+ defaultWorkspaceId: 'default',
13012
+ sessionCookieName: 'taskforce_session',
13013
+ sessionSecret: 'test-secret',
13014
+ sessionTtlSeconds: 3600
13015
+ };
13016
+ routes = createRoutes(core, { authConfig });
13017
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13018
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-reject', authConfig, {
13019
+ userId: 'admin-user',
13020
+ role: 'admin',
13021
+ workspaceId: 'workspace-1'
13022
+ });
13023
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13024
+ profileId: 'ai-profile-1',
13025
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Favatar.webp'
13026
+ }, {
13027
+ cookie,
13028
+ 'x-taskforce-runtime-mode': 'cloud',
13029
+ 'x-taskforce-role': 'admin',
13030
+ 'x-taskforce-user-id': 'admin-user',
13031
+ 'x-taskforce-workspace-id': 'workspace-1'
13032
+ });
13033
+ const res = createMockRes();
13034
+ await match?.handler(req, res, {});
13035
+ expect(core.updateAiProfileAvatar).not.toHaveBeenCalled();
13036
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
13037
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13038
+ expect(payload).toEqual({
13039
+ error: 'AI profile avatar updates must use the stable avatar upload endpoint.',
13040
+ code: 'AI_PROFILE_AVATAR_STABLE_UPLOAD_REQUIRED'
13041
+ });
13042
+ });
13043
+ it('POST /api/taskforce/workspace/ai-profiles/avatar should allow clear-only updates and redact profile tokens', async () => {
13044
+ const authConfig = {
13045
+ runtimeMode: 'cloud',
13046
+ enabled: true,
13047
+ requiredForApi: true,
13048
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13049
+ defaultRole: 'member',
13050
+ defaultWorkspaceId: 'default',
13051
+ sessionCookieName: 'taskforce_session',
13052
+ sessionSecret: 'test-secret',
13053
+ sessionTtlSeconds: 3600
13054
+ };
13055
+ routes = createRoutes(core, { authConfig });
13056
+ core.listAiProfiles.mockReturnValue([
13057
+ {
13058
+ id: 'ai-profile-1',
13059
+ workspaceId: 'workspace-1',
13060
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fdisplay.webp',
13061
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fai-profile-1%2Favatar%2Fsource.webp'
13062
+ }
13063
+ ]);
13064
+ core.updateAiProfileAvatar.mockReturnValue({
13065
+ id: 'ai-profile-1',
13066
+ workspaceId: 'workspace-1',
13067
+ profileToken: 'tfp_codex_sync',
13068
+ name: 'Codex',
13069
+ username: 'codex',
13070
+ icon: 'Bot',
13071
+ color: '#ef6c00',
13072
+ avatarUrl: null,
13073
+ avatarSourceUrl: null,
13074
+ description: 'Builds product code',
13075
+ role: 'Implementer',
13076
+ provider: 'OpenAI',
13077
+ model: 'gpt-5.4',
13078
+ surfaceType: 'coding_tool',
13079
+ archivedAt: null,
13080
+ createdAt: '2026-03-01T00:00:00.000Z',
13081
+ updatedAt: '2026-04-19T17:00:00.000Z',
13082
+ lastActiveAt: null
13083
+ });
13084
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', routes);
13085
+ const cookie = createSessionTokenCookie('session-admin-avatar-ai-profile-clear', authConfig, {
13086
+ userId: 'admin-user',
13087
+ role: 'admin',
13088
+ workspaceId: 'workspace-1'
13089
+ });
13090
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/avatar', {
13091
+ profileId: 'ai-profile-1',
13092
+ avatarUrl: null,
13093
+ avatarSourceUrl: null
13094
+ }, {
13095
+ cookie,
13096
+ 'x-taskforce-runtime-mode': 'cloud',
13097
+ 'x-taskforce-role': 'admin',
13098
+ 'x-taskforce-user-id': 'admin-user',
13099
+ 'x-taskforce-workspace-id': 'workspace-1'
13100
+ });
13101
+ const res = createMockRes();
13102
+ await match?.handler(req, res, {});
13103
+ expect(core.updateAiProfileAvatar).toHaveBeenCalledWith({
13104
+ workspaceId: 'workspace-1',
13105
+ profileId: 'ai-profile-1',
13106
+ avatarUrl: null,
13107
+ avatarSourceUrl: null
13108
+ });
13109
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13110
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13111
+ expect(payload.success).toBe(true);
13112
+ expect(payload.profile).toEqual(expect.objectContaining({
13113
+ id: 'ai-profile-1',
13114
+ avatarUrl: null,
13115
+ avatarSourceUrl: null
13116
+ }));
13117
+ expect(payload.profile).not.toHaveProperty('profileToken');
13118
+ });
13119
+ it('POST /api/taskforce/workspace/ai-profiles/merge should redact profile tokens from admin responses', async () => {
13120
+ const authConfig = {
13121
+ runtimeMode: 'cloud',
13122
+ enabled: true,
13123
+ requiredForApi: true,
13124
+ excludedPaths: ['/api/taskforce/health', '/api/taskforce/auth'],
13125
+ defaultRole: 'member',
13126
+ defaultWorkspaceId: 'default',
13127
+ sessionCookieName: 'taskforce_session',
13128
+ sessionSecret: 'test-secret',
13129
+ sessionTtlSeconds: 3600
13130
+ };
13131
+ routes = createRoutes(core, { authConfig });
13132
+ core.mergeAiProfiles.mockReturnValue({
13133
+ id: 'ai-profile-keep',
13134
+ workspaceId: 'workspace-1',
13135
+ profileToken: 'tfp_codex_sync',
13136
+ name: 'Codex',
13137
+ username: 'codex',
13138
+ icon: 'Bot',
13139
+ color: '#ef6c00',
13140
+ description: 'Builds product code',
13141
+ role: 'Implementer',
13142
+ provider: 'OpenAI',
13143
+ model: 'gpt-5.4',
13144
+ surfaceType: 'coding_tool',
13145
+ archivedAt: null,
13146
+ createdAt: '2026-03-01T00:00:00.000Z',
13147
+ updatedAt: '2026-04-19T17:00:00.000Z',
13148
+ lastActiveAt: null
13149
+ });
13150
+ const match = matchRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', routes);
13151
+ const cookie = createSessionTokenCookie('session-admin-merge-ai-profile', authConfig, {
13152
+ userId: 'admin-user',
13153
+ role: 'admin',
13154
+ workspaceId: 'workspace-1'
13155
+ });
13156
+ const req = createMockReq('POST', '/api/taskforce/workspace/ai-profiles/merge', {
13157
+ keepId: 'ai-profile-keep',
13158
+ mergeId: 'ai-profile-merge'
13159
+ }, {
13160
+ cookie,
13161
+ 'x-taskforce-runtime-mode': 'cloud',
13162
+ 'x-taskforce-role': 'admin',
13163
+ 'x-taskforce-user-id': 'admin-user',
13164
+ 'x-taskforce-workspace-id': 'workspace-1'
13165
+ });
13166
+ const res = createMockRes();
13167
+ await match?.handler(req, res, {});
13168
+ expect(core.mergeAiProfiles).toHaveBeenCalledWith({
13169
+ workspaceId: 'workspace-1',
13170
+ keepId: 'ai-profile-keep',
13171
+ mergeId: 'ai-profile-merge'
13172
+ });
13173
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
13174
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13175
+ expect(payload.profile).toEqual(expect.objectContaining({
13176
+ id: 'ai-profile-keep',
13177
+ name: 'Codex'
13178
+ }));
13179
+ expect(payload.profile).not.toHaveProperty('profileToken');
12456
13180
  });
12457
13181
  it('POST /api/taskforce/workspace/ai-profiles/archive should archive active profiles for workspace admins', async () => {
12458
13182
  const authConfig = {
@@ -12518,6 +13242,7 @@ describe('Server Routes', () => {
12518
13242
  id: 'ai-profile-1',
12519
13243
  archivedReason: 'manual_archive'
12520
13244
  }));
13245
+ expect(payload.profile).not.toHaveProperty('profileToken');
12521
13246
  });
12522
13247
  it('POST /api/taskforce/workspace/ai-profiles/archive should honor actual owner membership even when the request role is stale', async () => {
12523
13248
  const authConfig = {
@@ -12837,7 +13562,11 @@ describe('Server Routes', () => {
12837
13562
  core.getGlobalSettings.mockReturnValue({
12838
13563
  site: {
12839
13564
  showRunTaskforceLocally: false,
12840
- pricingPageEnabled: false
13565
+ pricingPageEnabled: false,
13566
+ animatedBackgroundEnabled: true,
13567
+ siteTheme: 'light',
13568
+ pricingPageTitle: 'Choose Your Plan',
13569
+ pricingPageDescription: 'Compare plans and pick the right one.'
12841
13570
  }
12842
13571
  });
12843
13572
  const match = matchRoute('GET', '/api/taskforce/admin/site-settings', routes);
@@ -12847,14 +13576,26 @@ describe('Server Routes', () => {
12847
13576
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12848
13577
  expect(payload.status.showRunTaskforceLocally).toBe(false);
12849
13578
  expect(payload.status.pricingPageEnabled).toBe(false);
13579
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13580
+ expect(payload.status.siteTheme).toBe('light');
13581
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13582
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
12850
13583
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
12851
13584
  expect(payload.settings.pricingPageEnabled).toBe(false);
13585
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13586
+ expect(payload.settings.siteTheme).toBe('light');
13587
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13588
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
12852
13589
  });
12853
13590
  it('GET /api/taskforce/admin/site_settings should expose effective website settings for legacy admin bundles', async () => {
12854
13591
  core.getGlobalSettings.mockReturnValue({
12855
13592
  site: {
12856
13593
  showRunTaskforceLocally: false,
12857
- pricingPageEnabled: false
13594
+ pricingPageEnabled: false,
13595
+ animatedBackgroundEnabled: true,
13596
+ siteTheme: 'light',
13597
+ pricingPageTitle: 'Choose Your Plan',
13598
+ pricingPageDescription: 'Compare plans and pick the right one.'
12858
13599
  }
12859
13600
  });
12860
13601
  const match = matchRoute('GET', '/api/taskforce/admin/site_settings', routes);
@@ -12864,21 +13605,37 @@ describe('Server Routes', () => {
12864
13605
  const payload = JSON.parse(res.end.mock.calls[0][0]);
12865
13606
  expect(payload.status.showRunTaskforceLocally).toBe(false);
12866
13607
  expect(payload.status.pricingPageEnabled).toBe(false);
13608
+ expect(payload.status.animatedBackgroundEnabled).toBe(true);
13609
+ expect(payload.status.siteTheme).toBe('light');
13610
+ expect(payload.status.pricingPageTitle).toBe('Choose Your Plan');
13611
+ expect(payload.status.pricingPageDescription).toBe('Compare plans and pick the right one.');
12867
13612
  expect(payload.settings.showRunTaskforceLocally).toBe(false);
12868
13613
  expect(payload.settings.pricingPageEnabled).toBe(false);
13614
+ expect(payload.settings.animatedBackgroundEnabled).toBe(true);
13615
+ expect(payload.settings.siteTheme).toBe('light');
13616
+ expect(payload.settings.pricingPageTitle).toBe('Choose Your Plan');
13617
+ expect(payload.settings.pricingPageDescription).toBe('Compare plans and pick the right one.');
12869
13618
  });
12870
13619
  it('POST /api/taskforce/admin/site-settings should persist website settings', async () => {
12871
13620
  const match = matchRoute('POST', '/api/taskforce/admin/site-settings', routes);
12872
13621
  const req = createMockReq('POST', '/api/taskforce/admin/site-settings', {
12873
13622
  showRunTaskforceLocally: false,
12874
- pricingPageEnabled: false
13623
+ pricingPageEnabled: false,
13624
+ animatedBackgroundEnabled: true,
13625
+ siteTheme: 'light',
13626
+ pricingPageTitle: 'Choose Your Plan',
13627
+ pricingPageDescription: 'Compare plans and pick the right one.'
12875
13628
  }, { 'x-taskforce-role': 'admin' });
12876
13629
  const res = createMockRes();
12877
13630
  await match?.handler(req, res, {});
12878
13631
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
12879
13632
  site: {
12880
13633
  showRunTaskforceLocally: false,
12881
- pricingPageEnabled: false
13634
+ pricingPageEnabled: false,
13635
+ animatedBackgroundEnabled: true,
13636
+ siteTheme: 'light',
13637
+ pricingPageTitle: 'Choose Your Plan',
13638
+ pricingPageDescription: 'Compare plans and pick the right one.'
12882
13639
  }
12883
13640
  });
12884
13641
  });
@@ -12886,14 +13643,22 @@ describe('Server Routes', () => {
12886
13643
  const match = matchRoute('POST', '/api/taskforce/admin/site_settings', routes);
12887
13644
  const req = createMockReq('POST', '/api/taskforce/admin/site_settings', {
12888
13645
  showRunTaskforceLocally: false,
12889
- pricingPageEnabled: false
13646
+ pricingPageEnabled: false,
13647
+ animatedBackgroundEnabled: true,
13648
+ siteTheme: 'light',
13649
+ pricingPageTitle: 'Choose Your Plan',
13650
+ pricingPageDescription: 'Compare plans and pick the right one.'
12890
13651
  }, { 'x-taskforce-role': 'admin' });
12891
13652
  const res = createMockRes();
12892
13653
  await match?.handler(req, res, {});
12893
13654
  expect(core.updateGlobalSettings).toHaveBeenCalledWith({
12894
13655
  site: {
12895
13656
  showRunTaskforceLocally: false,
12896
- pricingPageEnabled: false
13657
+ pricingPageEnabled: false,
13658
+ animatedBackgroundEnabled: true,
13659
+ siteTheme: 'light',
13660
+ pricingPageTitle: 'Choose Your Plan',
13661
+ pricingPageDescription: 'Compare plans and pick the right one.'
12897
13662
  }
12898
13663
  });
12899
13664
  });
@@ -12901,7 +13666,11 @@ describe('Server Routes', () => {
12901
13666
  core.getGlobalSettings.mockReturnValue({
12902
13667
  site: {
12903
13668
  showRunTaskforceLocally: false,
12904
- pricingPageEnabled: false
13669
+ pricingPageEnabled: false,
13670
+ animatedBackgroundEnabled: true,
13671
+ siteTheme: 'light',
13672
+ pricingPageTitle: 'Choose Your Plan',
13673
+ pricingPageDescription: 'Compare plans and pick the right one.'
12905
13674
  }
12906
13675
  });
12907
13676
  const match = matchRoute('GET', '/api/taskforce/public/site-config', routes);
@@ -12916,7 +13685,11 @@ describe('Server Routes', () => {
12916
13685
  success: true,
12917
13686
  config: {
12918
13687
  showRunTaskforceLocally: false,
12919
- pricingPageEnabled: false
13688
+ pricingPageEnabled: false,
13689
+ animatedBackgroundEnabled: true,
13690
+ siteTheme: 'light',
13691
+ pricingPageTitle: 'Choose Your Plan',
13692
+ pricingPageDescription: 'Compare plans and pick the right one.'
12920
13693
  }
12921
13694
  });
12922
13695
  });
@@ -12924,7 +13697,11 @@ describe('Server Routes', () => {
12924
13697
  core.getGlobalSettings.mockReturnValue({
12925
13698
  site: {
12926
13699
  showRunTaskforceLocally: false,
12927
- pricingPageEnabled: false
13700
+ pricingPageEnabled: false,
13701
+ animatedBackgroundEnabled: true,
13702
+ siteTheme: 'light',
13703
+ pricingPageTitle: 'Choose Your Plan',
13704
+ pricingPageDescription: 'Compare plans and pick the right one.'
12928
13705
  }
12929
13706
  });
12930
13707
  const match = matchRoute('GET', '/api/taskforce/public/site-config-script', routes);
@@ -12937,6 +13714,9 @@ describe('Server Routes', () => {
12937
13714
  }));
12938
13715
  expect(res.end.mock.calls[0][0]).toContain('window.__TASKFORCE_SITE_CONFIG__ = ');
12939
13716
  expect(res.end.mock.calls[0][0]).toContain('"pricingPageEnabled":false');
13717
+ expect(res.end.mock.calls[0][0]).toContain('"animatedBackgroundEnabled":true');
13718
+ expect(res.end.mock.calls[0][0]).toContain('"siteTheme":"light"');
13719
+ expect(res.end.mock.calls[0][0]).toContain('"pricingPageTitle":"Choose Your Plan"');
12940
13720
  });
12941
13721
  it('POST /api/taskforce/admin/system-settings should reject invalid onboarding settings', async () => {
12942
13722
  core.validateOnboardingPolicy.mockReturnValueOnce({
@@ -13113,6 +13893,94 @@ describe('Server Routes', () => {
13113
13893
  ]);
13114
13894
  fs.rmSync(dbRoot, { recursive: true, force: true });
13115
13895
  });
13896
+ it('GET /api/taskforce/admin/orphaned-assets should not list AI profile avatar assets still referenced by profiles', async () => {
13897
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
13898
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cropped.webp';
13899
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
13900
+ core.getTenantId = vi.fn().mockReturnValue('default');
13901
+ core.listAiProfiles.mockReturnValueOnce([
13902
+ {
13903
+ id: 'ai-profile-codex',
13904
+ workspaceId: 'workspace-1',
13905
+ name: 'Codex',
13906
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
13907
+ avatarSourceUrl: null,
13908
+ createdAt: '2026-03-18T17:00:00.000Z',
13909
+ updatedAt: '2026-03-18T17:05:00.000Z'
13910
+ }
13911
+ ]);
13912
+ store.upsertAsset({
13913
+ assetId: 'asset-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
13914
+ workspaceId: 'workspace-1',
13915
+ kind: 'image',
13916
+ originalFilename: 'avatar.webp',
13917
+ mimeType: 'image/webp',
13918
+ storageProvider: 'r2',
13919
+ storageKey: avatarStorageKey,
13920
+ contentSha256: 'sha-avatar',
13921
+ sizeBytes: 42,
13922
+ createdAt: '2026-03-18T17:00:00.000Z',
13923
+ updatedAt: '2026-03-18T17:05:00.000Z'
13924
+ });
13925
+ routes = createRoutes(core);
13926
+ const match = matchRoute('GET', '/api/taskforce/admin/orphaned-assets', routes);
13927
+ const req = createMockReq('GET', '/api/taskforce/admin/orphaned-assets?workspaceId=workspace-1', undefined, {
13928
+ 'x-taskforce-role': 'admin',
13929
+ 'x-taskforce-workspace-id': 'workspace-1'
13930
+ });
13931
+ const res = createMockRes();
13932
+ await match?.handler(req, res, {});
13933
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13934
+ expect(payload.success).toBe(true);
13935
+ expect(payload.assets).toEqual([]);
13936
+ fs.rmSync(dbRoot, { recursive: true, force: true });
13937
+ });
13938
+ it('POST /api/taskforce/admin/orphaned-assets/delete should reject AI profile avatar assets still referenced by profiles', async () => {
13939
+ const { tmpRoot: dbRoot, db, store } = createWorkspaceAssetTestStore();
13940
+ const avatarStorageKey = 'workspaces/workspace-1/assets/images/ai-profiles/ai-profile-codex/avatar/asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-cropped.webp';
13941
+ core.getDatabaseAdapter = vi.fn().mockReturnValue(db);
13942
+ core.getTenantId = vi.fn().mockReturnValue('default');
13943
+ core.listAiProfiles.mockReturnValueOnce([
13944
+ {
13945
+ id: 'ai-profile-codex',
13946
+ workspaceId: 'workspace-1',
13947
+ name: 'Codex',
13948
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(avatarStorageKey)}`,
13949
+ avatarSourceUrl: null,
13950
+ createdAt: '2026-03-18T17:00:00.000Z',
13951
+ updatedAt: '2026-03-18T17:05:00.000Z'
13952
+ }
13953
+ ]);
13954
+ store.upsertAsset({
13955
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
13956
+ workspaceId: 'workspace-1',
13957
+ kind: 'image',
13958
+ originalFilename: 'avatar.webp',
13959
+ mimeType: 'image/webp',
13960
+ storageProvider: 'r2',
13961
+ storageKey: avatarStorageKey,
13962
+ contentSha256: 'sha-avatar',
13963
+ sizeBytes: 42,
13964
+ createdAt: '2026-03-18T17:00:00.000Z',
13965
+ updatedAt: '2026-03-18T17:05:00.000Z'
13966
+ });
13967
+ routes = createRoutes(core);
13968
+ const match = matchRoute('POST', '/api/taskforce/admin/orphaned-assets/delete', routes);
13969
+ const req = createMockReq('POST', '/api/taskforce/admin/orphaned-assets/delete', {
13970
+ assetId: 'asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
13971
+ workspaceId: 'workspace-1'
13972
+ }, {
13973
+ 'x-taskforce-role': 'admin',
13974
+ 'x-taskforce-workspace-id': 'workspace-1'
13975
+ });
13976
+ const res = createMockRes();
13977
+ await match?.handler(req, res, {});
13978
+ expect(res.writeHead).toHaveBeenCalledWith(409, expect.any(Object));
13979
+ const payload = JSON.parse(res.end.mock.calls[0][0]);
13980
+ expect(payload.error).toMatch(/referenced by an AI profile avatar/i);
13981
+ expect(store.get('asset-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'workspace-1')?.deletedAt).toBeNull();
13982
+ fs.rmSync(dbRoot, { recursive: true, force: true });
13983
+ });
13116
13984
  it('POST /api/taskforce/admin/object-storage-settings should persist sanitized values', async () => {
13117
13985
  const match = matchRoute('POST', '/api/taskforce/admin/object-storage-settings', routes);
13118
13986
  const req = createMockReq('POST', '/api/taskforce/admin/object-storage-settings', {