@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
@@ -157,6 +157,7 @@ function setupBillingSchema(db) {
157
157
  tenant_id TEXT NOT NULL,
158
158
  plan_version_id TEXT NOT NULL,
159
159
  billing_interval TEXT NOT NULL,
160
+ pricing_audience TEXT NOT NULL DEFAULT 'public',
160
161
  stripe_price_id TEXT NOT NULL,
161
162
  pricing_type TEXT NOT NULL DEFAULT 'stripe',
162
163
  active INTEGER NOT NULL DEFAULT 1,
@@ -164,10 +165,10 @@ function setupBillingSchema(db) {
164
165
  currency TEXT,
165
166
  created_at TEXT NOT NULL,
166
167
  updated_at TEXT NOT NULL,
167
- PRIMARY KEY (tenant_id, plan_version_id, billing_interval, stripe_price_id)
168
+ PRIMARY KEY (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id)
168
169
  );
169
170
  CREATE UNIQUE INDEX idx_billing_price_mappings_active_interval
170
- ON billing_price_mappings (tenant_id, plan_version_id, billing_interval)
171
+ ON billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience)
171
172
  WHERE active = 1;
172
173
  CREATE TABLE plan_versions (
173
174
  plan_version_id TEXT PRIMARY KEY,
@@ -257,6 +258,7 @@ function readMockPlanVersionFeatures(db, tenantId, planVersionId) {
257
258
  function buildBillingRouteHandlers(db, opts) {
258
259
  const routes = new Map();
259
260
  const tenantId = 'default';
261
+ const siteSettings = opts?.siteSettings || {};
260
262
  const resolvePlanIdForVersion = (planVersionIdRaw) => {
261
263
  const planVersionId = String(planVersionIdRaw || '').trim();
262
264
  if (!planVersionId)
@@ -431,6 +433,9 @@ function buildBillingRouteHandlers(db, opts) {
431
433
  planId: planVersionId.startsWith('pro') ? 'pro' : 'starter',
432
434
  entitlementState: 'active'
433
435
  }),
436
+ getGlobalSettings: () => ({
437
+ site: siteSettings
438
+ }),
434
439
  resolveUserIdentity: () => opts?.userIdentity === undefined
435
440
  ? {
436
441
  userId: 'u1',
@@ -1001,6 +1006,39 @@ describe('Billing Webhook Routes', () => {
1001
1006
  expect(args.customer).toBeUndefined();
1002
1007
  expect(args.customer_email).toBe('owner@example.com');
1003
1008
  });
1009
+ it('creates checkout with the campaign price mapping when requested', async () => {
1010
+ const now = new Date().toISOString();
1011
+ db.prepare(`
1012
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1013
+ VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1014
+ `).run(now, now);
1015
+ db.prepare(`
1016
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, active, created_at, updated_at)
1017
+ VALUES ('default', 'collab-v1', 'month', 'public', 'price_collab_month', 1, ?, ?)
1018
+ `).run(now, now);
1019
+ db.prepare(`
1020
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, active, created_at, updated_at)
1021
+ VALUES ('default', 'collab-v1', 'month', 'campaign', 'price_collab_campaign_month', 1, ?, ?)
1022
+ `).run(now, now);
1023
+ const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1024
+ const req = createMockReq(JSON.stringify({
1025
+ planVersionId: 'collab-v1',
1026
+ interval: 'month',
1027
+ pricingAudience: 'campaign'
1028
+ }), {
1029
+ 'x-taskforce-runtime-mode': 'cloud',
1030
+ host: 'app.taskforcehq.ai'
1031
+ });
1032
+ const res = createMockRes();
1033
+ await checkoutHandler?.(req, res, {});
1034
+ expect(res.statusCode).toBe(200);
1035
+ const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1036
+ expect(args.line_items?.[0]?.price).toBe('price_collab_campaign_month');
1037
+ expect(String(args.success_url || '')).toContain('pricingAudience=campaign');
1038
+ expect(String(args.cancel_url || '')).toContain('pricingAudience=campaign');
1039
+ expect(args.metadata?.pricingAudience).toBe('campaign');
1040
+ expect(args.subscription_data?.metadata?.pricingAudience).toBe('campaign');
1041
+ });
1004
1042
  it('uses per-version trial days when creating checkout', async () => {
1005
1043
  const now = new Date().toISOString();
1006
1044
  db.prepare(`
@@ -2546,6 +2584,55 @@ describe('Billing Webhook Routes', () => {
2546
2584
  { stripe_price_id: 'price_old', active: 0, unit_amount: 1500 }
2547
2585
  ]);
2548
2586
  });
2587
+ it('keeps campaign price mappings separate from public mappings in admin routes', async () => {
2588
+ const now = new Date().toISOString();
2589
+ db.prepare(`
2590
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2591
+ VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2592
+ `).run(now, now);
2593
+ db.prepare(`
2594
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2595
+ VALUES ('default', 'collab-v1', 'month', 'public', 'price_public', 'stripe', 1, 1500, 'usd', ?, ?)
2596
+ `).run(now, now);
2597
+ mockPriceRetrieve.mockResolvedValueOnce({
2598
+ id: 'price_campaign',
2599
+ active: true,
2600
+ currency: 'usd',
2601
+ unit_amount: 900,
2602
+ recurring: { interval: 'month' }
2603
+ });
2604
+ const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2605
+ const upsertReq = createMockReq(JSON.stringify({
2606
+ planVersionId: 'collab-v1',
2607
+ interval: 'month',
2608
+ pricingAudience: 'campaign',
2609
+ stripePriceId: 'price_campaign',
2610
+ active: true
2611
+ }), { 'x-taskforce-runtime-mode': 'cloud' });
2612
+ const upsertRes = createMockRes();
2613
+ await upsertRoute(upsertReq, upsertRes, {});
2614
+ expect(upsertRes.statusCode).toBe(200);
2615
+ expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2616
+ mapping: expect.objectContaining({
2617
+ plan_version_id: 'collab-v1',
2618
+ billing_interval: 'month',
2619
+ pricing_audience: 'campaign',
2620
+ stripe_price_id: 'price_campaign',
2621
+ active: 1,
2622
+ unit_amount: 900
2623
+ })
2624
+ }));
2625
+ const rows = db.prepare(`
2626
+ SELECT pricing_audience, stripe_price_id, active
2627
+ FROM billing_price_mappings
2628
+ WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
2629
+ ORDER BY pricing_audience ASC
2630
+ `).all();
2631
+ expect(rows).toEqual([
2632
+ { pricing_audience: 'campaign', stripe_price_id: 'price_campaign', active: 1 },
2633
+ { pricing_audience: 'public', stripe_price_id: 'price_public', active: 1 }
2634
+ ]);
2635
+ });
2549
2636
  it('rejects manual reuse of a Stripe price id across multiple plan versions', async () => {
2550
2637
  const now = new Date().toISOString();
2551
2638
  db.prepare(`
@@ -2723,6 +2810,10 @@ describe('Billing Webhook Routes', () => {
2723
2810
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2724
2811
  VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1500, 'usd', ?, ?)
2725
2812
  `).run(now, now);
2813
+ db.prepare(`
2814
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2815
+ VALUES ('default', 'pro-v1', 'month', 'campaign', 'price_pro_campaign_month', 'stripe', 1, 900, 'usd', ?, ?)
2816
+ `).run(now, now);
2726
2817
  db.prepare(`
2727
2818
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2728
2819
  VALUES ('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, NULL, ?, ?)
@@ -2733,6 +2824,8 @@ describe('Billing Webhook Routes', () => {
2733
2824
  expect(res.statusCode).toBe(200);
2734
2825
  const body = JSON.parse(res.body);
2735
2826
  expect(body.plans).toBeInstanceOf(Array);
2827
+ expect(body.pageTitle).toBe('Subscription Plans');
2828
+ expect(body.pageDescription).toBe('Manage your subscription and explore available features. Your current plan is highlighted below.');
2736
2829
  expect(body.pricingSource).toEqual({
2737
2830
  environment: 'production',
2738
2831
  runtimeMode: 'cloud'
@@ -2751,6 +2844,14 @@ describe('Billing Webhook Routes', () => {
2751
2844
  expect(monthPricing.currency).toBe('usd');
2752
2845
  expect(monthPricing.stripePriceId).toBe('price_pro_month');
2753
2846
  expect(proPlan.pricing.year).toBeNull();
2847
+ expect(proPlan.campaignPricing.month).toEqual(expect.objectContaining({
2848
+ pricingType: 'stripe',
2849
+ unitAmount: 900,
2850
+ currency: 'usd',
2851
+ stripePriceId: 'price_pro_campaign_month',
2852
+ interval: 'month'
2853
+ }));
2854
+ expect(proPlan.campaignPricing.year).toBeNull();
2754
2855
  // Free plan is explicitly free, not "missing"
2755
2856
  expect(freePlan.pricing.month.pricingType).toBe('free');
2756
2857
  expect(freePlan.pricing.month.unitAmount).toBe(0);
@@ -2791,6 +2892,40 @@ describe('Billing Webhook Routes', () => {
2791
2892
  const secondBody = JSON.parse(secondRes.body);
2792
2893
  expect(secondBody.plans.find((p) => p.planId === 'pro')?.pricing?.month?.unitAmount).toBe(2400);
2793
2894
  });
2895
+ it('invalidates cached public pricing when pricing page header copy changes', async () => {
2896
+ const now = new Date().toISOString();
2897
+ const siteSettings = {};
2898
+ routeHandlers = buildBillingRouteHandlers(db, { siteSettings });
2899
+ const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2900
+ expect(pricingRoute).toBeTypeOf('function');
2901
+ db.prepare(`
2902
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2903
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2904
+ `).run(now, now);
2905
+ db.prepare(`
2906
+ INSERT INTO billing_price_mappings (
2907
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2908
+ ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2909
+ `).run(now, now);
2910
+ const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2911
+ const firstRes = createMockRes();
2912
+ await pricingRoute(firstReq, firstRes, {});
2913
+ expect(firstRes.statusCode).toBe(200);
2914
+ expect(JSON.parse(firstRes.body)).toMatchObject({
2915
+ pageTitle: 'Subscription Plans',
2916
+ pageDescription: 'Manage your subscription and explore available features. Your current plan is highlighted below.'
2917
+ });
2918
+ siteSettings.pricingPageTitle = 'Choose Your Plan';
2919
+ siteSettings.pricingPageDescription = 'Compare plans and pick the right fit for your team.';
2920
+ const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2921
+ const secondRes = createMockRes();
2922
+ await pricingRoute(secondReq, secondRes, {});
2923
+ expect(secondRes.statusCode).toBe(200);
2924
+ expect(JSON.parse(secondRes.body)).toMatchObject({
2925
+ pageTitle: 'Choose Your Plan',
2926
+ pageDescription: 'Compare plans and pick the right fit for your team.'
2927
+ });
2928
+ });
2794
2929
  it('invalidates cached public pricing when plan features change', async () => {
2795
2930
  const now = new Date().toISOString();
2796
2931
  routeHandlers = buildBillingRouteHandlers(db);
@@ -2844,7 +2979,8 @@ describe('Billing Webhook Routes', () => {
2844
2979
  });
2845
2980
  it('includes public feature copy and invalidates cached public pricing when catalog overrides change', async () => {
2846
2981
  const now = new Date().toISOString();
2847
- routeHandlers = buildBillingRouteHandlers(db);
2982
+ const siteSettings = {};
2983
+ routeHandlers = buildBillingRouteHandlers(db, { siteSettings });
2848
2984
  const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2849
2985
  expect(pricingRoute).toBeTypeOf('function');
2850
2986
  db.prepare(`
@@ -2874,15 +3010,11 @@ describe('Billing Webhook Routes', () => {
2874
3010
  featureKey: 'collaboration.ai_profiles',
2875
3011
  label: 'AI Profiles',
2876
3012
  publicLabel: 'AI teammates',
2877
- publicDescription: 'Add reusable AI teammates to your workspace.'
3013
+ publicDescription: 'Add reusable AI teammates to your workspace.',
3014
+ publicDescriptionVisible: false
2878
3015
  })
2879
3016
  ]);
2880
- const updatedAt = new Date(Date.now() + 1000).toISOString();
2881
- db.prepare(`
2882
- UPDATE plan_feature_catalog_overrides
2883
- SET public_label = 'AI copilots', updated_at = ?
2884
- WHERE tenant_id = 'default' AND feature_key = 'collaboration.ai_profiles'
2885
- `).run(updatedAt);
3017
+ siteSettings.pricingPageShowPublicDescriptions = true;
2886
3018
  const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2887
3019
  const secondRes = createMockRes();
2888
3020
  await pricingRoute(secondReq, secondRes, {});
@@ -2891,7 +3023,25 @@ describe('Billing Webhook Routes', () => {
2891
3023
  expect(secondBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
2892
3024
  expect.objectContaining({
2893
3025
  featureKey: 'collaboration.ai_profiles',
2894
- publicLabel: 'AI copilots'
3026
+ publicDescriptionVisible: true
3027
+ })
3028
+ ]);
3029
+ const updatedAt = new Date(Date.now() + 1000).toISOString();
3030
+ db.prepare(`
3031
+ UPDATE plan_feature_catalog_overrides
3032
+ SET public_label = 'AI copilots', updated_at = ?
3033
+ WHERE tenant_id = 'default' AND feature_key = 'collaboration.ai_profiles'
3034
+ `).run(updatedAt);
3035
+ const thirdReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3036
+ const thirdRes = createMockRes();
3037
+ await pricingRoute(thirdReq, thirdRes, {});
3038
+ expect(thirdRes.statusCode).toBe(200);
3039
+ const thirdBody = JSON.parse(thirdRes.body);
3040
+ expect(thirdBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
3041
+ expect.objectContaining({
3042
+ featureKey: 'collaboration.ai_profiles',
3043
+ publicLabel: 'AI copilots',
3044
+ publicDescriptionVisible: true
2895
3045
  })
2896
3046
  ]);
2897
3047
  });
@@ -2949,6 +3099,7 @@ describe('Billing Webhook Routes', () => {
2949
3099
  await pricingRoute(req, res, {});
2950
3100
  expect(res.statusCode).toBe(200);
2951
3101
  const body = JSON.parse(res.body);
3102
+ expect(body.pageTitle).toBe('Subscription Plans');
2952
3103
  expect(body.pricingSource).toEqual({
2953
3104
  environment: 'performance',
2954
3105
  runtimeMode: 'cloud'
@@ -474,6 +474,14 @@ export function registerDocumentRoutes(deps) {
474
474
  if (shouldProxyInlineUserAvatar && await proxyCloudUserProfileAvatar(req, res, normalizedRelative, contentTypeForExt)) {
475
475
  return;
476
476
  }
477
+ if (shouldProxyInlineAiAvatar && filePath && fs.existsSync(filePath)) {
478
+ res.writeHead(200, {
479
+ 'Content-Type': contentTypeForExt(ext),
480
+ 'Content-Disposition': contentDisposition
481
+ });
482
+ res.end(fs.readFileSync(filePath));
483
+ return;
484
+ }
477
485
  res.writeHead(404, { 'Content-Type': 'text/plain' });
478
486
  res.end('Context file not found');
479
487
  return;
@@ -873,6 +881,7 @@ export function registerDocumentRoutes(deps) {
873
881
  }));
874
882
  return;
875
883
  }
884
+ const attachedAt = new Date().toISOString();
876
885
  const payload = buildTaskAttachmentPayload(asset.storageKey, {
877
886
  caption: getCanonicalDocumentName({
878
887
  assetId: asset.assetId,
@@ -883,6 +892,7 @@ export function registerDocumentRoutes(deps) {
883
892
  }),
884
893
  assetId: asset.assetId,
885
894
  taskId: targetId,
895
+ timestamp: attachedAt,
886
896
  });
887
897
  if (!payload) {
888
898
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -216,16 +216,6 @@ export function makeResolveAuthenticatedMutationUserId(core, context, requestWor
216
216
  const explicitUserId = String(requestUserId(req) || '').trim();
217
217
  if (explicitUserId && explicitUserId !== 'anonymous')
218
218
  return explicitUserId;
219
- if (requestRuntimeMode(req) === 'local') {
220
- const workspaceId = String(requestWorkspaceId(req) || '').trim() || 'default';
221
- const fallbackUser = core.listWorkspaceUsers(workspaceId).find((user) => {
222
- const status = String(user.status || '').trim().toLowerCase();
223
- return status === 'active' && user.disabled !== true;
224
- });
225
- const fallbackUserId = String(fallbackUser?.userId || '').trim();
226
- if (fallbackUserId)
227
- return fallbackUserId;
228
- }
229
219
  throw new TaskforceRuleError('Authenticated user required for mutation attribution.', 401, 'MUTATION_AUTH_REQUIRED');
230
220
  };
231
221
  }
@@ -1676,6 +1676,227 @@ describe('Server Routes Workspace Sync Integration', () => {
1676
1676
  })
1677
1677
  ]);
1678
1678
  });
1679
+ it('round-trips planning attachment links through pull and apply-local routes', async () => {
1680
+ const cloudProjectRoot = createIsolatedProjectRoot('taskforce-sync-planning-link-cloud-');
1681
+ const localProjectRoot = createIsolatedProjectRoot('taskforce-sync-planning-link-local-');
1682
+ const cloudCore = new TaskforceCore({ projectRoot: cloudProjectRoot, storagePath: '.taskforce' });
1683
+ seedAuthenticatedWorkspace(cloudCore);
1684
+ cloudCore.createInitiative({
1685
+ id: 'initiative-cloud-link-roundtrip-1',
1686
+ title: 'Cloud initiative attachment target',
1687
+ createdAt: '2026-03-18T09:00:00.000Z',
1688
+ updatedAt: '2026-03-18T09:00:00.000Z'
1689
+ }, workspaceId);
1690
+ cloudCore.createWorkstream({
1691
+ id: 'workstream-cloud-link-roundtrip-1',
1692
+ initiativeId: 'initiative-cloud-link-roundtrip-1',
1693
+ title: 'Cloud workstream attachment target',
1694
+ createdAt: '2026-03-18T09:00:00.000Z',
1695
+ updatedAt: '2026-03-18T09:00:00.000Z'
1696
+ }, workspaceId);
1697
+ const cloudBasePath = cloudCore.getPaths().basePath;
1698
+ const cloudStore = new WorkspaceAssetStore(cloudCore.getDatabaseAdapter(), cloudCore.getTenantId());
1699
+ const markdownPath = `workspaces/${workspaceId}/assets/documents/asset-cloud-planning-link-plan.md`;
1700
+ const filePath = `workspaces/${workspaceId}/assets/files/asset-cloud-planning-link-notes.txt`;
1701
+ fs.mkdirSync(path.dirname(path.join(cloudBasePath, markdownPath)), { recursive: true });
1702
+ fs.mkdirSync(path.dirname(path.join(cloudBasePath, filePath)), { recursive: true });
1703
+ fs.writeFileSync(path.join(cloudBasePath, markdownPath), '# planning sync\n', 'utf8');
1704
+ fs.writeFileSync(path.join(cloudBasePath, filePath), 'planning attachment', 'utf8');
1705
+ cloudStore.upsertAsset({
1706
+ assetId: 'asset-cloud-planning-link-doc-1',
1707
+ workspaceId,
1708
+ kind: 'document',
1709
+ originalFilename: 'plan.md',
1710
+ mimeType: 'text/markdown',
1711
+ storageProvider: 'local',
1712
+ storageKey: markdownPath,
1713
+ contentSha256: 'planning-doc-roundtrip-hash',
1714
+ sizeBytes: '# planning sync\n'.length,
1715
+ documentId: 'doc-cloud-planning-link-1',
1716
+ version: 1,
1717
+ isLatest: true,
1718
+ createdAt: '2026-03-18T09:00:00.000Z',
1719
+ updatedAt: '2026-03-18T09:00:00.000Z'
1720
+ });
1721
+ cloudStore.upsertLink({
1722
+ workspaceId,
1723
+ assetId: 'asset-cloud-planning-link-doc-1',
1724
+ targetType: 'initiative',
1725
+ targetId: 'initiative-cloud-link-roundtrip-1',
1726
+ role: 'attachment',
1727
+ updatedAt: '2026-03-18T10:00:00.000Z'
1728
+ });
1729
+ cloudStore.upsertAsset({
1730
+ assetId: 'asset-cloud-planning-link-file-1',
1731
+ workspaceId,
1732
+ kind: 'file',
1733
+ originalFilename: 'notes.txt',
1734
+ mimeType: 'text/plain',
1735
+ storageProvider: 'local',
1736
+ storageKey: filePath,
1737
+ contentSha256: 'planning-file-roundtrip-hash',
1738
+ sizeBytes: 'planning attachment'.length,
1739
+ isLatest: true,
1740
+ createdAt: '2026-03-18T09:00:00.000Z',
1741
+ updatedAt: '2026-03-18T09:00:00.000Z'
1742
+ });
1743
+ cloudStore.upsertLink({
1744
+ workspaceId,
1745
+ assetId: 'asset-cloud-planning-link-file-1',
1746
+ targetType: 'workstream',
1747
+ targetId: 'workstream-cloud-link-roundtrip-1',
1748
+ role: 'attachment',
1749
+ updatedAt: '2026-03-18T10:00:01.000Z'
1750
+ });
1751
+ const cloudRoutes = createRoutes(cloudCore, { authConfig });
1752
+ const pullMatch = matchRoute('GET', '/api/taskforce/sync/workspace/pull', cloudRoutes);
1753
+ expect(pullMatch).toBeTruthy();
1754
+ const cookie = createAuthCookie(workspaceId);
1755
+ const pullReq = createMockReq('GET', '/api/taskforce/sync/workspace/pull?workspaceId=workspace-local-active&limit=50&cursor=eyJ1IjoiMjAyNi0wMy0xOFQwOTozMDowMC4wMDBaIiwiaWQiOiJzZWVkIn0', undefined, { cookie });
1756
+ const pullRes = createMockRes();
1757
+ await pullMatch?.handler(pullReq, pullRes, {});
1758
+ expect(pullRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1759
+ const pullBody = readJsonBody(pullRes);
1760
+ const pulledChanges = Array.isArray(pullBody?.changes) ? pullBody.changes : [];
1761
+ expect(pulledChanges).toEqual(expect.arrayContaining([
1762
+ expect.objectContaining({
1763
+ op: 'document-upsert',
1764
+ path: markdownPath,
1765
+ assetId: 'asset-cloud-planning-link-doc-1',
1766
+ linkTargetType: 'initiative',
1767
+ linkTargetId: 'initiative-cloud-link-roundtrip-1',
1768
+ originalFilename: 'plan.md',
1769
+ linkRole: 'attachment'
1770
+ }),
1771
+ expect.objectContaining({
1772
+ op: 'asset-upsert',
1773
+ path: filePath,
1774
+ assetId: 'asset-cloud-planning-link-file-1',
1775
+ linkTargetType: 'workstream',
1776
+ linkTargetId: 'workstream-cloud-link-roundtrip-1',
1777
+ originalFilename: 'notes.txt',
1778
+ linkRole: 'attachment'
1779
+ })
1780
+ ]));
1781
+ const localCore = new TaskforceCore({ projectRoot: localProjectRoot, storagePath: '.taskforce' });
1782
+ localCore.createWorkspace({ workspaceId, name: 'Local Active Workspace' });
1783
+ localCore.createInitiative({
1784
+ id: 'initiative-cloud-link-roundtrip-1',
1785
+ title: 'Local initiative attachment target',
1786
+ createdAt: '2026-03-18T09:00:00.000Z',
1787
+ updatedAt: '2026-03-18T09:00:00.000Z'
1788
+ }, workspaceId);
1789
+ localCore.createWorkstream({
1790
+ id: 'workstream-cloud-link-roundtrip-1',
1791
+ initiativeId: 'initiative-cloud-link-roundtrip-1',
1792
+ title: 'Local workstream attachment target',
1793
+ createdAt: '2026-03-18T09:00:00.000Z',
1794
+ updatedAt: '2026-03-18T09:00:00.000Z'
1795
+ }, workspaceId);
1796
+ const localRoutes = createRoutes(localCore, { authConfig: { runtimeMode: 'local', enabled: false } });
1797
+ const applyMatch = matchRoute('POST', '/api/taskforce/sync/workspace/apply-local', localRoutes);
1798
+ expect(applyMatch).toBeTruthy();
1799
+ const applyReq = createMockReq('POST', '/api/taskforce/sync/workspace/apply-local', { changes: pulledChanges }, { 'x-taskforce-workspace-id': workspaceId });
1800
+ const applyRes = createMockRes();
1801
+ await applyMatch?.handler(applyReq, applyRes, {});
1802
+ expect(applyRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1803
+ const reloadedLocalCore = new TaskforceCore({ projectRoot: localProjectRoot, storagePath: '.taskforce' });
1804
+ const reloadedStore = new WorkspaceAssetStore(reloadedLocalCore.getDatabaseAdapter(), reloadedLocalCore.getTenantId());
1805
+ expect(fs.readFileSync(path.join(reloadedLocalCore.getPaths().basePath, markdownPath), 'utf8')).toBe('# planning sync\n');
1806
+ expect(fs.readFileSync(path.join(reloadedLocalCore.getPaths().basePath, filePath), 'utf8')).toBe('planning attachment');
1807
+ expect(reloadedStore.listLinksForAsset('asset-cloud-planning-link-doc-1', workspaceId)).toEqual([
1808
+ expect.objectContaining({
1809
+ targetType: 'initiative',
1810
+ targetId: 'initiative-cloud-link-roundtrip-1',
1811
+ taskId: null,
1812
+ role: 'attachment',
1813
+ deletedAt: null
1814
+ })
1815
+ ]);
1816
+ expect(reloadedStore.listLinksForAsset('asset-cloud-planning-link-file-1', workspaceId)).toEqual([
1817
+ expect.objectContaining({
1818
+ targetType: 'workstream',
1819
+ targetId: 'workstream-cloud-link-roundtrip-1',
1820
+ taskId: null,
1821
+ role: 'attachment',
1822
+ deletedAt: null
1823
+ })
1824
+ ]);
1825
+ });
1826
+ it('pushes document attachment links when only the link metadata changed locally', async () => {
1827
+ const projectRoot = createIsolatedProjectRoot('taskforce-sync-doc-link-only-push-');
1828
+ const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1829
+ seedAuthenticatedWorkspace(seedCore);
1830
+ seedCore.createTask({
1831
+ id: 'task-doc-link-only-push-1',
1832
+ title: 'Document link target',
1833
+ status: 'task'
1834
+ }, {
1835
+ workspaceId,
1836
+ allowClientProvidedId: true
1837
+ });
1838
+ const seedBasePath = seedCore.getPaths().basePath;
1839
+ const relativeDocPath = `workspaces/${workspaceId}/assets/documents/asset-doc-link-only-push-plan.md`;
1840
+ const absoluteDocPath = path.join(seedBasePath, relativeDocPath);
1841
+ const content = '# already synced\n';
1842
+ fs.mkdirSync(path.dirname(absoluteDocPath), { recursive: true });
1843
+ fs.writeFileSync(absoluteDocPath, content, 'utf8');
1844
+ const contentHash = createHash('sha256').update(content).digest('hex');
1845
+ seedCore.upsertDocumentWatermark(workspaceId, relativeDocPath, contentHash, '2026-03-18T13:30:00.000Z');
1846
+ const seedStore = new WorkspaceAssetStore(seedCore.getDatabaseAdapter(), seedCore.getTenantId());
1847
+ seedStore.upsertAsset({
1848
+ assetId: 'asset-doc-link-only-push-1',
1849
+ workspaceId,
1850
+ kind: 'document',
1851
+ originalFilename: 'plan.md',
1852
+ mimeType: 'text/markdown',
1853
+ storageProvider: 'local',
1854
+ storageKey: relativeDocPath,
1855
+ contentSha256: contentHash,
1856
+ sizeBytes: Buffer.byteLength(content, 'utf8'),
1857
+ documentId: 'doc-link-only-push-1',
1858
+ version: 1,
1859
+ isLatest: true,
1860
+ createdAt: '2026-03-18T13:30:00.000Z',
1861
+ updatedAt: '2026-03-18T13:30:00.000Z'
1862
+ });
1863
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1864
+ const routes = createRoutes(core, { authConfig });
1865
+ const pushMatch = matchRoute('POST', '/api/taskforce/sync/workspace/push', routes);
1866
+ expect(pushMatch).toBeTruthy();
1867
+ const req = createMockReq('POST', '/api/taskforce/sync/workspace/push', {
1868
+ workspaceId,
1869
+ idempotencyKey: 'doc-link-only-push-1',
1870
+ changes: [
1871
+ {
1872
+ op: 'document-upsert',
1873
+ path: relativeDocPath,
1874
+ updatedAt: '2026-03-18T14:45:00.000Z',
1875
+ content,
1876
+ assetId: 'asset-doc-link-only-push-1',
1877
+ documentId: 'doc-link-only-push-1',
1878
+ referenceNumber: 12,
1879
+ version: 1,
1880
+ taskId: 'task-doc-link-only-push-1',
1881
+ originalFilename: 'plan.md',
1882
+ linkRole: 'attachment'
1883
+ }
1884
+ ]
1885
+ }, { cookie: createAuthCookie(workspaceId) });
1886
+ const res = createMockRes();
1887
+ await pushMatch?.handler(req, res, {});
1888
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
1889
+ const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1890
+ const reloadedStore = new WorkspaceAssetStore(reloadedCore.getDatabaseAdapter(), reloadedCore.getTenantId());
1891
+ expect(reloadedStore.listLinksForAsset('asset-doc-link-only-push-1', workspaceId)).toEqual([
1892
+ expect.objectContaining({
1893
+ taskId: 'task-doc-link-only-push-1',
1894
+ role: 'attachment',
1895
+ updatedAt: '2026-03-18T14:45:00.000Z',
1896
+ deletedAt: null
1897
+ })
1898
+ ]);
1899
+ });
1679
1900
  it('propagates document-delete through authenticated push and omits deleted doc from pull payload', async () => {
1680
1901
  const projectRoot = createIsolatedProjectRoot('taskforce-sync-push-pull-doc-delete-');
1681
1902
  const seedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
@@ -3,7 +3,7 @@ import { parseJsonBody } from './primitives.js';
3
3
  import { normalizeTaskDataRelativePath, ObjectStorageClient } from '../../storage/objectStorageClient.js';
4
4
  import { WorkspaceAssetStore, } from '../../storage/workspaceAssetStore.js';
5
5
  import { resolveSessionCookieAttributesForRequest } from '../auth.js';
6
- import { isAiProfileSurfaceType } from '../../shared/aiProfileSurfaceType.js';
6
+ import { isAiProfileSurfaceType, parseAiProfileSurfaceType } from '../../shared/aiProfileSurfaceType.js';
7
7
  import { isAiProfileSeatScope } from '../../shared/aiProfileSeatScope.js';
8
8
  import { serializeBinaryAssetSyncPayload, serializeDocumentSyncPayload, } from '../../sync/contentSyncPayload.js';
9
9
  import { createContentSyncState } from '../../sync/contentSyncState.js';
@@ -52,6 +52,8 @@ function normalizeIncomingAiProfileSurfaceType(value, context) {
52
52
  const normalized = String(value || '').trim();
53
53
  if (!normalized)
54
54
  return null;
55
+ if (normalized === 'other')
56
+ return parseAiProfileSurfaceType(normalized);
55
57
  if (!isAiProfileSurfaceType(normalized)) {
56
58
  throw createSyncRouteError(`Invalid ai profile surfaceType "${normalized}"`, {
57
59
  ...context,
@@ -79,11 +81,17 @@ function normalizeTaskEventSyncPayload(raw, workspaceId, sourceWatermark) {
79
81
  return null;
80
82
  const source = raw;
81
83
  const id = String(source.id || '').trim();
84
+ const entityType = source.entityType === 'initiative' || source.entityType === 'workstream'
85
+ ? source.entityType
86
+ : source.entity_type === 'initiative' || source.entity_type === 'workstream'
87
+ ? source.entity_type
88
+ : 'task';
82
89
  const taskId = String(source.taskId || source.task_id || '').trim();
90
+ const entityId = String(source.entityId || source.entity_id || taskId).trim();
83
91
  const action = String(source.action || '').trim();
84
92
  const actor = String(source.actor || 'system').trim() || 'system';
85
93
  const createdAt = String(source.createdAt || source.created_at || sourceWatermark || '').trim();
86
- if (!id || !taskId || !action || !Number.isFinite(Date.parse(createdAt)))
94
+ if (!id || !entityId || !action || !Number.isFinite(Date.parse(createdAt)))
87
95
  return null;
88
96
  const actorType = source.actorType === 'ai' || source.actorType === 'human' || source.actorType === 'system'
89
97
  ? source.actorType
@@ -99,8 +107,10 @@ function normalizeTaskEventSyncPayload(raw, workspaceId, sourceWatermark) {
99
107
  : {};
100
108
  return {
101
109
  id,
102
- taskId,
103
110
  workspaceId,
111
+ entityType,
112
+ entityId,
113
+ taskId: entityType === 'task' ? (taskId || entityId) : undefined,
104
114
  action,
105
115
  actor,
106
116
  actorType,
@@ -59,6 +59,8 @@ export function registerWorkspaceSnapshotSyncRoutes({ addRoute, core, context, r
59
59
  profile.profileToken,
60
60
  profile.avatarUrl ?? '',
61
61
  profile.avatarSourceUrl ?? '',
62
+ profile.avatarRevision ?? 0,
63
+ profile.avatarUpdatedAt ?? '',
62
64
  profile.surfaceType ?? '',
63
65
  profile.seatScope ?? '',
64
66
  profile.archivedAt ?? '',