@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
@@ -8,9 +8,9 @@ import { createDatabaseAdapter } from '../storage/createDatabaseAdapter.js';
8
8
  import { WorkspaceAssetStore } from '../storage/workspaceAssetStore.js';
9
9
  import { AuthTokenService } from './AuthTokenService.js';
10
10
  import { AuthIdentityService } from './AuthIdentityService.js';
11
- import { archiveAiProfileService, getAiProfileByTokenService, listAiProfilesService, mergeAiProfilesService, resolveAiProfileService, updateAiProfileAvatarService, upsertAiProfileForSyncService, } from './AiProfileService.js';
12
- import { syncCanonicalTaskAttachmentLinksService, } from './AttachmentLinkService.js';
13
- import { addTaskEventService, buildTaskActivityService, listTaskEventsInternalService, listWorkspaceTaskEventsService, replaceTaskCommentsService, replaceTaskEventsService, upsertTaskEventForSyncService, } from './TaskActivityService.js';
11
+ import { archiveAiProfileService, getAiProfileByTokenService, listAiProfilesService, mergeAiProfilesService, resolveAiProfileService, updateAiProfileAvatarService, updateAiProfileSurfaceTypeService, upsertAiProfileForSyncService, } from './AiProfileService.js';
12
+ import { syncCanonicalAttachmentLinksForTargetService, syncCanonicalTaskAttachmentLinksService, } from './AttachmentLinkService.js';
13
+ import { addEntityEventService, addTaskEventService, buildEntityActivityService, buildTaskActivityService, getEntityEventForSyncService, getTaskEventForSyncService, listEntityEventsInternalService, listWorkspaceEntityEventsForSyncService, listTaskEventsInternalService, listWorkspaceTaskEventsForSyncService, replaceEntityEventsService, replaceTaskCommentsService, replaceTaskEventsService, upsertEntityEventForSyncService, upsertTaskEventForSyncService, } from './TaskActivityService.js';
14
14
  import { addDeletedTaskRecordService, getDeletedTaskService, listDeletedTasksService, mapDeletedTaskRowService, normalizeTaskSnapshotForRestoreService, pruneDeletedTasksService, purgeExpiredDeletedTasksService, } from './DeletedTaskLifecycleService.js';
15
15
  import { applyWorkspaceSyncSnapshotService, deleteDocumentWatermarkService, getDocumentWatermarkService, listTasksForSyncService, listWorkspaceSyncDeletesSinceService, readPushIdempotencyService, recordSyncEventService, upsertDocumentWatermarkService, writePushIdempotencyService, } from './SyncReconciliationService.js';
16
16
  import { getGlobalSettingsService, hasPersistedGlobalSettingsService, readGlobalConfigService, updateGlobalSettingsService, writeGlobalConfigService, } from './GlobalSettingsService.js';
@@ -268,6 +268,15 @@ export class TaskforceCore {
268
268
  const normalized = String(value || '').trim().toLowerCase();
269
269
  return normalized === 'user' || normalized === 'human';
270
270
  }
