@taskforcehq/taskforce 0.3.301 → 0.3.303

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 (171) hide show
  1. package/dist/Taskforce.module.css +37 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +72 -33
  4. package/dist/TaskforceCore.test.js +848 -130
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/task/TaskKanban.test.js +17 -0
  14. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  15. package/dist/components/views/PlansPage.d.ts +3 -1
  16. package/dist/components/views/PlansPage.js +109 -49
  17. package/dist/components/views/PlansPage.test.d.ts +1 -0
  18. package/dist/components/views/PlansPage.test.js +100 -0
  19. package/dist/components/views/StandaloneLayout.d.ts +1 -0
  20. package/dist/components/views/StandaloneLayout.js +473 -256
  21. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  22. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
  23. package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
  24. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
  25. package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
  26. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  27. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  28. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  29. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  30. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  31. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  32. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  33. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  34. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  35. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  36. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  37. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  38. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  39. package/dist/components/views/standalone/modals/types.js +1 -0
  40. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  41. package/dist/components/views/teamManagementAccess.js +2 -2
  42. package/dist/components/views/teamManagementAccess.test.js +3 -3
  43. package/dist/config/deployment.js +2 -2
  44. package/dist/config/deployment.test.js +5 -3
  45. package/dist/core/AiProfileService.js +7 -2
  46. package/dist/core/AiProfiles.test.js +19 -0
  47. package/dist/core/EntitlementsPolicy.test.js +2 -2
  48. package/dist/core/GlobalSettingsService.js +78 -18
  49. package/dist/core/PlanEntitlementService.d.ts +29 -5
  50. package/dist/core/PlanEntitlementService.js +297 -219
  51. package/dist/core/SignupMonetizationSettings.test.js +173 -49
  52. package/dist/core/SyncReconciliationService.d.ts +2 -0
  53. package/dist/core/SyncReconciliationService.js +3 -0
  54. package/dist/core/SystemAdmin.test.js +186 -96
  55. package/dist/core/TaskAuditTimeline.test.js +40 -0
  56. package/dist/core/Taskforce.d.ts +100 -10
  57. package/dist/core/Taskforce.ids.test.js +133 -0
  58. package/dist/core/Taskforce.js +459 -127
  59. package/dist/core/Taskforce.mcpAuth.test.js +0 -2
  60. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  61. package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
  62. package/dist/core/taskReferencePolicy.d.ts +28 -0
  63. package/dist/core/taskReferencePolicy.js +189 -0
  64. package/dist/core/types.d.ts +13 -12
  65. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  66. package/dist/hooks/useSyncOrchestrator.js +17 -3
  67. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
  68. package/dist/hooks/useTaskData.js +3 -2
  69. package/dist/hooks/useTaskData.test.js +66 -0
  70. package/dist/hooks/useTaskforce.d.ts +8 -0
  71. package/dist/hooks/useTaskforce.js +72 -19
  72. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  73. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  74. package/dist/mcp/aiProfileBootstrap.js +80 -0
  75. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  76. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  77. package/dist/mcp/clientIntegrations.d.ts +2 -8
  78. package/dist/mcp/clientIntegrations.js +21 -40
  79. package/dist/mcp/clientIntegrations.test.js +28 -2
  80. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  81. package/dist/mcp/clientProfileDefaults.js +37 -0
  82. package/dist/mcp/runtime.d.ts +10 -7
  83. package/dist/mcp/runtime.js +131 -48
  84. package/dist/mcp/runtime.test.js +321 -83
  85. package/dist/migrations/billingSchemaParity.test.js +3 -0
  86. package/dist/migrations/taskSchemaMigrations.js +68 -1
  87. package/dist/qaOpen.test.d.ts +1 -0
  88. package/dist/qaOpen.test.js +56 -0
  89. package/dist/server/index.cookieProxy.test.js +2 -0
  90. package/dist/server/index.d.ts +1 -0
  91. package/dist/server/index.js +17 -1
  92. package/dist/server/index.test.js +15 -1
  93. package/dist/server/mockStripe.d.ts +1 -0
  94. package/dist/server/mockStripe.js +2 -0
  95. package/dist/server/routes/admin.js +105 -45
  96. package/dist/server/routes/auth.d.ts +6 -0
  97. package/dist/server/routes/auth.js +505 -30
  98. package/dist/server/routes/billing.js +517 -24
  99. package/dist/server/routes/billing.test.js +967 -45
  100. package/dist/server/routes/documents.js +6 -1
  101. package/dist/server/routes/shared.d.ts +1 -1
  102. package/dist/server/routes/shared.js +16 -6
  103. package/dist/server/routes/sync.integration.test.js +290 -0
  104. package/dist/server/routes/sync.js +234 -1922
  105. package/dist/server/routes/tasks.js +18 -4
  106. package/dist/server/routes.js +29 -22
  107. package/dist/server/routes.test.js +639 -82
  108. package/dist/shared/runtimeContract.d.ts +1 -1
  109. package/dist/sync/cloudSyncApi.d.ts +1 -0
  110. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  111. package/dist/sync/collaborationSyncPayload.js +131 -0
  112. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  113. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  114. package/dist/sync/collaborationSyncState.d.ts +15 -0
  115. package/dist/sync/collaborationSyncState.js +60 -0
  116. package/dist/sync/contentSyncPayload.d.ts +45 -0
  117. package/dist/sync/contentSyncPayload.js +104 -0
  118. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  119. package/dist/sync/contentSyncPayload.test.js +104 -0
  120. package/dist/sync/contentSyncState.d.ts +98 -0
  121. package/dist/sync/contentSyncState.js +1004 -0
  122. package/dist/sync/planningSyncPayload.d.ts +9 -0
  123. package/dist/sync/planningSyncPayload.js +82 -0
  124. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  125. package/dist/sync/planningSyncPayload.test.js +68 -0
  126. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  127. package/dist/sync/syncApplyHandlers.js +445 -0
  128. package/dist/sync/syncService.d.ts +1 -0
  129. package/dist/sync/syncService.js +21 -8
  130. package/dist/sync/taskSyncPayload.d.ts +33 -0
  131. package/dist/sync/taskSyncPayload.js +126 -0
  132. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  133. package/dist/sync/taskSyncPayload.test.js +54 -0
  134. package/dist/sync/workspacePullFeed.d.ts +38 -0
  135. package/dist/sync/workspacePullFeed.js +349 -0
  136. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  137. package/dist/sync/workspacePullFeed.test.js +278 -0
  138. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  139. package/dist/sync/workspaceSyncModel.js +9 -2
  140. package/dist/sync/workspaceSyncModel.test.js +29 -0
  141. package/dist/test/setup.js +1 -1
  142. package/dist/types.d.ts +1 -0
  143. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
  144. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
  145. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
  146. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
  147. package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
  148. package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
  149. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  150. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
  151. package/dist/ui/assets/index-CLIMgR6g.js +6 -0
  152. package/dist/ui/assets/index-DneETxcu.css +1 -0
  153. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
  154. package/dist/ui/index.html +3 -3
  155. package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
  156. package/dist/utils/accountProfileSummaryCache.js +97 -0
  157. package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
  158. package/dist/utils/accountProfileSummaryCache.test.js +63 -0
  159. package/dist/utils/taskActivity.js +17 -4
  160. package/dist/utils/taskActivity.test.js +27 -0
  161. package/dist/utils/taskNormalization.js +10 -2
  162. package/dist/utils/taskReferences.d.ts +11 -1
  163. package/dist/utils/taskReferences.js +33 -1
  164. package/package.json +4 -3
  165. package/scripts/qa-open-lib.mjs +175 -0
  166. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  167. package/scripts/qa-open.mjs +352 -0
  168. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  169. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  170. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  171. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -27,7 +27,6 @@ function setupWorkspace(core, userId = 'owner-1', workspaceId = 'workspace-1') {
27
27
  hasEntitlement: true,
28
28
  canAccessApp: true,
29
29
  canAccessPlans: true,
30
- signupMonetizationStrategy: 'none',
31
30
  planSelectionRequired: false,
32
31
  message: null,
33
32
  entitlement: null
@@ -328,7 +327,6 @@ describe('TaskforceCore MCP auth invalidation', () => {
328
327
  hasEntitlement: false,
329
328
  canAccessApp: false,
330
329
  canAccessPlans: true,
331
- signupMonetizationStrategy: 'none',
332
330
  planSelectionRequired: false,
333
331
  message: 'Suspended for test',
334
332
  entitlement: null
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { TaskforceCore } from './Taskforce';
6
+ function makeProjectRoot() {
7
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-user-avatar-'));
8
+ }
9
+ function seedPlans(core) {
10
+ core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
11
+ core.updatePlanVersion({
12
+ planId: 'starter',
13
+ versionNumber: 1,
14
+ seatLimit: 1
15
+ });
16
+ const db = core.db;
17
+ const now = new Date().toISOString();
18
+ db.prepare(`
19
+ INSERT OR IGNORE INTO billing_price_mappings (
20
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
21
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
+ `).run(core.getTenantId(), 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, null, now, now);
23
+ core.updateGlobalSettings({
24
+ billing: {
25
+ onboardingPolicy: {
26
+ mode: 'auto_grant_free',
27
+ planVersionId: 'starter-v1'
28
+ }
29
+ }
30
+ });
31
+ }
32
+ function openCore(projectRoot) {
33
+ const core = new TaskforceCore({
34
+ projectRoot,
35
+ storagePath: path.join(projectRoot, '.taskforce')
36
+ });
37
+ seedPlans(core);
38
+ return core;
39
+ }
40
+ describe('TaskforceCore user profile avatar drafts', () => {
41
+ it('persists avatarUrl on profile updates and exposes it in resolved identity', () => {
42
+ const projectRoot = makeProjectRoot();
43
+ const core = openCore(projectRoot);
44
+ try {
45
+ const created = core.registerPasswordUser({
46
+ email: 'avatar-user@example.com',
47
+ password: 'password123'
48
+ });
49
+ const updated = core.updateUserProfile(created.userId, {
50
+ displayName: 'Avatar User',
51
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`
52
+ });
53
+ expect(updated).toMatchObject({
54
+ userId: created.userId,
55
+ displayName: 'Avatar User',
56
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`
57
+ });
58
+ expect(core.resolveUserIdentity(created.userId)?.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`);
59
+ }
60
+ finally {
61
+ core.close();
62
+ fs.rmSync(projectRoot, { recursive: true, force: true });
63
+ }
64
+ });
65
+ it('tracks, finalizes, discards, and cleans up stale avatar upload drafts', () => {
66
+ const projectRoot = makeProjectRoot();
67
+ const core = openCore(projectRoot);
68
+ try {
69
+ const created = core.registerPasswordUser({
70
+ email: 'draft-user@example.com',
71
+ password: 'password123'
72
+ });
73
+ const draft = core.createUserAvatarUploadDraft({
74
+ userId: created.userId,
75
+ draftId: 'avatar-draft-1',
76
+ storageKey: `users/${created.userId}/profile/avatar/avatar-draft-1-headshot.png`,
77
+ mimeType: 'image/png',
78
+ originalFilename: 'headshot.png',
79
+ sizeBytes: 1024,
80
+ storageProvider: 'r2'
81
+ });
82
+ expect(draft.finalizedAt).toBeNull();
83
+ expect(core.markUserAvatarUploadDraftFinalized(created.userId, draft.draftId)?.finalizedAt).toEqual(expect.any(String));
84
+ expect(core.markUserAvatarUploadDraftConsumed(created.userId, draft.draftId)?.consumedAt).toEqual(expect.any(String));
85
+ expect(core.discardUserAvatarUploadDraft(created.userId, draft.draftId)?.discardedAt).toEqual(expect.any(String));
86
+ core.createUserAvatarUploadDraft({
87
+ userId: created.userId,
88
+ draftId: 'avatar-draft-stale',
89
+ storageKey: `users/${created.userId}/profile/avatar/avatar-draft-stale-headshot.png`,
90
+ mimeType: 'image/png',
91
+ originalFilename: 'stale.png',
92
+ sizeBytes: 512,
93
+ storageProvider: 'r2'
94
+ });
95
+ const db = core.db;
96
+ db.prepare(`
97
+ UPDATE user_avatar_upload_drafts
98
+ SET created_at = ?, updated_at = ?
99
+ WHERE tenant_id = ? AND user_id = ? AND draft_id = ?
100
+ `).run('2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', core.getTenantId(), created.userId, 'avatar-draft-stale');
101
+ const stale = core.cleanupStaleUserAvatarUploadDrafts(created.userId, '2026-02-01T00:00:00.000Z');
102
+ expect(stale).toHaveLength(1);
103
+ expect(stale[0]?.draftId).toBe('avatar-draft-stale');
104
+ expect(core.getUserAvatarUploadDraft(created.userId, 'avatar-draft-stale')?.discardedAt).toEqual(expect.any(String));
105
+ }
106
+ finally {
107
+ core.close();
108
+ fs.rmSync(projectRoot, { recursive: true, force: true });
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,28 @@
1
+ import type { DatabaseAdapter } from '../storage/databaseAdapter.js';
2
+ import type { TaskItem } from './Taskforce.js';
3
+ interface TaskReferencePolicyDeps<TTask> {
4
+ db: DatabaseAdapter;
5
+ tenantId: string;
6
+ normalizeWorkspaceId: (workspaceId?: string) => string;
7
+ sanitizeTaskReferenceNumber: (value: unknown) => number | null;
8
+ rowToTask: (row: any) => TTask;
9
+ getTask: (id: string, workspaceId?: string) => TTask | null;
10
+ }
11
+ export declare function getTaskReferenceSequenceValue<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string): number;
12
+ export declare function getLocalTaskReferenceSequenceValue<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string): number;
13
+ export declare function getMaxTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
14
+ export declare function getMaxLocalTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
15
+ export declare function persistTaskReferenceSequence<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number): void;
16
+ export declare function persistLocalTaskReferenceSequence<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number): void;
17
+ export declare function allocateNextTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
18
+ export declare function allocateNextLocalTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
19
+ export declare function relocateTaskReferenceConflict<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number, claimantTaskId?: string | null, updatedAt?: string | null): {
20
+ taskId: string;
21
+ referenceNumber: number;
22
+ } | null;
23
+ export declare function reserveTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, preferredReferenceNumber?: number | null, currentTaskId?: string | null): number;
24
+ export declare function getTaskByReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, referenceNumber: number, workspaceId?: string): TTask | null;
25
+ export declare function getTaskByLocalReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, referenceNumber: number, workspaceId?: string): TTask | null;
26
+ export declare function resolveTaskIdentifier(deps: TaskReferencePolicyDeps<TaskItem>, value: string, workspaceId?: string): TaskItem | null;
27
+ export declare function deriveTaskReferenceLabel(task: Pick<TaskItem, 'referenceNumber' | 'localReferenceNumber'>): string;
28
+ export {};
@@ -0,0 +1,189 @@
1
+ import { formatLocalTaskReference, parseLocalTaskReference, parseTaskReference, } from '../utils/taskReferences.js';
2
+ export function getTaskReferenceSequenceValue(deps, workspaceId) {
3
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
4
+ const row = deps.db.prepare(`
5
+ SELECT next_reference_number
6
+ FROM workspace_task_reference_sequences
7
+ WHERE tenant_id = ? AND workspace_id = ?
8
+ LIMIT 1
9
+ `).get(deps.tenantId, normalizedWorkspaceId);
10
+ return deps.sanitizeTaskReferenceNumber(row?.next_reference_number) || 0;
11
+ }
12
+ export function getLocalTaskReferenceSequenceValue(deps, workspaceId) {
13
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
14
+ const row = deps.db.prepare(`
15
+ SELECT next_reference_number
16
+ FROM workspace_local_task_reference_sequences
17
+ WHERE tenant_id = ? AND workspace_id = ?
18
+ LIMIT 1
19
+ `).get(deps.tenantId, normalizedWorkspaceId);
20
+ return deps.sanitizeTaskReferenceNumber(row?.next_reference_number) || 0;
21
+ }
22
+ export function getMaxTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
23
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
24
+ const normalizedExcludeTaskId = String(excludeTaskId || '').trim();
25
+ const row = normalizedExcludeTaskId
26
+ ? deps.db.prepare(`
27
+ SELECT MAX(reference_number) AS max_reference_number
28
+ FROM tasks
29
+ WHERE tenant_id = ? AND workspace_id = ? AND id != ?
30
+ `).get(deps.tenantId, normalizedWorkspaceId, normalizedExcludeTaskId)
31
+ : deps.db.prepare(`
32
+ SELECT MAX(reference_number) AS max_reference_number
33
+ FROM tasks
34
+ WHERE tenant_id = ? AND workspace_id = ?
35
+ `).get(deps.tenantId, normalizedWorkspaceId);
36
+ return deps.sanitizeTaskReferenceNumber(row?.max_reference_number) || 0;
37
+ }
38
+ export function getMaxLocalTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
39
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
40
+ const normalizedExcludeTaskId = String(excludeTaskId || '').trim();
41
+ const row = normalizedExcludeTaskId
42
+ ? deps.db.prepare(`
43
+ SELECT MAX(local_reference_number) AS max_reference_number
44
+ FROM tasks
45
+ WHERE tenant_id = ? AND workspace_id = ? AND id != ?
46
+ `).get(deps.tenantId, normalizedWorkspaceId, normalizedExcludeTaskId)
47
+ : deps.db.prepare(`
48
+ SELECT MAX(local_reference_number) AS max_reference_number
49
+ FROM tasks
50
+ WHERE tenant_id = ? AND workspace_id = ?
51
+ `).get(deps.tenantId, normalizedWorkspaceId);
52
+ return deps.sanitizeTaskReferenceNumber(row?.max_reference_number) || 0;
53
+ }
54
+ export function persistTaskReferenceSequence(deps, workspaceId, referenceNumber) {
55
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
56
+ const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
57
+ if (!normalizedReferenceNumber)
58
+ return;
59
+ deps.db.prepare(`
60
+ INSERT INTO workspace_task_reference_sequences (tenant_id, workspace_id, next_reference_number)
61
+ VALUES (?, ?, ?)
62
+ ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
63
+ next_reference_number = CASE
64
+ WHEN workspace_task_reference_sequences.next_reference_number < EXCLUDED.next_reference_number
65
+ THEN EXCLUDED.next_reference_number
66
+ ELSE workspace_task_reference_sequences.next_reference_number
67
+ END
68
+ `).run(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
69
+ }
70
+ export function persistLocalTaskReferenceSequence(deps, workspaceId, referenceNumber) {
71
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
72
+ const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
73
+ if (!normalizedReferenceNumber)
74
+ return;
75
+ deps.db.prepare(`
76
+ INSERT INTO workspace_local_task_reference_sequences (tenant_id, workspace_id, next_reference_number)
77
+ VALUES (?, ?, ?)
78
+ ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
79
+ next_reference_number = CASE
80
+ WHEN workspace_local_task_reference_sequences.next_reference_number < EXCLUDED.next_reference_number
81
+ THEN EXCLUDED.next_reference_number
82
+ ELSE workspace_local_task_reference_sequences.next_reference_number
83
+ END
84
+ `).run(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
85
+ }
86
+ export function allocateNextTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
87
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
88
+ const nextReferenceNumber = Math.max(getTaskReferenceSequenceValue(deps, normalizedWorkspaceId), getMaxTaskReferenceNumber(deps, normalizedWorkspaceId, excludeTaskId)) + 1;
89
+ persistTaskReferenceSequence(deps, normalizedWorkspaceId, nextReferenceNumber);
90
+ return nextReferenceNumber;
91
+ }
92
+ export function allocateNextLocalTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
93
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
94
+ const nextReferenceNumber = Math.max(getLocalTaskReferenceSequenceValue(deps, normalizedWorkspaceId), getMaxLocalTaskReferenceNumber(deps, normalizedWorkspaceId, excludeTaskId)) + 1;
95
+ persistLocalTaskReferenceSequence(deps, normalizedWorkspaceId, nextReferenceNumber);
96
+ return nextReferenceNumber;
97
+ }
98
+ export function relocateTaskReferenceConflict(deps, workspaceId, referenceNumber, claimantTaskId, updatedAt) {
99
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
100
+ const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
101
+ if (!normalizedReferenceNumber)
102
+ return null;
103
+ const normalizedClaimantTaskId = String(claimantTaskId || '').trim();
104
+ const incumbent = deps.db.prepare(`
105
+ SELECT id
106
+ FROM tasks
107
+ WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ? AND id != ?
108
+ LIMIT 1
109
+ `).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber, normalizedClaimantTaskId);
110
+ const incumbentTaskId = String(incumbent?.id || '').trim();
111
+ if (!incumbentTaskId)
112
+ return null;
113
+ const nextReferenceNumber = allocateNextTaskReferenceNumber(deps, normalizedWorkspaceId, incumbentTaskId);
114
+ deps.db.prepare(`
115
+ UPDATE tasks
116
+ SET reference_number = ?, updated_at = ?
117
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
118
+ `).run(nextReferenceNumber, String(updatedAt || new Date().toISOString()), deps.tenantId, normalizedWorkspaceId, incumbentTaskId);
119
+ return { taskId: incumbentTaskId, referenceNumber: nextReferenceNumber };
120
+ }
121
+ export function reserveTaskReferenceNumber(deps, workspaceId, preferredReferenceNumber, currentTaskId) {
122
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
123
+ const preferred = deps.sanitizeTaskReferenceNumber(preferredReferenceNumber);
124
+ const normalizedCurrentTaskId = String(currentTaskId || '').trim();
125
+ if (preferred) {
126
+ const existing = deps.db.prepare(`
127
+ SELECT id
128
+ FROM tasks
129
+ WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ?
130
+ LIMIT 1
131
+ `).get(deps.tenantId, normalizedWorkspaceId, preferred);
132
+ if (!existing?.id || existing.id === normalizedCurrentTaskId) {
133
+ persistTaskReferenceSequence(deps, normalizedWorkspaceId, preferred);
134
+ return preferred;
135
+ }
136
+ }
137
+ return allocateNextTaskReferenceNumber(deps, normalizedWorkspaceId, normalizedCurrentTaskId || undefined);
138
+ }
139
+ export function getTaskByReferenceNumber(deps, referenceNumber, workspaceId) {
140
+ const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
141
+ if (!normalizedReferenceNumber)
142
+ return null;
143
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
144
+ const row = deps.db.prepare(`
145
+ SELECT * FROM tasks
146
+ WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ?
147
+ LIMIT 1
148
+ `).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
149
+ if (!row)
150
+ return null;
151
+ return deps.rowToTask(row);
152
+ }
153
+ export function getTaskByLocalReferenceNumber(deps, referenceNumber, workspaceId) {
154
+ const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
155
+ if (!normalizedReferenceNumber)
156
+ return null;
157
+ const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
158
+ const row = deps.db.prepare(`
159
+ SELECT * FROM tasks
160
+ WHERE tenant_id = ? AND workspace_id = ? AND local_reference_number = ?
161
+ LIMIT 1
162
+ `).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
163
+ if (!row)
164
+ return null;
165
+ return deps.rowToTask(row);
166
+ }
167
+ export function resolveTaskIdentifier(deps, value, workspaceId) {
168
+ const normalizedValue = String(value || '').trim();
169
+ if (!normalizedValue)
170
+ return null;
171
+ const referenceNumber = parseTaskReference(normalizedValue);
172
+ if (referenceNumber) {
173
+ const exactReferenceMatch = getTaskByReferenceNumber(deps, referenceNumber, workspaceId);
174
+ if (exactReferenceMatch)
175
+ return exactReferenceMatch;
176
+ }
177
+ const localReferenceNumber = parseLocalTaskReference(normalizedValue);
178
+ if (localReferenceNumber) {
179
+ const exactLocalReferenceMatch = getTaskByLocalReferenceNumber(deps, localReferenceNumber, workspaceId);
180
+ if (exactLocalReferenceMatch)
181
+ return exactLocalReferenceMatch;
182
+ }
183
+ return deps.getTask(normalizedValue, workspaceId);
184
+ }
185
+ export function deriveTaskReferenceLabel(task) {
186
+ return task.referenceNumber
187
+ ? `T-${task.referenceNumber}`
188
+ : formatLocalTaskReference(task.localReferenceNumber);
189
+ }
@@ -27,6 +27,7 @@ export type TaskAssignee = string;
27
27
  export interface TaskItem {
28
28
  id: string;
29
29
  referenceNumber?: number | null;
30
+ localReferenceNumber?: number | null;
30
31
  referenceLabel?: string;
31
32
  title: string;
32
33
  description: string | null;
@@ -267,8 +268,9 @@ export interface GlobalTaskforceSettings {
267
268
  authRateLimitEnabled?: boolean;
268
269
  authRateLimitMaxRequests?: number;
269
270
  authRateLimitWindowMs?: number;
270
- signupMonetizationStrategy?: 'auto_assign_default' | 'plan_selection_required';
271
- defaultSignupPlanVersionId?: string | null;
271
+ };
272
+ billing?: {
273
+ onboardingPolicy?: OnboardingPolicy;
272
274
  };
273
275
  site?: {
274
276
  showRunTaskforceLocally?: boolean;
@@ -298,14 +300,14 @@ export interface UserGlobalSyncSettings {
298
300
  weekStartsOn?: 'sunday' | 'monday';
299
301
  };
300
302
  }
301
- export type SignupMonetizationStrategy = 'auto_assign_default' | 'plan_selection_required';
302
- export interface SignupMonetizationSettings {
303
- strategy: SignupMonetizationStrategy;
304
- defaultSignupPlanVersionId: string | null;
303
+ export type OnboardingPolicyMode = 'require_plan_selection' | 'auto_grant_free' | 'auto_start_trial';
304
+ export interface OnboardingPolicy {
305
+ mode: OnboardingPolicyMode;
306
+ planVersionId: string | null;
305
307
  }
306
- export interface SignupMonetizationValidationResult {
308
+ export interface OnboardingPolicyValidationResult {
307
309
  valid: boolean;
308
- code?: 'SIGNUP_PLAN_VERSION_REQUIRED' | 'SIGNUP_PLAN_VERSION_NOT_FOUND' | 'SIGNUP_PLAN_NOT_ENABLED';
310
+ code?: 'ONBOARDING_PLAN_VERSION_REQUIRED' | 'ONBOARDING_PLAN_VERSION_NOT_FOUND' | 'ONBOARDING_PLAN_NOT_ENABLED' | 'ONBOARDING_PLAN_MUST_BE_FREE' | 'ONBOARDING_PLAN_TRIAL_REQUIRED';
309
311
  message?: string;
310
312
  details?: Record<string, unknown>;
311
313
  }
@@ -330,6 +332,7 @@ export interface UserIdentityRecord {
330
332
  userId: string;
331
333
  email: string;
332
334
  displayName?: string | null;
335
+ avatarUrl?: string | null;
333
336
  systemRole?: SystemRole;
334
337
  betaAccess?: boolean;
335
338
  }
@@ -439,7 +442,7 @@ export interface McpAuditLogRecord {
439
442
  reasonCode: string | null;
440
443
  details: Record<string, unknown>;
441
444
  }
442
- export type EntitlementState = 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
445
+ export type EntitlementState = 'pending_plan_selection' | 'checkout_pending' | 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
443
446
  export type PlanFeatureAccess = 'enabled' | 'disabled' | 'limited';
444
447
  export interface PlanFeatureSnapshot {
445
448
  featureKey: string;
@@ -497,17 +500,15 @@ export interface AccountAccessStatus_Ok {
497
500
  hasEntitlement: true;
498
501
  canAccessApp: true;
499
502
  canAccessPlans: true;
500
- signupMonetizationStrategy: SignupMonetizationStrategy;
501
503
  planSelectionRequired: false;
502
504
  message: null;
503
505
  entitlement: AccountEntitlementSnapshot;
504
506
  }
505
507
  export interface AccountAccessStatus_Blocked {
506
- gate: 'plan_selection_required' | 'misconfigured' | 'missing_entitlement';
508
+ gate: 'plan_selection_required' | 'checkout_pending' | 'misconfigured' | 'missing_entitlement';
507
509
  hasEntitlement: boolean;
508
510
  canAccessApp: false;
509
511
  canAccessPlans: true;
510
- signupMonetizationStrategy: SignupMonetizationStrategy;
511
512
  planSelectionRequired: boolean;
512
513
  message: string;
513
514
  entitlement: AccountEntitlementSnapshot | null;
@@ -130,6 +130,9 @@ export function useTaskFormActions(input) {
130
130
  if (!editingTaskId || attachmentsDirty) {
131
131
  body.attachments = attachments;
132
132
  }
133
+ if (editingTaskId) {
134
+ body.saveSource = source;
135
+ }
133
136
  const res = await fetch(url, {
134
137
  method,
135
138
  headers: { 'Content-Type': 'application/json' },
@@ -1571,7 +1571,12 @@ export function useSyncOrchestrator(input) {
1571
1571
  status: 'error',
1572
1572
  statusCode: pullResult.status,
1573
1573
  errorMessage: message,
1574
- requestMs: pullResult.pullRequestMs
1574
+ requestMs: pullResult.pullRequestMs,
1575
+ details: {
1576
+ pages: pullResult.pages,
1577
+ applyMs: pullResult.applyMs,
1578
+ ...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
1579
+ }
1575
1580
  });
1576
1581
  if (pullResult.transient || pullResult.hasTransientApplyFailure || isTransientWorkspaceSyncStatus(pullResult.status)) {
1577
1582
  scheduleWorkspaceSyncRetry(pullResult.retryAfterMs);
@@ -1638,7 +1643,12 @@ export function useSyncOrchestrator(input) {
1638
1643
  eventType: workspaceSyncPhase === 'attach-cloud' ? 'bootstrap' : 'pull',
1639
1644
  status: 'success',
1640
1645
  changeCount: pullResult.appliedChanges,
1641
- requestMs: pullResult.pullRequestMs
1646
+ requestMs: pullResult.pullRequestMs,
1647
+ details: {
1648
+ pages: pullResult.pages,
1649
+ applyMs: pullResult.applyMs,
1650
+ ...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
1651
+ }
1642
1652
  });
1643
1653
  if (workspaceSyncPhase === 'attach-cloud') {
1644
1654
  await refreshWorkspaceSyncMarkdownDocumentsSnapshot();
@@ -1727,8 +1737,11 @@ export function useSyncOrchestrator(input) {
1727
1737
  const eventId = String(signal?.eventId || '').trim();
1728
1738
  if (eventId && realtimeSuppressedEventIdsRef.current.delete(eventId))
1729
1739
  return;
1730
- if (isWorkspaceRetryPending() || workspacePullInFlightRef.current)
1740
+ if (workspacePullInFlightRef.current)
1731
1741
  return;
1742
+ if (isWorkspaceRetryPending()) {
1743
+ clearWorkspaceRetry();
1744
+ }
1732
1745
  if (realtimePullDebounceRef.current !== null) {
1733
1746
  window.clearTimeout(realtimePullDebounceRef.current);
1734
1747
  }
@@ -1742,6 +1755,7 @@ export function useSyncOrchestrator(input) {
1742
1755
  workspaceCloudSyncEnabled,
1743
1756
  workspaceSyncPhase,
1744
1757
  isWorkspaceRetryPending,
1758
+ clearWorkspaceRetry,
1745
1759
  workspacePullInFlightRef,
1746
1760
  pullWorkspaceChangesFromCloud
1747
1761
  ]);
@@ -3,19 +3,44 @@ import { act, renderHook, waitFor } from '@testing-library/react';
3
3
  import { useSyncOrchestrator } from './useSyncOrchestrator';
4
4
  class MockWebSocket {
5
5
  static instances = [];
6
+ static OPEN = 1;
7
+ static CLOSED = 3;
6
8
  url;
9
+ readyState = 0;
10
+ sent = [];
11
+ listeners = new Map();
7
12
  constructor(url) {
8
13
  this.url = url;
9
14
  MockWebSocket.instances.push(this);
10
15
  }
11
16
  close() {
12
- // noop for orchestration gating tests
17
+ this.readyState = MockWebSocket.CLOSED;
18
+ this.emit('close', {});
13
19
  }
14
- addEventListener() {
15
- // noop for orchestration gating tests
20
+ addEventListener(type, listener) {
21
+ const bucket = this.listeners.get(type) || new Set();
22
+ bucket.add(listener);
23
+ this.listeners.set(type, bucket);
16
24
  }
17
- send() {
18
- // noop for orchestration gating tests
25
+ send(payload) {
26
+ if (typeof payload === 'string') {
27
+ this.sent.push(payload);
28
+ }
29
+ }
30
+ emit(type, event) {
31
+ const bucket = this.listeners.get(type);
32
+ if (!bucket)
33
+ return;
34
+ for (const listener of bucket) {
35
+ listener(event);
36
+ }
37
+ }
38
+ open() {
39
+ this.readyState = MockWebSocket.OPEN;
40
+ this.emit('open', {});
41
+ }
42
+ message(payload) {
43
+ this.emit('message', { data: JSON.stringify(payload) });
19
44
  }
20
45
  }
21
46
  const { ensureCloudWorkspaceReadyForSyncServiceMock, runWorkspacePullSyncServiceMock, runWorkspacePushSyncServiceMock, syncUserGlobalSettingsServiceMock } = vi.hoisted(() => ({
@@ -206,6 +231,73 @@ describe('useSyncOrchestrator', () => {
206
231
  expect(MockWebSocket.instances[0]?.url).toBe('wss://api.example.com/taskforce-ws');
207
232
  unmount();
208
233
  });
234
+ it('lets a realtime signal break transient retry backoff and pull immediately', async () => {
235
+ vi.useFakeTimers();
236
+ vi.stubGlobal('WebSocket', MockWebSocket);
237
+ runWorkspacePullSyncServiceMock
238
+ .mockResolvedValueOnce({
239
+ success: false,
240
+ kind: 'pull_http',
241
+ status: 502,
242
+ transient: true,
243
+ retryAfterMs: 60_000,
244
+ cursor: null,
245
+ pages: 0,
246
+ appliedChanges: 0,
247
+ pullRequestMs: 1,
248
+ applyMs: 0,
249
+ pulledDeleteTaskIds: new Set(),
250
+ pulledActiveUpserts: false,
251
+ pulledArchivedUpserts: false,
252
+ documentDecryptFailures: []
253
+ })
254
+ .mockResolvedValueOnce({
255
+ success: true,
256
+ syncedAt: '2026-03-15T18:05:00.000Z',
257
+ cursor: null,
258
+ pages: 1,
259
+ appliedChanges: 1,
260
+ pullRequestMs: 1,
261
+ applyMs: 0,
262
+ pulledDeleteTaskIds: new Set(),
263
+ pulledActiveUpserts: true,
264
+ pulledArchivedUpserts: false,
265
+ documentDecryptFailures: []
266
+ });
267
+ const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
268
+ initialProps: buildInput({ realtimeSyncEnabled: true })
269
+ });
270
+ act(() => {
271
+ result.current.applyWorkspaceSyncStateSnapshot({
272
+ enabled: true,
273
+ phase: 'active',
274
+ pullCursor: null
275
+ });
276
+ });
277
+ await act(async () => {
278
+ await Promise.resolve();
279
+ await Promise.resolve();
280
+ });
281
+ expect(result.current.workspaceRetryAt).not.toBeNull();
282
+ expect(MockWebSocket.instances).toHaveLength(1);
283
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
284
+ act(() => {
285
+ MockWebSocket.instances[0]?.open();
286
+ MockWebSocket.instances[0]?.message({
287
+ type: 'taskforce:update',
288
+ workspaceId: 'workspace-local-active'
289
+ });
290
+ });
291
+ await act(async () => {
292
+ await vi.advanceTimersByTimeAsync(300);
293
+ await Promise.resolve();
294
+ await Promise.resolve();
295
+ });
296
+ expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(2);
297
+ expect(result.current.workspaceRetryAt).toBeNull();
298
+ expect(result.current.workspaceLastPullAt).toBe('2026-03-15T18:05:00.000Z');
299
+ unmount();
300
+ });
209
301
  it('retries transient push failures automatically', async () => {
210
302
  runWorkspacePushSyncServiceMock
211
303
  .mockResolvedValueOnce({
@@ -152,10 +152,11 @@ export function useTaskData({ tasks, archivedTasks, setTasks, setArchivedTasks,
152
152
  console.error('[Taskforce] Failed to fetch tasks:', err);
153
153
  }
154
154
  finally {
155
- if (tasksAbortRef.current === abortController) {
155
+ const isCurrentRequest = tasksAbortRef.current === abortController;
156
+ if (isCurrentRequest) {
156
157
  tasksAbortRef.current = null;
157
158
  }
158
- if (!isSilent)
159
+ if (!isSilent && isCurrentRequest)
159
160
  setLoadingTasks(false);
160
161
  }
161
162
  }, [