271
+ isStableActorReference(value) {
272
+ const normalized = String(value || '').trim().toLowerCase();
273
+ if (!normalized)
274
+ return false;
275
+ return normalized === 'user'
276
+ || normalized === 'system'
277
+ || normalized.startsWith('user-')
278
+ || normalized.startsWith('ai-profile-');
279
+ }
271
280
  normalizeAiProfileName(name) {
272
281
  const normalized = String(name || '').trim();
273
282
  if (!normalized) {
@@ -375,6 +384,8 @@ export class TaskforceCore {
375
384
  color: this.normalizeAiProfileColor(row.color),
376
385
  avatarUrl: typeof row.avatar_url === 'string' && row.avatar_url.trim().length > 0 ? row.avatar_url.trim() : null,
377
386
  avatarSourceUrl: typeof row.avatar_source_url === 'string' && row.avatar_source_url.trim().length > 0 ? row.avatar_source_url.trim() : null,
387
+ avatarRevision: Number.isFinite(Number(row.avatar_revision)) ? Math.max(0, Math.floor(Number(row.avatar_revision))) : 0,
388
+ avatarUpdatedAt: typeof row.avatar_updated_at === 'string' && row.avatar_updated_at.trim().length > 0 ? row.avatar_updated_at.trim() : null,
378
389
  description: typeof row.description === 'string' && row.description.trim().length > 0 ? row.description.trim() : null,
379
390
  role: typeof row.role === 'string' && row.role.trim().length > 0 ? row.role.trim() : null,
380
391
  provider: typeof row.provider === 'string' && row.provider.trim().length > 0 ? row.provider.trim() : null,
@@ -404,7 +415,6 @@ export class TaskforceCore {
404
415
  color: profile.color,
405
416
  isNamed: true,
406
417
  meta: {
407
- profileToken: profile.profileToken,
408
418
  workspaceId: profile.workspaceId,
409
419
  avatarUrl: profile.avatarUrl ?? null,
410
420
  avatarSourceUrl: profile.avatarSourceUrl ?? null,
@@ -423,6 +433,59 @@ export class TaskforceCore {
423
433
  }
424
434
  };
425
435
  }
436
+ resolveCanonicalAiProfileId(rowById, profileId) {
437
+ let currentId = String(profileId || '').trim();
438
+ const visited = new Set();
439
+ while (currentId) {
440
+ if (visited.has(currentId))
441
+ break;
442
+ visited.add(currentId);
443
+ const row = rowById.get(currentId);
444
+ const mergedIntoProfileId = String(row?.merged_into_profile_id || '').trim();
445
+ if (!mergedIntoProfileId || mergedIntoProfileId === currentId)
446
+ break;
447
+ currentId = mergedIntoProfileId;
448
+ }
449
+ return currentId;
450
+ }
451
+ findCanonicalAiProfileIdsByAlias(workspaceId, actorRef) {
452
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
453
+ const rawActorRef = String(actorRef || '').trim();
454
+ if (!rawActorRef)
455
+ return [];
456
+ const matches = this.db.prepare(`
457
+ SELECT *
458
+ FROM ai_profiles
459
+ WHERE tenant_id = ?
460
+ AND workspace_id = ?
461
+ AND (
462
+ LOWER(name) = LOWER(?)
463
+ OR LOWER(username) = LOWER(?)
464
+ )
465
+ ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
466
+ `).all(this.tenantId, normalizedWorkspaceId, rawActorRef, rawActorRef);
467
+ const rowById = new Map(matches
468
+ .map((row) => [String(row.id || '').trim(), row])
469
+ .filter(([id]) => Boolean(id)));
470
+ return [...new Set(matches
471
+ .map((row) => this.resolveCanonicalAiProfileId(rowById, String(row.id || '').trim()))
472
+ .filter(Boolean))];
473
+ }
474
+ resolveUniqueAiProfileActorByAlias(workspaceId, actorRef) {
475
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
476
+ const uniqueCanonicalIds = this.findCanonicalAiProfileIdsByAlias(normalizedWorkspaceId, actorRef);
477
+ if (uniqueCanonicalIds.length !== 1)
478
+ return null;
479
+ const resolvedRow = this.db.prepare(`
480
+ SELECT *
481
+ FROM ai_profiles
482
+ WHERE tenant_id = ?
483
+ AND workspace_id = ?
484
+ AND id = ?
485
+ LIMIT 1
486
+ `).get(this.tenantId, normalizedWorkspaceId, uniqueCanonicalIds[0]);
487
+ return resolvedRow ? this.aiProfileToActor(this.aiProfileRowToRecord(resolvedRow)) : null;
488
+ }
426
489
  canonicalizeActorReference(workspaceId, value, fallback) {
427
490
  const raw = String(value || '').trim();
428
491
  if (!raw)
@@ -531,8 +594,21 @@ export class TaskforceCore {
531
594
  normalized.fsPath = raw.fsPath.trim();
532
595
  if (typeof raw.caption === 'string' && raw.caption.trim())
533
596
  normalized.caption = raw.caption.trim();
597
+ if (typeof raw.displayName === 'string' && raw.displayName.trim())
598
+ normalized.displayName = raw.displayName.trim();
599
+ if (typeof raw.originalFilename === 'string' && raw.originalFilename.trim())
600
+ normalized.originalFilename = raw.originalFilename.trim();
534
601
  if (typeof raw.timestamp === 'string' && raw.timestamp.trim())
535
602
  normalized.timestamp = raw.timestamp.trim();
603
+ if (typeof raw.assetId === 'string' && raw.assetId.trim())
604
+ normalized.assetId = raw.assetId.trim();
605
+ const referenceNumber = this.sanitizeTaskReferenceNumber(raw.referenceNumber);
606
+ if (referenceNumber !== null)
607
+ normalized.referenceNumber = referenceNumber;
608
+ if (typeof raw.referenceLabel === 'string' && raw.referenceLabel.trim())
609
+ normalized.referenceLabel = raw.referenceLabel.trim();
610
+ if (typeof raw.taskId === 'string' && raw.taskId.trim())
611
+ normalized.taskId = raw.taskId.trim();
536
612
  return normalized;
537
613
  })
538
614
  .filter((attachment) => attachment !== null);
@@ -552,15 +628,27 @@ export class TaskforceCore {
552
628
  actorProfile
553
629
  };
554
630
  }
631
+ listEntityEventsInternal(entityType, entityId, workspaceId) {
632
+ return listEntityEventsInternalService(this.getTaskActivityServiceDeps(), entityType, entityId, workspaceId);
633
+ }
555
634
  listTaskEventsInternal(taskId, workspaceId) {
556
635
  return listTaskEventsInternalService(this.getTaskActivityServiceDeps(), taskId, workspaceId);
557
636
  }
637
+ buildEntityActivity(comments, entityEvents, hiddenActions) {
638
+ return buildEntityActivityService(comments, entityEvents, hiddenActions?.length ? { hiddenActions } : undefined);
639
+ }
558
640
  buildTaskActivity(comments, taskEvents) {
559
641
  return buildTaskActivityService(comments, taskEvents);
560
642
  }
643
+ addEntityEvent(input) {
644
+ addEntityEventService(this.getTaskActivityServiceDeps(), input);
645
+ }
561
646
  addTaskEvent(input) {
562
647
  addTaskEventService(this.getTaskActivityServiceDeps(), input);
563
648
  }
649
+ replaceEntityEvents(entityType, entityId, workspaceId, entityEvents) {
650
+ replaceEntityEventsService(this.getTaskActivityServiceDeps(), entityType, entityId, workspaceId, entityEvents);
651
+ }
564
652
  replaceTaskEvents(taskId, workspaceId, taskEvents) {
565
653
  replaceTaskEventsService(this.getTaskActivityServiceDeps(), taskId, workspaceId, taskEvents);
566
654
  }
@@ -1066,6 +1154,65 @@ export class TaskforceCore {
1066
1154
  }
1067
1155
  return (existingValue ?? null) === (nextValue ?? null);
1068
1156
  }
1157
+ buildPlanningEntityEventChanges(existing, next, fields) {
1158
+ const changes = {};
1159
+ for (const field of fields) {
1160
+ const previousValue = existing[field] ?? null;
1161
+ const nextValue = next[field] ?? null;
1162
+ if (JSON.stringify(previousValue) === JSON.stringify(nextValue))
1163
+ continue;
1164
+ changes[field] = { from: previousValue, to: nextValue };
1165
+ }
1166
+ return changes;
1167
+ }
1168
+ getPlanningEntityEventAction(entityType, changes) {
1169
+ const fields = Object.keys(changes);
1170
+ if (fields.length === 1 && fields[0] === 'attachments') {
1171
+ return `${entityType}-attachments-changed`;
1172
+ }
1173
+ if (entityType === 'workstream' && fields.length === 1 && fields[0] === 'initiativeId') {
1174
+ return 'workstream-relationship-changed';
1175
+ }
1176
+ return `${entityType}-updated`;
1177
+ }
1178
+ addWorkstreamTaskMembershipEvents(input) {
1179
+ const previousWorkstreamId = this.sanitizeWorkstreamId(input.previousWorkstreamId);
1180
+ const nextWorkstreamId = this.sanitizeWorkstreamId(input.nextWorkstreamId);
1181
+ if (previousWorkstreamId === nextWorkstreamId)
1182
+ return;
1183
+ const details = {
1184
+ taskId: input.task.id,
1185
+ taskTitle: input.task.title,
1186
+ changes: {
1187
+ workstreamId: {
1188
+ from: previousWorkstreamId ?? null,
1189
+ to: nextWorkstreamId ?? null,
1190
+ }
1191
+ }
1192
+ };
1193
+ if (previousWorkstreamId) {
1194
+ this.addEntityEvent({
1195
+ entityType: 'workstream',
1196
+ entityId: previousWorkstreamId,
1197
+ workspaceId: input.workspaceId,
1198
+ action: 'workstream-task-removed',
1199
+ actor: input.actor,
1200
+ createdAt: input.createdAt,
1201
+ details,
1202
+ });
1203
+ }
1204
+ if (nextWorkstreamId) {
1205
+ this.addEntityEvent({
1206
+ entityType: 'workstream',
1207
+ entityId: nextWorkstreamId,
1208
+ workspaceId: input.workspaceId,
1209
+ action: 'workstream-task-added',
1210
+ actor: input.actor,
1211
+ createdAt: input.createdAt,
1212
+ details,
1213
+ });
1214
+ }
1215
+ }
1069
1216
  assertWorkstreamLinkAllowed(workstreamId, workspaceId) {
1070
1217
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
1071
1218
  if (!workstreamId)
@@ -1470,6 +1617,19 @@ export class TaskforceCore {
1470
1617
  FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
1471
1618
  );
1472
1619
 
1620
+ CREATE TABLE IF NOT EXISTS entity_events (
1621
+ id TEXT PRIMARY KEY,
1622
+ tenant_id TEXT NOT NULL DEFAULT 'default',
1623
+ workspace_id TEXT NOT NULL DEFAULT 'default',
1624
+ entity_type TEXT NOT NULL,
1625
+ entity_id TEXT NOT NULL,
1626
+ action TEXT NOT NULL,
1627
+ actor TEXT NOT NULL,
1628
+ actor_type TEXT NOT NULL,
1629
+ details_json TEXT,
1630
+ created_at TEXT NOT NULL
1631
+ );
1632
+
1473
1633
  CREATE TABLE IF NOT EXISTS deleted_tasks (
1474
1634
  id TEXT PRIMARY KEY,
1475
1635
  tenant_id TEXT NOT NULL DEFAULT 'default',
@@ -1598,6 +1758,9 @@ export class TaskforceCore {
1598
1758
  token_prefix TEXT NOT NULL,
1599
1759
  token_hash TEXT NOT NULL,
1600
1760
  status TEXT NOT NULL DEFAULT 'active',
1761
+ ai_profile_id TEXT,
1762
+ ai_profile_token TEXT,
1763
+ ai_profile_name TEXT,
1601
1764
  last_used_at TEXT,
1602
1765
  revoked_at TEXT,
1603
1766
  revoked_by_user_id TEXT,
@@ -1656,6 +1819,8 @@ export class TaskforceCore {
1656
1819
  color TEXT NOT NULL,
1657
1820
  avatar_url TEXT,
1658
1821
  avatar_source_url TEXT,
1822
+ avatar_revision INTEGER NOT NULL DEFAULT 0,
1823
+ avatar_updated_at TEXT,
1659
1824
  description TEXT,
1660
1825
  role TEXT,
1661
1826
  provider TEXT,
@@ -1682,6 +1847,8 @@ export class TaskforceCore {
1682
1847
  CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_workspace_created ON admin_audit_logs(workspace_id, created_at);
1683
1848
  CREATE INDEX IF NOT EXISTS idx_task_events_task_created ON task_events(tenant_id, workspace_id, task_id, created_at);
1684
1849
  CREATE INDEX IF NOT EXISTS idx_task_events_workspace_created ON task_events(tenant_id, workspace_id, created_at);
1850
+ CREATE INDEX IF NOT EXISTS idx_entity_events_entity_created ON entity_events(tenant_id, workspace_id, entity_type, entity_id, created_at);
1851
+ CREATE INDEX IF NOT EXISTS idx_entity_events_workspace_created ON entity_events(tenant_id, workspace_id, created_at);
1685
1852
  CREATE UNIQUE INDEX IF NOT EXISTS idx_deleted_tasks_workspace_task ON deleted_tasks(tenant_id, workspace_id, task_id);
1686
1853
  CREATE INDEX IF NOT EXISTS idx_deleted_tasks_workspace_deleted ON deleted_tasks(tenant_id, workspace_id, deleted_at);
1687
1854
  CREATE INDEX IF NOT EXISTS idx_mcp_audit_logs_workspace_created ON mcp_audit_logs(tenant_id, workspace_id, created_at);
@@ -1786,6 +1953,25 @@ export class TaskforceCore {
1786
1953
  // Best-effort schema repair for older deployments.
1787
1954
  }
1788
1955
  }
1956
+ if (this.db.provider === 'sqlite') {
1957
+ this.db.exec(`
1958
+ INSERT OR IGNORE INTO entity_events (
1959
+ id, tenant_id, workspace_id, entity_type, entity_id, action, actor, actor_type, details_json, created_at
1960
+ )
1961
+ SELECT id, tenant_id, workspace_id, 'task', task_id, action, actor, actor_type, details_json, created_at
1962
+ FROM task_events
1963
+ `);
1964
+ }
1965
+ else {
1966
+ this.db.exec(`
1967
+ INSERT INTO entity_events (
1968
+ id, tenant_id, workspace_id, entity_type, entity_id, action, actor, actor_type, details_json, created_at
1969
+ )
1970
+ SELECT id, tenant_id, workspace_id, 'task', task_id, action, actor, actor_type, details_json, created_at
1971
+ FROM task_events
1972
+ ON CONFLICT (id) DO NOTHING
1973
+ `);
1974
+ }
1789
1975
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workstream ON tasks(tenant_id, workspace_id, workstream_id)`);
1790
1976
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(tenant_id, workspace_id, assignee, is_archived)`);
1791
1977
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_workstreams_initiative ON workstreams(tenant_id, workspace_id, initiative_id)`);
@@ -1806,6 +1992,28 @@ export class TaskforceCore {
1806
1992
  }
1807
1993
  }
1808
1994
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_auth_tokens_workspace_purpose ON auth_tokens(workspace_id, purpose)`);
1995
+ if (this.db.provider === 'sqlite') {
1996
+ const mcpOauthGrantColumns = this.db.prepare(`PRAGMA table_info(mcp_oauth_connector_grants)`).all();
1997
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_id')) {
1998
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_id TEXT`);
1999
+ }
2000
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_token')) {
2001
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_token TEXT`);
2002
+ }
2003
+ if (!mcpOauthGrantColumns.some((c) => c.name === 'ai_profile_name')) {
2004
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN ai_profile_name TEXT`);
2005
+ }
2006
+ }
2007
+ else {
2008
+ try {
2009
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_id TEXT`);
2010
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_token TEXT`);
2011
+ this.db.exec(`ALTER TABLE mcp_oauth_connector_grants ADD COLUMN IF NOT EXISTS ai_profile_name TEXT`);
2012
+ }
2013
+ catch {
2014
+ // Best-effort schema repair for existing deployments.
2015
+ }
2016
+ }
1809
2017
  if (this.db.provider === 'sqlite') {
1810
2018
  const mcpAuditColumns = this.db.prepare(`PRAGMA table_info(mcp_audit_logs)`).all();
1811
2019
  if (!mcpAuditColumns.some((c) => c.name === 'request_id')) {
@@ -1906,6 +2114,8 @@ export class TaskforceCore {
1906
2114
  { name: 'color', ddl: `ALTER TABLE ai_profiles ADD COLUMN color TEXT NOT NULL DEFAULT '#64748b'` },
1907
2115
  { name: 'avatar_url', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_url TEXT` },
1908
2116
  { name: 'avatar_source_url', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_source_url TEXT` },
2117
+ { name: 'avatar_revision', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_revision INTEGER NOT NULL DEFAULT 0` },
2118
+ { name: 'avatar_updated_at', ddl: `ALTER TABLE ai_profiles ADD COLUMN avatar_updated_at TEXT` },
1909
2119
  { name: 'description', ddl: `ALTER TABLE ai_profiles ADD COLUMN description TEXT` },
1910
2120
  { name: 'role', ddl: `ALTER TABLE ai_profiles ADD COLUMN role TEXT` },
1911
2121
  { name: 'provider', ddl: `ALTER TABLE ai_profiles ADD COLUMN provider TEXT` },
@@ -1938,6 +2148,8 @@ export class TaskforceCore {
1938
2148
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#64748b'`,
1939
2149
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_url TEXT`,
1940
2150
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_source_url TEXT`,
2151
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_revision INTEGER NOT NULL DEFAULT 0`,
2152
+ `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS avatar_updated_at TEXT`,
1941
2153
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS description TEXT`,
1942
2154
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS role TEXT`,
1943
2155
  `ALTER TABLE ai_profiles ADD COLUMN IF NOT EXISTS provider TEXT`,
@@ -1978,10 +2190,30 @@ export class TaskforceCore {
1978
2190
  AND roster_state_updated_at < archived_at
1979
2191
  )
1980
2192
  `);
2193
+ this.db.exec(`
2194
+ UPDATE ai_profiles
2195
+ SET avatar_revision = CASE
2196
+ WHEN (
2197
+ (avatar_url IS NOT NULL AND TRIM(avatar_url) <> '')
2198
+ OR (avatar_source_url IS NOT NULL AND TRIM(avatar_source_url) <> '')
2199
+ ) THEN 1
2200
+ ELSE 0
2201
+ END,
2202
+ avatar_updated_at = CASE
2203
+ WHEN (
2204
+ (avatar_url IS NOT NULL AND TRIM(avatar_url) <> '')
2205
+ OR (avatar_source_url IS NOT NULL AND TRIM(avatar_source_url) <> '')
2206
+ ) THEN COALESCE(NULLIF(TRIM(avatar_updated_at), ''), NULLIF(TRIM(updated_at), ''), NULLIF(TRIM(created_at), ''))
2207
+ ELSE NULL
2208
+ END
2209
+ WHERE avatar_revision IS NULL
2210
+ OR avatar_revision = 0
2211
+ `);
1981
2212
  this.migrateSystemRoleBackfillIfNeeded();
1982
2213
  this.migrateUserBetaAccessBackfillIfNeeded();
1983
2214
  this.migrateDuplicateAiProfilesIfNeeded();
1984
2215
  this.migrateLegacyActorReferencesIfNeeded();
2216
+ this.migrateLegacyCommentActorReferencesIfNeeded();
1985
2217
  // Ensure sync watermark index exists on existing databases (added in v0.3.52+).
1986
2218
  this.db.exec(`
1987
2219
  CREATE INDEX IF NOT EXISTS idx_tasks_sync_watermark ON tasks(tenant_id, workspace_id, updated_at, id);
@@ -2128,7 +2360,7 @@ export class TaskforceCore {
2128
2360
  if (existing?.payload)
2129
2361
  return;
2130
2362
  const rows = this.db.prepare(`
2131
- SELECT id, workspace_id, name, username, created_at, updated_at, last_active_at
2363
+ SELECT id, workspace_id, name, username, seat_scope, created_at, updated_at, last_active_at
2132
2364
  FROM ai_profiles
2133
2365
  WHERE tenant_id = ?
2134
2366
  `).all(this.tenantId);
@@ -2137,7 +2369,8 @@ export class TaskforceCore {
2137
2369
  const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2138
2370
  const normalizedName = String(row.name || '').trim().toLowerCase();
2139
2371
  const normalizedUsername = String(row.username || '').trim().toLowerCase();
2140
- const dedupeKey = `${workspaceId}::${normalizedName || normalizedUsername}`;
2372
+ const seatScope = String(row.seat_scope || 'unknown').trim().toLowerCase() || 'unknown';
2373
+ const dedupeKey = `${workspaceId}::${seatScope}::${normalizedName || normalizedUsername}`;
2141
2374
  if (!normalizedName && !normalizedUsername)
2142
2375
  continue;
2143
2376
  const list = groups.get(dedupeKey) || [];
@@ -2185,15 +2418,19 @@ export class TaskforceCore {
2185
2418
  if (existing?.payload)
2186
2419
  return;
2187
2420
  const rows = this.db.prepare(`
2188
- SELECT id, workspace_id, name, username
2421
+ SELECT id, workspace_id, name, username, merged_into_profile_id
2189
2422
  FROM ai_profiles
2190
2423
  WHERE tenant_id = ?
2191
2424
  `).all(this.tenantId);
2425
+ const rowById = new Map(rows
2426
+ .map((row) => [String(row.id || '').trim(), row])
2427
+ .filter(([id]) => Boolean(id)));
2192
2428
  const aliasesByWorkspace = new Map();
2193
2429
  for (const row of rows) {
2194
2430
  const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2195
2431
  const profileId = String(row.id || '').trim();
2196
- if (!profileId)
2432
+ const canonicalProfileId = this.resolveCanonicalAiProfileId(rowById, profileId);
2433
+ if (!canonicalProfileId)
2197
2434
  continue;
2198
2435
  const aliases = [
2199
2436
  String(row.name || '').trim().toLowerCase(),
@@ -2208,8 +2445,8 @@ export class TaskforceCore {
2208
2445
  }
2209
2446
  for (const alias of aliases) {
2210
2447
  const ids = aliasMap.get(alias) || [];
2211
- if (!ids.includes(profileId))
2212
- ids.push(profileId);
2448
+ if (!ids.includes(canonicalProfileId))
2449
+ ids.push(canonicalProfileId);
2213
2450
  aliasMap.set(alias, ids);
2214
2451
  }
2215
2452
  }
@@ -2250,6 +2487,135 @@ export class TaskforceCore {
2250
2487
  updated_at = excluded.updated_at
2251
2488
  `).run(this.tenantId, migrationKey, JSON.stringify({ completedAt: now }), now);
2252
2489
  }
2490
+ migrateLegacyCommentActorReferencesIfNeeded() {
2491
+ const migrationKey = 'migration.actor_refs.canonicalize_comments.v2';
2492
+ const existing = this.db.prepare(`
2493
+ SELECT payload
2494
+ FROM system_settings
2495
+ WHERE tenant_id = ? AND settings_key = ?
2496
+ LIMIT 1
2497
+ `).get(this.tenantId, migrationKey);
2498
+ if (existing?.payload)
2499
+ return;
2500
+ const commentRows = this.db.prepare(`
2501
+ SELECT id, task_id, workspace_id, author, submitted_author
2502
+ FROM comments
2503
+ WHERE tenant_id = ?
2504
+ `).all(this.tenantId);
2505
+ const updateCommentAuthor = this.db.prepare(`
2506
+ UPDATE comments
2507
+ SET author = ?
2508
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2509
+ `);
2510
+ const updateTaskSyncWatermark = this.db.prepare(`
2511
+ UPDATE tasks
2512
+ SET updated_at = ?
2513
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2514
+ `);
2515
+ const selectTaskSyncWatermark = this.db.prepare(`
2516
+ SELECT updated_at, created_at
2517
+ FROM tasks
2518
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
2519
+ LIMIT 1
2520
+ `);
2521
+ let repairedCount = 0;
2522
+ let ambiguousCount = 0;
2523
+ let skippedCount = 0;
2524
+ const touchedTaskIdsByWorkspace = new Map();
2525
+ const now = new Date().toISOString();
2526
+ const nowMs = Date.parse(now);
2527
+ const tx = this.db.transaction(() => {
2528
+ for (const row of commentRows) {
2529
+ const workspaceId = this.normalizeWorkspaceId(row.workspace_id);
2530
+ const commentId = String(row.id || '').trim();
2531
+ const taskId = String(row.task_id || '').trim();
2532
+ const rawAuthor = String(row.author || '').trim();
2533
+ const rawSubmittedAuthor = String(row.submitted_author || '').trim();
2534
+ const normalizedAuthor = rawAuthor.toLowerCase();
2535
+ if (!commentId) {
2536
+ skippedCount += 1;
2537
+ continue;
2538
+ }
2539
+ if (this.isStableActorReference(rawAuthor)
2540
+ || this.isHumanActorReference(rawAuthor)
2541
+ || normalizedAuthor === 'system') {
2542
+ skippedCount += 1;
2543
+ continue;
2544
+ }
2545
+ const candidateIds = new Set();
2546
+ let hasAmbiguousEvidence = false;
2547
+ const evidenceValues = [rawAuthor, rawSubmittedAuthor];
2548
+ for (const value of evidenceValues) {
2549
+ const rawValue = String(value || '').trim();
2550
+ const normalizedValue = rawValue.toLowerCase();
2551
+ if (!rawValue)
2552
+ continue;
2553
+ if (this.isHumanActorReference(rawValue)
2554
+ || this.isGenericAiActorReference(rawValue)
2555
+ || normalizedValue === 'system'
2556
+ || normalizedValue.startsWith('user-')) {
2557
+ continue;
2558
+ }
2559
+ if (normalizedValue.startsWith('ai-profile-')) {
2560
+ const actor = this.resolveWorkspaceActor(workspaceId, rawValue);
2561
+ if (actor?.kind === 'ai' && actor.meta?.unresolved !== true) {
2562
+ candidateIds.add(actor.id);
2563
+ }
2564
+ continue;
2565
+ }
2566
+ const matches = this.findCanonicalAiProfileIdsByAlias(workspaceId, rawValue);
2567
+ if (matches.length === 1) {
2568
+ candidateIds.add(matches[0]);
2569
+ }
2570
+ else if (matches.length > 1) {
2571
+ hasAmbiguousEvidence = true;
2572
+ }
2573
+ }
2574
+ if (!hasAmbiguousEvidence && candidateIds.size === 1) {
2575
+ updateCommentAuthor.run([...candidateIds][0], this.tenantId, workspaceId, commentId);
2576
+ if (taskId) {
2577
+ let touchedTaskIds = touchedTaskIdsByWorkspace.get(workspaceId);
2578
+ if (!touchedTaskIds) {
2579
+ touchedTaskIds = new Set();
2580
+ touchedTaskIdsByWorkspace.set(workspaceId, touchedTaskIds);
2581
+ }
2582
+ touchedTaskIds.add(taskId);
2583
+ }
2584
+ repairedCount += 1;
2585
+ continue;
2586
+ }
2587
+ if (hasAmbiguousEvidence || candidateIds.size > 1) {
2588
+ ambiguousCount += 1;
2589
+ continue;
2590
+ }
2591
+ skippedCount += 1;
2592
+ }
2593
+ for (const [workspaceId, touchedTaskIds] of touchedTaskIdsByWorkspace.entries()) {
2594
+ for (const taskId of touchedTaskIds) {
2595
+ const taskRow = selectTaskSyncWatermark.get(this.tenantId, workspaceId, taskId);
2596
+ const existingWatermark = String(taskRow?.updated_at || taskRow?.created_at || '').trim();
2597
+ const existingWatermarkMs = Date.parse(existingWatermark);
2598
+ const nextWatermarkMs = Number.isFinite(existingWatermarkMs)
2599
+ ? Math.max(existingWatermarkMs, nowMs) + 1
2600
+ : nowMs;
2601
+ updateTaskSyncWatermark.run(new Date(nextWatermarkMs).toISOString(), this.tenantId, workspaceId, taskId);
2602
+ }
2603
+ }
2604
+ });
2605
+ tx();
2606
+ this.db.prepare(`
2607
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
2608
+ VALUES (?, ?, ?, ?)
2609
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
2610
+ payload = excluded.payload,
2611
+ updated_at = excluded.updated_at
2612
+ `).run(this.tenantId, migrationKey, JSON.stringify({
2613
+ completedAt: now,
2614
+ repairedCount,
2615
+ ambiguousCount,
2616
+ skippedCount,
2617
+ }), now);
2618
+ }
2253
2619
  getWorkspaceOwnerUserId(workspaceId) {
2254
2620
  const normalizedWorkspaceId = String(workspaceId || '').trim();
2255
2621
  if (!normalizedWorkspaceId)
@@ -2925,6 +3291,9 @@ export class TaskforceCore {
2925
3291
  syncCanonicalTaskAttachmentLinks(taskId, workspaceId, attachments) {
2926
3292
  syncCanonicalTaskAttachmentLinksService(this.getAttachmentLinkServiceDeps(), taskId, workspaceId, attachments);
2927
3293
  }
3294
+ syncCanonicalPlanningAttachmentLinks(targetType, targetId, workspaceId, attachments) {
3295
+ syncCanonicalAttachmentLinksForTargetService(this.getAttachmentLinkServiceDeps(), targetType, targetId, workspaceId, attachments);
3296
+ }
2928
3297
  rowToTask(row) {
2929
3298
  const workspaceId = this.normalizeWorkspaceId(row?.workspace_id);
2930
3299
  const commentsRaw = this.db.prepare('SELECT * FROM comments WHERE tenant_id = ? AND workspace_id = ? AND task_id = ? ORDER BY timestamp ASC').all(this.tenantId, workspaceId, row.id);
@@ -2983,12 +3352,21 @@ export class TaskforceCore {
2983
3352
  checklistItems
2984
3353
  };
2985
3354
  }
2986
- rowToInitiative(row) {
3355
+ rowToInitiative(row, options) {
3356
+ const id = String(row.id || '').trim();
3357
+ const workspaceId = this.normalizeWorkspaceId(typeof row.workspace_id === 'string' ? row.workspace_id : undefined);
2987
3358
  const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
2988
3359
  const comments = this.normalizeIncomingComments(this.parseJsonField(row.comments_json, []));
3360
+ const includeActivity = options?.includeActivity === true;
3361
+ const entityEvents = includeActivity
3362
+ ? this.listEntityEventsInternal('initiative', id, workspaceId)
3363
+ : undefined;
3364
+ const activity = includeActivity
3365
+ ? this.buildEntityActivity(comments, entityEvents || [])
3366
+ : undefined;
2989
3367
  const attachments = this.normalizePlanningAttachments(this.parseJsonField(row.attachments_json, []));
2990
3368
  return {
2991
- id: String(row.id || '').trim(),
3369
+ id,
2992
3370
  referenceNumber,
2993
3371
  referenceLabel: formatInitiativeReference(referenceNumber),
2994
3372
  title: String(row.title || '').trim() || 'Untitled Initiative',
@@ -3000,14 +3378,27 @@ export class TaskforceCore {
3000
3378
  isArchived: Boolean(row.is_archived),
3001
3379
  comments,
3002
3380
  attachments,
3381
+ ...(includeActivity ? {
3382
+ activity,
3383
+ entityEvents,
3384
+ } : {}),
3003
3385
  };
3004
3386
  }
3005
- rowToWorkstream(row) {
3387
+ rowToWorkstream(row, options) {
3388
+ const id = String(row.id || '').trim();
3389
+ const workspaceId = this.normalizeWorkspaceId(typeof row.workspace_id === 'string' ? row.workspace_id : undefined);
3006
3390
  const referenceNumber = this.sanitizeTaskReferenceNumber(row.reference_number);
3007
3391
  const comments = this.normalizeIncomingComments(this.parseJsonField(row.comments_json, []));
3392
+ const includeActivity = options?.includeActivity === true;
3393
+ const entityEvents = includeActivity
3394
+ ? this.listEntityEventsInternal('workstream', id, workspaceId)
3395
+ : undefined;
3396
+ const activity = includeActivity
3397
+ ? this.buildEntityActivity(comments, entityEvents || [])
3398
+ : undefined;
3008
3399
  const attachments = this.normalizePlanningAttachments(this.parseJsonField(row.attachments_json, []));
3009
3400
  return {
3010
- id: String(row.id || '').trim(),
3401
+ id,
3011
3402
  referenceNumber,
3012
3403
  referenceLabel: formatWorkstreamReference(referenceNumber),
3013
3404
  initiativeId: typeof row.initiative_id === 'string' && row.initiative_id.trim().length > 0 ? row.initiative_id.trim() : null,
@@ -3020,6 +3411,10 @@ export class TaskforceCore {
3020
3411
  isArchived: Boolean(row.is_archived),
3021
3412
  comments,
3022
3413
  attachments,
3414
+ ...(includeActivity ? {
3415
+ activity,
3416
+ entityEvents,
3417
+ } : {}),
3023
3418
  };
3024
3419
  }
3025
3420
  rowToChecklistItem(row) {
@@ -3072,7 +3467,7 @@ export class TaskforceCore {
3072
3467
  `).get(this.tenantId, normalizedWorkspaceId, normalizedId);
3073
3468
  if (!row)
3074
3469
  return null;
3075
- return this.rowToInitiative(row);
3470
+ return this.rowToInitiative(row, { includeActivity: true });
3076
3471
  }
3077
3472
  listInitiatives(workspaceId) {
3078
3473
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -3084,7 +3479,45 @@ export class TaskforceCore {
3084
3479
  `).all(this.tenantId, normalizedWorkspaceId);
3085
3480
  return rows.map((row) => this.rowToInitiative(row));
3086
3481
  }
3087
- createInitiative(input, workspaceId) {
3482
+ isTransientPlanningHydrationError(error) {
3483
+ const message = error instanceof Error ? String(error.message || '').trim() : String(error || '').trim();
3484
+ if (!message)
3485
+ return false;
3486
+ return /(timed out|timeout|ETIMEDOUT|query canceled|statement timeout|database timeout|postgres worker)/i.test(message);
3487
+ }
3488
+ getInitiativeForWriteResult(id, workspaceId, fallback, context) {
3489
+ try {
3490
+ return this.getInitiative(id, workspaceId) || fallback;
3491
+ }
3492
+ catch (error) {
3493
+ if (!this.isTransientPlanningHydrationError(error))
3494
+ throw error;
3495
+ console.warn('[Taskforce] Falling back to post-write initiative snapshot after hydration failed', {
3496
+ context,
3497
+ initiativeId: id,
3498
+ workspaceId,
3499
+ message: error instanceof Error ? error.message : String(error),
3500
+ });
3501
+ return fallback;
3502
+ }
3503
+ }
3504
+ getWorkstreamForWriteResult(id, workspaceId, fallback, context) {
3505
+ try {
3506
+ return this.getWorkstream(id, workspaceId) || fallback;
3507
+ }
3508
+ catch (error) {
3509
+ if (!this.isTransientPlanningHydrationError(error))
3510
+ throw error;
3511
+ console.warn('[Taskforce] Falling back to post-write workstream snapshot after hydration failed', {
3512
+ context,
3513
+ workstreamId: id,
3514
+ workspaceId,
3515
+ message: error instanceof Error ? error.message : String(error),
3516
+ });
3517
+ return fallback;
3518
+ }
3519
+ }
3520
+ createInitiative(input, workspaceId, options) {
3088
3521
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3089
3522
  const id = String(input.id || '').trim() || this.generateEntityId('initiative');
3090
3523
  const referenceNumber = this.reserveInitiativeReferenceNumber(normalizedWorkspaceId, input.referenceNumber);
@@ -3123,18 +3556,37 @@ export class TaskforceCore {
3123
3556
  sortOrder: initiative.order,
3124
3557
  isArchived: initiative.isArchived ? 1 : 0,
3125
3558
  });
3126
- return initiative;
3559
+ this.syncCanonicalPlanningAttachmentLinks('initiative', initiative.id, normalizedWorkspaceId, initiative.attachments || []);
3560
+ if (options?.skipEntityEvents !== true) {
3561
+ this.addEntityEvent({
3562
+ entityType: 'initiative',
3563
+ entityId: initiative.id,
3564
+ workspaceId: normalizedWorkspaceId,
3565
+ action: 'initiative-created',
3566
+ actor: options?.actorRef || 'system',
3567
+ createdAt: initiative.updatedAt || initiative.createdAt,
3568
+ details: {
3569
+ title: initiative.title,
3570
+ ownerId: initiative.ownerId || null,
3571
+ }
3572
+ });
3573
+ }
3574
+ return this.getInitiativeForWriteResult(initiative.id, normalizedWorkspaceId, initiative, 'createInitiative:post-write');
3127
3575
  }
3128
- updateInitiative(id, patch, workspaceId) {
3576
+ updateInitiative(id, patch, workspaceId, options) {
3129
3577
  const existing = this.getInitiative(id, workspaceId);
3130
3578
  if (!existing) {
3131
3579
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3132
3580
  }
3133
3581
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3582
+ const nextAttachments = Object.prototype.hasOwnProperty.call(patch, 'attachments') && Array.isArray(patch.attachments)
3583
+ ? patch.attachments
3584
+ : existing.attachments;
3134
3585
  const next = {
3135
3586
  ...existing,
3136
3587
  ...patch,
3137
3588
  id: existing.id,
3589
+ attachments: nextAttachments,
3138
3590
  updatedAt: String(patch.updatedAt || '').trim() || new Date().toISOString(),
3139
3591
  referenceLabel: formatInitiativeReference(patch.referenceNumber ?? existing.referenceNumber),
3140
3592
  };
@@ -3164,25 +3616,68 @@ export class TaskforceCore {
3164
3616
  sortOrder: next.order,
3165
3617
  isArchived: next.isArchived ? 1 : 0,
3166
3618
  });
3167
- return this.getInitiative(existing.id, normalizedWorkspaceId);
3619
+ this.syncCanonicalPlanningAttachmentLinks('initiative', existing.id, normalizedWorkspaceId, next.attachments || []);
3620
+ const hydrated = this.getInitiativeForWriteResult(existing.id, normalizedWorkspaceId, next, 'updateInitiative:post-write');
3621
+ if (options?.skipEntityEvents !== true) {
3622
+ const changes = this.buildPlanningEntityEventChanges(existing, next, ['title', 'description', 'ownerId', 'attachments']);
3623
+ if (Object.keys(changes).length > 0) {
3624
+ this.addEntityEvent({
3625
+ entityType: 'initiative',
3626
+ entityId: existing.id,
3627
+ workspaceId: normalizedWorkspaceId,
3628
+ action: this.getPlanningEntityEventAction('initiative', changes),
3629
+ actor: options?.actorRef || 'system',
3630
+ createdAt: next.updatedAt,
3631
+ details: { changes }
3632
+ });
3633
+ return this.getInitiativeForWriteResult(existing.id, normalizedWorkspaceId, hydrated, 'updateInitiative:post-event');
3634
+ }
3635
+ }
3636
+ return hydrated;
3168
3637
  }
3169
- archiveInitiative(id, workspaceId) {
3638
+ archiveInitiative(id, workspaceId, options) {
3170
3639
  const existing = this.getInitiative(id, workspaceId);
3171
3640
  if (!existing) {
3172
3641
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3173
3642
  }
3174
3643
  if (existing.isArchived)
3175
3644
  return existing;
3176
- return this.updateInitiative(id, { isArchived: true }, workspaceId);
3645
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3646
+ const archived = this.updateInitiative(id, { isArchived: true }, workspaceId, { ...options, skipEntityEvents: true });
3647
+ if (options?.skipEntityEvents !== true) {
3648
+ this.addEntityEvent({
3649
+ entityType: 'initiative',
3650
+ entityId: id,
3651
+ workspaceId: normalizedWorkspaceId,
3652
+ action: 'initiative-archived',
3653
+ actor: options?.actorRef || 'system',
3654
+ createdAt: archived.updatedAt || new Date().toISOString(),
3655
+ });
3656
+ return this.getInitiativeForWriteResult(id, normalizedWorkspaceId, archived, 'archiveInitiative:post-event');
3657
+ }
3658
+ return archived;
3177
3659
  }
3178
- unarchiveInitiative(id, workspaceId) {
3660
+ unarchiveInitiative(id, workspaceId, options) {
3179
3661
  const existing = this.getInitiative(id, workspaceId);
3180
3662
  if (!existing) {
3181
3663
  throw new TaskforceRuleError(`Initiative not found: ${id}`, 404, 'INITIATIVE_NOT_FOUND');
3182
3664
  }
3183
3665
  if (!existing.isArchived)
3184
3666
  return existing;
3185
- return this.updateInitiative(id, { isArchived: false }, workspaceId);
3667
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3668
+ const restored = this.updateInitiative(id, { isArchived: false }, workspaceId, { ...options, skipEntityEvents: true });
3669
+ if (options?.skipEntityEvents !== true) {
3670
+ this.addEntityEvent({
3671
+ entityType: 'initiative',
3672
+ entityId: id,
3673
+ workspaceId: normalizedWorkspaceId,
3674
+ action: 'initiative-unarchived',
3675
+ actor: options?.actorRef || 'system',
3676
+ createdAt: restored.updatedAt || new Date().toISOString(),
3677
+ });
3678
+ return this.getInitiativeForWriteResult(id, normalizedWorkspaceId, restored, 'unarchiveInitiative:post-event');
3679
+ }
3680
+ return restored;
3186
3681
  }
3187
3682
  getWorkstream(id, workspaceId) {
3188
3683
  const normalizedId = String(id || '').trim();
@@ -3197,7 +3692,7 @@ export class TaskforceCore {
3197
3692
  `).get(this.tenantId, normalizedWorkspaceId, normalizedId);
3198
3693
  if (!row)
3199
3694
  return null;
3200
- return this.rowToWorkstream(row);
3695
+ return this.rowToWorkstream(row, { includeActivity: true });
3201
3696
  }
3202
3697
  listWorkstreams(workspaceId) {
3203
3698
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -3266,7 +3761,7 @@ export class TaskforceCore {
3266
3761
  workstreamTaskSummaries: Array.from(workstreamTaskSummaries.values()),
3267
3762
  };
3268
3763
  }
3269
- createWorkstream(input, workspaceId) {
3764
+ createWorkstream(input, workspaceId, options) {
3270
3765
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3271
3766
  const id = String(input.id || '').trim() || this.generateEntityId('workstream');
3272
3767
  const referenceNumber = this.reserveWorkstreamReferenceNumber(normalizedWorkspaceId, input.referenceNumber);
@@ -3309,14 +3804,33 @@ export class TaskforceCore {
3309
3804
  sortOrder: workstream.order,
3310
3805
  isArchived: workstream.isArchived ? 1 : 0,
3311
3806
  });
3312
- return workstream;
3807
+ this.syncCanonicalPlanningAttachmentLinks('workstream', workstream.id, normalizedWorkspaceId, workstream.attachments || []);
3808
+ if (options?.skipEntityEvents !== true) {
3809
+ this.addEntityEvent({
3810
+ entityType: 'workstream',
3811
+ entityId: workstream.id,
3812
+ workspaceId: normalizedWorkspaceId,
3813
+ action: 'workstream-created',
3814
+ actor: options?.actorRef || 'system',
3815
+ createdAt: workstream.updatedAt || workstream.createdAt,
3816
+ details: {
3817
+ title: workstream.title,
3818
+ ownerId: workstream.ownerId || null,
3819
+ initiativeId: workstream.initiativeId || null,
3820
+ }
3821
+ });
3822
+ }
3823
+ return this.getWorkstreamForWriteResult(workstream.id, normalizedWorkspaceId, workstream, 'createWorkstream:post-write');
3313
3824
  }
3314
- updateWorkstream(id, patch, workspaceId) {
3825
+ updateWorkstream(id, patch, workspaceId, options) {
3315
3826
  const existing = this.getWorkstream(id, workspaceId);
3316
3827
  if (!existing) {
3317
3828
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3318
3829
  }
3319
3830
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3831
+ const nextAttachments = Object.prototype.hasOwnProperty.call(patch, 'attachments') && Array.isArray(patch.attachments)
3832
+ ? patch.attachments
3833
+ : existing.attachments;
3320
3834
  const next = {
3321
3835
  ...existing,
3322
3836
  ...patch,
@@ -3324,6 +3838,7 @@ export class TaskforceCore {
3324
3838
  initiativeId: Object.prototype.hasOwnProperty.call(patch, 'initiativeId')
3325
3839
  ? this.sanitizeInitiativeId(patch.initiativeId)
3326
3840
  : existing.initiativeId,
3841
+ attachments: nextAttachments,
3327
3842
  updatedAt: String(patch.updatedAt || '').trim() || new Date().toISOString(),
3328
3843
  referenceLabel: formatWorkstreamReference(patch.referenceNumber ?? existing.referenceNumber),
3329
3844
  };
@@ -3356,41 +3871,94 @@ export class TaskforceCore {
3356
3871
  sortOrder: next.order,
3357
3872
  isArchived: next.isArchived ? 1 : 0,
3358
3873
  });
3359
- return this.getWorkstream(existing.id, normalizedWorkspaceId);
3874
+ this.syncCanonicalPlanningAttachmentLinks('workstream', existing.id, normalizedWorkspaceId, next.attachments || []);
3875
+ const hydrated = this.getWorkstreamForWriteResult(existing.id, normalizedWorkspaceId, next, 'updateWorkstream:post-write');
3876
+ if (options?.skipEntityEvents !== true) {
3877
+ const changes = this.buildPlanningEntityEventChanges(existing, next, ['title', 'description', 'ownerId', 'initiativeId', 'attachments']);
3878
+ if (Object.keys(changes).length > 0) {
3879
+ this.addEntityEvent({
3880
+ entityType: 'workstream',
3881
+ entityId: existing.id,
3882
+ workspaceId: normalizedWorkspaceId,
3883
+ action: this.getPlanningEntityEventAction('workstream', changes),
3884
+ actor: options?.actorRef || 'system',
3885
+ createdAt: next.updatedAt,
3886
+ details: { changes }
3887
+ });
3888
+ return this.getWorkstreamForWriteResult(existing.id, normalizedWorkspaceId, hydrated, 'updateWorkstream:post-event');
3889
+ }
3890
+ }
3891
+ return hydrated;
3360
3892
  }
3361
- archiveWorkstream(id, workspaceId) {
3893
+ archiveWorkstream(id, workspaceId, options) {
3362
3894
  const existing = this.getWorkstream(id, workspaceId);
3363
3895
  if (!existing) {
3364
3896
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3365
3897
  }
3366
3898
  if (existing.isArchived)
3367
3899
  return existing;
3368
- return this.updateWorkstream(id, { isArchived: true }, workspaceId);
3900
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3901
+ const archived = this.updateWorkstream(id, { isArchived: true }, workspaceId, { ...options, skipEntityEvents: true });
3902
+ if (options?.skipEntityEvents !== true) {
3903
+ this.addEntityEvent({
3904
+ entityType: 'workstream',
3905
+ entityId: id,
3906
+ workspaceId: normalizedWorkspaceId,
3907
+ action: 'workstream-archived',
3908
+ actor: options?.actorRef || 'system',
3909
+ createdAt: archived.updatedAt || new Date().toISOString(),
3910
+ });
3911
+ return this.getWorkstreamForWriteResult(id, normalizedWorkspaceId, archived, 'archiveWorkstream:post-event');
3912
+ }
3913
+ return archived;
3369
3914
  }
3370
- unarchiveWorkstream(id, workspaceId) {
3915
+ unarchiveWorkstream(id, workspaceId, options) {
3371
3916
  const existing = this.getWorkstream(id, workspaceId);
3372
3917
  if (!existing) {
3373
3918
  throw new TaskforceRuleError(`Workstream not found: ${id}`, 404, 'WORKSTREAM_NOT_FOUND');
3374
3919
  }
3375
3920
  if (!existing.isArchived)
3376
3921
  return existing;
3377
- return this.updateWorkstream(id, { isArchived: false }, workspaceId);
3922
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3923
+ const restored = this.updateWorkstream(id, { isArchived: false }, workspaceId, { ...options, skipEntityEvents: true });
3924
+ if (options?.skipEntityEvents !== true) {
3925
+ this.addEntityEvent({
3926
+ entityType: 'workstream',
3927
+ entityId: id,
3928
+ workspaceId: normalizedWorkspaceId,
3929
+ action: 'workstream-unarchived',
3930
+ actor: options?.actorRef || 'system',
3931
+ createdAt: restored.updatedAt || new Date().toISOString(),
3932
+ });
3933
+ return this.getWorkstreamForWriteResult(id, normalizedWorkspaceId, restored, 'unarchiveWorkstream:post-event');
3934
+ }
3935
+ return restored;
3378
3936
  }
3379
3937
  upsertInitiativeForSync(initiative, workspaceId) {
3380
3938
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3381
3939
  const existing = this.getInitiative(initiative.id, normalizedWorkspaceId);
3382
3940
  if (existing) {
3383
- return this.updateInitiative(existing.id, initiative, normalizedWorkspaceId);
3941
+ const existingUpdatedAtMs = Date.parse(String(existing.updatedAt || existing.createdAt || '').trim());
3942
+ const incomingUpdatedAtMs = Date.parse(String(initiative.updatedAt || initiative.createdAt || '').trim());
3943
+ if (Number.isFinite(existingUpdatedAtMs) && Number.isFinite(incomingUpdatedAtMs) && incomingUpdatedAtMs < existingUpdatedAtMs) {
3944
+ return existing;
3945
+ }
3946
+ return this.updateInitiative(existing.id, initiative, normalizedWorkspaceId, { skipEntityEvents: true });
3384
3947
  }
3385
- return this.createInitiative(initiative, normalizedWorkspaceId);
3948
+ return this.createInitiative(initiative, normalizedWorkspaceId, { skipEntityEvents: true });
3386
3949
  }
3387
3950
  upsertWorkstreamForSync(workstream, workspaceId) {
3388
3951
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
3389
3952
  const existing = this.getWorkstream(workstream.id, normalizedWorkspaceId);
3390
3953
  if (existing) {
3391
- return this.updateWorkstream(existing.id, workstream, normalizedWorkspaceId);
3954
+ const existingUpdatedAtMs = Date.parse(String(existing.updatedAt || existing.createdAt || '').trim());
3955
+ const incomingUpdatedAtMs = Date.parse(String(workstream.updatedAt || workstream.createdAt || '').trim());
3956
+ if (Number.isFinite(existingUpdatedAtMs) && Number.isFinite(incomingUpdatedAtMs) && incomingUpdatedAtMs < existingUpdatedAtMs) {
3957
+ return existing;
3958
+ }
3959
+ return this.updateWorkstream(existing.id, workstream, normalizedWorkspaceId, { skipEntityEvents: true });
3392
3960
  }
3393
- return this.createWorkstream(workstream, normalizedWorkspaceId);
3961
+ return this.createWorkstream(workstream, normalizedWorkspaceId, { skipEntityEvents: true });
3394
3962
  }
3395
3963
  listChecklistItems(taskId, workspaceId) {
3396
3964
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
@@ -4542,6 +5110,91 @@ export class TaskforceCore {
4542
5110
  `).run(now, now, this.tenantId, normalizedUserId, cutoff);
4543
5111
  return staleDrafts;
4544
5112
  }
5113
+ createTaskAttachmentUploadDraft(input) {
5114
+ const draftId = String(input.draftId || '').trim();
5115
+ const workspaceId = String(input.workspaceId || '').trim();
5116
+ const taskId = String(input.taskId || '').trim();
5117
+ const actorRef = String(input.actorRef || '').trim() || 'system';
5118
+ const storageKey = String(input.storageKey || '').trim();
5119
+ const mimeType = String(input.mimeType || 'application/octet-stream').trim() || 'application/octet-stream';
5120
+ const originalFilename = String(input.originalFilename || '').trim() || 'attachment';
5121
+ const sizeBytes = Number.isFinite(Number(input.sizeBytes)) ? Math.max(0, Math.floor(Number(input.sizeBytes))) : 0;
5122
+ const storageProvider = input.storageProvider === 'r2' || input.storageProvider === 'external' ? input.storageProvider : 'local';
5123
+ if (!draftId)
5124
+ throw new TaskforceRuleError('draftId is required', 400, 'TASK_ATTACHMENT_DRAFT_ID_REQUIRED');
5125
+ if (!workspaceId)
5126
+ throw new TaskforceRuleError('workspaceId is required', 400, 'WORKSPACE_ID_REQUIRED');
5127
+ if (!taskId)
5128
+ throw new TaskforceRuleError('taskId is required', 400, 'TASK_ID_REQUIRED');
5129
+ if (!storageKey)
5130
+ throw new TaskforceRuleError('storageKey is required', 400, 'TASK_ATTACHMENT_STORAGE_KEY_REQUIRED');
5131
+ const now = new Date().toISOString();
5132
+ this.db.prepare(`
5133
+ INSERT INTO task_attachment_upload_drafts (
5134
+ draft_id, tenant_id, workspace_id, task_id, actor_ref, storage_key, mime_type, original_filename,
5135
+ size_bytes, storage_provider, consumed_at, discarded_at, created_at, updated_at
5136
+ )
5137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
5138
+ `).run(draftId, this.tenantId, workspaceId, taskId, actorRef, storageKey, mimeType, originalFilename, sizeBytes, storageProvider, now, now);
5139
+ return this.getTaskAttachmentUploadDraft(draftId);
5140
+ }
5141
+ getTaskAttachmentUploadDraft(draftId) {
5142
+ const normalizedDraftId = String(draftId || '').trim();
5143
+ if (!normalizedDraftId)
5144
+ return null;
5145
+ const row = this.db.prepare(`
5146
+ SELECT draft_id, workspace_id, task_id, actor_ref, storage_key, mime_type, original_filename,
5147
+ size_bytes, storage_provider, consumed_at, discarded_at, created_at, updated_at
5148
+ FROM task_attachment_upload_drafts
5149
+ WHERE tenant_id = ? AND draft_id = ?
5150
+ LIMIT 1
5151
+ `).get(this.tenantId, normalizedDraftId);
5152
+ if (!row?.draft_id)
5153
+ return null;
5154
+ return {
5155
+ draftId: String(row.draft_id),
5156
+ workspaceId: String(row.workspace_id || ''),
5157
+ taskId: String(row.task_id || ''),
5158
+ actorRef: String(row.actor_ref || 'system'),
5159
+ storageKey: String(row.storage_key || ''),
5160
+ mimeType: String(row.mime_type || 'application/octet-stream'),
5161
+ originalFilename: String(row.original_filename || 'attachment'),
5162
+ sizeBytes: Number(row.size_bytes || 0),
5163
+ storageProvider: String(row.storage_provider || 'local') === 'r2'
5164
+ ? 'r2'
5165
+ : String(row.storage_provider || 'local') === 'external'
5166
+ ? 'external'
5167
+ : 'local',
5168
+ consumedAt: row.consumed_at ? String(row.consumed_at) : null,
5169
+ discardedAt: row.discarded_at ? String(row.discarded_at) : null,
5170
+ createdAt: String(row.created_at || ''),
5171
+ updatedAt: String(row.updated_at || ''),
5172
+ };
5173
+ }
5174
+ markTaskAttachmentUploadDraftConsumed(draftId) {
5175
+ const existing = this.getTaskAttachmentUploadDraft(draftId);
5176
+ if (!existing || existing.discardedAt)
5177
+ return null;
5178
+ const now = new Date().toISOString();
5179
+ this.db.prepare(`
5180
+ UPDATE task_attachment_upload_drafts
5181
+ SET consumed_at = COALESCE(consumed_at, ?), updated_at = ?
5182
+ WHERE tenant_id = ? AND draft_id = ? AND discarded_at IS NULL
5183
+ `).run(now, now, this.tenantId, existing.draftId);
5184
+ return this.getTaskAttachmentUploadDraft(existing.draftId);
5185
+ }
5186
+ discardTaskAttachmentUploadDraft(draftId) {
5187
+ const existing = this.getTaskAttachmentUploadDraft(draftId);
5188
+ if (!existing || existing.discardedAt)
5189
+ return existing;
5190
+ const now = new Date().toISOString();
5191
+ this.db.prepare(`
5192
+ UPDATE task_attachment_upload_drafts
5193
+ SET discarded_at = ?, updated_at = ?
5194
+ WHERE tenant_id = ? AND draft_id = ? AND discarded_at IS NULL
5195
+ `).run(now, now, this.tenantId, existing.draftId);
5196
+ return this.getTaskAttachmentUploadDraft(existing.draftId);
5197
+ }
4545
5198
  getSystemRole(userId) {
4546
5199
  const normalizedUserId = String(userId || '').trim();
4547
5200
  if (!normalizedUserId || normalizedUserId === 'anonymous')
@@ -5106,6 +5759,9 @@ export class TaskforceCore {
5106
5759
  updateAiProfileAvatar(input) {
5107
5760
  return updateAiProfileAvatarService(this.getAiProfileServiceDeps(), input);
5108
5761
  }
5762
+ updateAiProfileSurfaceType(input) {
5763
+ return updateAiProfileSurfaceTypeService(this.getAiProfileServiceDeps(), input);
5764
+ }
5109
5765
  resolveAiProfile(input, options) {
5110
5766
  return resolveAiProfileService(this.getAiProfileServiceDeps(), input, options);
5111
5767
  }
@@ -5246,6 +5902,9 @@ export class TaskforceCore {
5246
5902
  upsertTaskEventForSync(event) {
5247
5903
  return upsertTaskEventForSyncService(this.getTaskActivityServiceDeps(), event);
5248
5904
  }
5905
+ upsertEntityEventForSync(event) {
5906
+ return upsertEntityEventForSyncService(this.getTaskActivityServiceDeps(), event);
5907
+ }
5249
5908
  deleteDocumentReviewSessionForSync(input) {
5250
5909
  const deps = this.getDocumentReviewServiceDeps();
5251
5910
  return deps.sessionStore.softDelete(input.sessionId, deps.normalizeWorkspaceId(input.workspaceId), input.deletedAt || undefined);
@@ -5313,38 +5972,9 @@ export class TaskforceCore {
5313
5972
  if (aiProfile) {
5314
5973
  return this.aiProfileToActor(this.aiProfileRowToRecord(aiProfile));
5315
5974
  }
5316
- // Name-based fallback: agents may use their display name instead of profile ID
5317
- const aiProfileByName = this.db.prepare(`
5318
- SELECT *
5319
- FROM ai_profiles
5320
- WHERE tenant_id = ?
5321
- AND workspace_id = ?
5322
- AND archived_at IS NULL
5323
- AND (
5324
- LOWER(name) = LOWER(?)
5325
- OR LOWER(username) = LOWER(?)
5326
- )
5327
- ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
5328
- LIMIT 1
5329
- `).get(this.tenantId, normalizedWorkspaceId, raw, raw);
5330
- if (aiProfileByName) {
5331
- return this.aiProfileToActor(this.aiProfileRowToRecord(aiProfileByName));
5332
- }
5333
- const archivedAiProfileByName = this.db.prepare(`
5334
- SELECT *
5335
- FROM ai_profiles
5336
- WHERE tenant_id = ?
5337
- AND workspace_id = ?
5338
- AND archived_at IS NOT NULL
5339
- AND (
5340
- LOWER(name) = LOWER(?)
5341
- OR LOWER(username) = LOWER(?)
5342
- )
5343
- ORDER BY COALESCE(last_active_at, updated_at, created_at) DESC, id ASC
5344
- LIMIT 1
5345
- `).get(this.tenantId, normalizedWorkspaceId, raw, raw);
5346
- if (archivedAiProfileByName) {
5347
- return this.aiProfileToActor(this.aiProfileRowToRecord(archivedAiProfileByName));
5975
+ const uniqueAiProfileByAlias = this.resolveUniqueAiProfileActorByAlias(normalizedWorkspaceId, raw);
5976
+ if (uniqueAiProfileByAlias) {
5977
+ return uniqueAiProfileByAlias;
5348
5978
  }
5349
5979
  const workspaceUser = this.listWorkspaceUsers(normalizedWorkspaceId).find((user) => user.userId === raw);
5350
5980
  if (workspaceUser) {
@@ -5589,6 +6219,12 @@ export class TaskforceCore {
5589
6219
  listPlanFeatureCatalog() {
5590
6220
  return this.planEntitlementService.listPlanFeatureCatalog();
5591
6221
  }
6222
+ createCustomPlanFeatureCatalogEntry(input) {
6223
+ return this.planEntitlementService.createCustomPlanFeatureCatalogEntry(input);
6224
+ }
6225
+ deleteCustomPlanFeatureCatalogEntry(featureKey) {
6226
+ return this.planEntitlementService.deleteCustomPlanFeatureCatalogEntry(featureKey);
6227
+ }
5592
6228
  updatePlanFeatureCatalogMarketingCopy(input) {
5593
6229
  return this.planEntitlementService.updatePlanFeatureCatalogMarketingCopy(input);
5594
6230
  }
@@ -6157,6 +6793,9 @@ export class TaskforceCore {
6157
6793
  revokeMcpAccessToken(input) {
6158
6794
  return this.mcpTokenService.revokeMcpAccessToken(input);
6159
6795
  }
6796
+ deleteMcpAccessToken(input) {
6797
+ return this.mcpTokenService.deleteMcpAccessToken(input);
6798
+ }
6160
6799
  regenerateMcpAccessToken(input) {
6161
6800
  return this.mcpTokenService.regenerateMcpAccessToken(input);
6162
6801
  }
@@ -6190,6 +6829,9 @@ export class TaskforceCore {
6190
6829
  authenticateMcpOAuthAccessToken(token, workspaceId) {
6191
6830
  return this.mcpTokenService.authenticateMcpOAuthAccessToken(token, workspaceId);
6192
6831
  }
6832
+ bindMcpOAuthConnectorGrantAiProfile(input) {
6833
+ return this.mcpTokenService.bindMcpOAuthConnectorGrantAiProfile(input);
6834
+ }
6193
6835
  recordMcpOAuthConnectorGrantUsage(input) {
6194
6836
  return this.mcpTokenService.recordMcpOAuthConnectorGrantUsage(input);
6195
6837
  }
@@ -6600,9 +7242,47 @@ export class TaskforceCore {
6600
7242
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
6601
7243
  return this.listTaskEventsInternal(taskId, normalizedWorkspaceId);
6602
7244
  }
7245
+ taskExistsForSync(id, workspaceId) {
7246
+ return this.taskExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7247
+ }
7248
+ initiativeExistsForSync(id, workspaceId) {
7249
+ return this.initiativeExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7250
+ }
7251
+ workstreamExistsForSync(id, workspaceId) {
7252
+ return this.workstreamExists(id, this.normalizeWorkspaceId(workspaceId || undefined));
7253
+ }
7254
+ recordTaskEvent(input) {
7255
+ this.addTaskEvent({
7256
+ taskId: input.taskId,
7257
+ workspaceId: this.normalizeWorkspaceId(input.workspaceId || undefined),
7258
+ action: input.action,
7259
+ actor: input.actor || 'system',
7260
+ createdAt: input.createdAt,
7261
+ details: input.details,
7262
+ });
7263
+ }
6603
7264
  listTaskEventsForSync(input) {
6604
7265
  const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
6605
- return listWorkspaceTaskEventsService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7266
+ return listWorkspaceTaskEventsForSyncService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7267
+ }
7268
+ getTaskEventForSync(input) {
7269
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7270
+ return getTaskEventForSyncService(this.getTaskActivityServiceDeps(), {
7271
+ workspaceId: normalizedWorkspaceId,
7272
+ taskId: input.taskId,
7273
+ eventId: input.eventId,
7274
+ });
7275
+ }
7276
+ listEntityEventsForSync(input) {
7277
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7278
+ return listWorkspaceEntityEventsForSyncService(this.getTaskActivityServiceDeps(), normalizedWorkspaceId);
7279
+ }
7280
+ getEntityEventForSync(input) {
7281
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(input.workspaceId || undefined);
7282
+ return getEntityEventForSyncService(this.getTaskActivityServiceDeps(), {
7283
+ workspaceId: normalizedWorkspaceId,
7284
+ eventId: input.eventId,
7285
+ });
6606
7286
  }
6607
7287
  getScheduleBoard(weekKey, showWeekends = false, workspaceId) {
6608
7288
  const parsedWeek = this.parseIsoWeekKeyToDates(weekKey);
@@ -6866,6 +7546,16 @@ export class TaskforceCore {
6866
7546
  workstreamId: task.workstreamId || null
6867
7547
  }
6868
7548
  });
7549
+ if (task.workstreamId) {
7550
+ this.addWorkstreamTaskMembershipEvents({
7551
+ task,
7552
+ workspaceId: normalizedWorkspaceId,
7553
+ actor: options?.actorRef || createdBy || 'system',
7554
+ createdAt: task.updatedAt || task.createdAt,
7555
+ previousWorkstreamId: null,
7556
+ nextWorkstreamId: task.workstreamId,
7557
+ });
7558
+ }
6869
7559
  }
6870
7560
  if (persistBackup) {
6871
7561
  this.persistBackup();
@@ -7187,6 +7877,16 @@ export class TaskforceCore {
7187
7877
  createdAt: nextTask.updatedAt,
7188
7878
  details: { changes }
7189
7879
  });
7880
+ if (changes.workstreamId) {
7881
+ this.addWorkstreamTaskMembershipEvents({
7882
+ task: nextTask,
7883
+ workspaceId: normalizedWorkspaceId,
7884
+ actor: options?.actorRef || 'system',
7885
+ createdAt: nextTask.updatedAt,
7886
+ previousWorkstreamId: existing.workstreamId || null,
7887
+ nextWorkstreamId: nextTask.workstreamId || null,
7888
+ });
7889
+ }
7190
7890
  }
7191
7891
  }
7192
7892
  return this.getTask(taskId, normalizedWorkspaceId);
@@ -7206,7 +7906,11 @@ export class TaskforceCore {
7206
7906
  }
7207
7907
  addComment(id, text, author, workspaceId, options) {
7208
7908
  const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
7209
- const existing = this.db.prepare('SELECT id FROM tasks WHERE tenant_id = ? AND workspace_id = ? AND id = ?').get(this.tenantId, normalizedWorkspaceId, id);
7909
+ const existing = this.db.prepare(`
7910
+ SELECT id, updated_at, created_at
7911
+ FROM tasks
7912
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
7913
+ `).get(this.tenantId, normalizedWorkspaceId, id);
7210
7914
  if (!existing)
7211
7915
  return null;
7212
7916
  const submittedAuthor = String(author || '').trim() || 'user';
@@ -7231,7 +7935,13 @@ export class TaskforceCore {
7231
7935
  submitted_author: comment.submittedAuthor,
7232
7936
  timestamp: comment.timestamp
7233
7937
  });
7234
- this.db.prepare('UPDATE tasks SET updated_at = ? WHERE tenant_id = ? AND workspace_id = ? AND id = ?').run(comment.timestamp, this.tenantId, normalizedWorkspaceId, id);
7938
+ const existingWatermark = String(existing.updated_at || existing.created_at || '').trim();
7939
+ const existingWatermarkMs = Date.parse(existingWatermark);
7940
+ const commentTimestampMs = Date.parse(comment.timestamp);
7941
+ const nextTaskUpdatedAt = Number.isFinite(existingWatermarkMs) && existingWatermarkMs >= commentTimestampMs
7942
+ ? new Date(existingWatermarkMs + 1).toISOString()
7943
+ : comment.timestamp;
7944
+ this.db.prepare('UPDATE tasks SET updated_at = ? WHERE tenant_id = ? AND workspace_id = ? AND id = ?').run(nextTaskUpdatedAt, this.tenantId, normalizedWorkspaceId, id);
7235
7945
  if (options?.skipTaskEvents !== true) {
7236
7946
  this.addTaskEvent({
7237
7947
  taskId: id,