@taskforcehq/taskforce 0.3.304 → 0.3.305

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 (138) hide show
  1. package/README.md +140 -247
  2. package/dist/TaskforceCore.js +165 -70
  3. package/dist/TaskforceCore.test.js +799 -90
  4. package/dist/compat/workspaceSyncCompat.js +6 -0
  5. package/dist/components/features/DocumentIndex.d.ts +1 -1
  6. package/dist/components/features/DocumentIndex.js +1 -1
  7. package/dist/components/features/DocumentViewer.d.ts +1 -1
  8. package/dist/components/features/DocumentViewer.js +32 -9
  9. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  10. package/dist/components/features/DocumentWorkspace.js +12 -155
  11. package/dist/components/features/DocumentWorkspace.test.js +193 -6
  12. package/dist/components/features/TaskSettings.js +29 -3
  13. package/dist/components/features/TaskSettings.test.js +42 -1
  14. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  15. package/dist/components/features/documentWorkspaceModel.js +57 -0
  16. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  17. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  18. package/dist/components/task/TaskForm.d.ts +8 -8
  19. package/dist/components/task/TaskForm.js +358 -221
  20. package/dist/components/task/TaskForm.test.js +456 -381
  21. package/dist/components/task/TaskKanban.js +2 -1
  22. package/dist/components/views/PlanComparisonPage.js +5 -8
  23. package/dist/components/views/PlanComparisonPage.test.js +51 -41
  24. package/dist/components/views/PlansPage.js +126 -84
  25. package/dist/components/views/PlansPage.test.js +308 -39
  26. package/dist/components/views/StandaloneLayout.js +28 -22
  27. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  28. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
  29. package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
  30. package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
  31. package/dist/config/envSchema.js +1 -1
  32. package/dist/core/AiProfileService.d.ts +6 -1
  33. package/dist/core/AiProfileService.js +161 -16
  34. package/dist/core/AiProfileService.test.js +3 -1
  35. package/dist/core/AiProfiles.test.js +200 -0
  36. package/dist/core/AuthTokenService.test.d.ts +1 -0
  37. package/dist/core/AuthTokenService.test.js +78 -0
  38. package/dist/core/EntitlementsPolicy.test.js +75 -13
  39. package/dist/core/PlanEntitlementService.d.ts +7 -1
  40. package/dist/core/PlanEntitlementService.js +117 -42
  41. package/dist/core/PlanVersionPolicy.test.js +19 -26
  42. package/dist/core/SignupMonetizationSettings.test.js +46 -21
  43. package/dist/core/SystemAdmin.test.js +212 -56
  44. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  45. package/dist/core/Taskforce.d.ts +3 -0
  46. package/dist/core/Taskforce.js +50 -10
  47. package/dist/core/WorkspacePlanMode.test.js +12 -12
  48. package/dist/core/shared.d.ts +2 -0
  49. package/dist/core/shared.js +11 -0
  50. package/dist/hooks/sync/auth.js +3 -1
  51. package/dist/hooks/sync/transfers.d.ts +2 -1
  52. package/dist/hooks/sync/transfers.js +8 -4
  53. package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
  54. package/dist/hooks/useSyncOrchestrator.js +75 -7
  55. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
  56. package/dist/hooks/useTaskMutations.d.ts +2 -1
  57. package/dist/hooks/useTaskMutations.js +12 -1
  58. package/dist/hooks/useTaskMutations.test.js +30 -1
  59. package/dist/hooks/useTaskforce.d.ts +5 -2
  60. package/dist/hooks/useTaskforce.js +17 -5
  61. package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
  62. package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
  63. package/dist/hooks/useWorkspaceSyncController.js +11 -1
  64. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  65. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  66. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  67. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  68. package/dist/mcp/collaborationRegistrar.js +71 -0
  69. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  70. package/dist/mcp/documentAssetRegistrar.js +346 -0
  71. package/dist/mcp/runtime.js +2362 -3530
  72. package/dist/mcp/runtime.test.js +159 -0
  73. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  74. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  75. package/dist/mcp/toolCatalog.d.ts +35 -0
  76. package/dist/mcp/toolCatalog.js +3 -0
  77. package/dist/server/routes/admin.d.ts +1 -5
  78. package/dist/server/routes/admin.js +138 -61
  79. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  80. package/dist/server/routes/annotatedAttachments.js +1 -1
  81. package/dist/server/routes/auth.d.ts +1 -4
  82. package/dist/server/routes/auth.js +23 -63
  83. package/dist/server/routes/authSupport.d.ts +30 -0
  84. package/dist/server/routes/authSupport.js +81 -0
  85. package/dist/server/routes/billing.d.ts +1 -1
  86. package/dist/server/routes/billing.js +104 -188
  87. package/dist/server/routes/billing.test.js +214 -86
  88. package/dist/server/routes/documentReviews.d.ts +1 -1
  89. package/dist/server/routes/documentReviews.js +1 -1
  90. package/dist/server/routes/documents.d.ts +4 -2
  91. package/dist/server/routes/documents.js +73 -7
  92. package/dist/server/routes/primitives.d.ts +11 -0
  93. package/dist/server/routes/primitives.js +73 -0
  94. package/dist/server/routes/resources.d.ts +1 -1
  95. package/dist/server/routes/resources.js +1 -1
  96. package/dist/server/routes/shared.d.ts +1 -1
  97. package/dist/server/routes/sync.d.ts +1 -1
  98. package/dist/server/routes/sync.js +1 -1
  99. package/dist/server/routes/tasks.d.ts +1 -1
  100. package/dist/server/routes/tasks.js +1 -1
  101. package/dist/server/routes/workspaces.d.ts +1 -1
  102. package/dist/server/routes/workspaces.js +1 -1
  103. package/dist/server/routes.d.ts +2 -9
  104. package/dist/server/routes.js +76 -162
  105. package/dist/server/routes.test.js +672 -30
  106. package/dist/sync/contentSyncState.js +16 -7
  107. package/dist/sync/syncApplyHandlers.test.js +2 -0
  108. package/dist/sync/syncService.d.ts +1 -0
  109. package/dist/sync/syncService.js +8 -0
  110. package/dist/sync/syncService.test.js +14 -0
  111. package/dist/sync/workspaceSyncModel.d.ts +3 -0
  112. package/dist/sync/workspaceSyncModel.js +12 -3
  113. package/dist/sync/workspaceSyncModel.test.js +34 -0
  114. package/dist/sync/workspaceSyncState.d.ts +2 -0
  115. package/dist/sync/workspaceSyncState.js +1 -0
  116. package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
  117. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
  118. package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
  119. package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
  120. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
  121. package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
  122. package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
  123. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
  124. package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
  125. package/dist/ui/assets/index-DcSI5F86.js +6 -0
  126. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
  127. package/dist/ui/index.html +3 -3
  128. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  129. package/dist/utils/commercialLifecyclePresentation.js +196 -0
  130. package/package.json +8 -1
  131. package/scripts/check-cloud-mcp.mjs +83 -0
  132. package/scripts/playwright-auth.sh +5 -1
  133. package/scripts/run-billing-performance-smoke.sh +24 -0
  134. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  135. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  136. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  137. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  138. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
@@ -26,7 +26,7 @@ const SPECS = [
26
26
  { key: 'TASKFORCE_BOOTSTRAP_ADMIN_EMAIL', description: 'Bootstrap admin email used by login fallback.', category: 'Auth', targets: ['server', 'all'] },
27
27
  { key: 'TASKFORCE_BOOTSTRAP_ADMIN_PASSWORD', description: 'Bootstrap admin password used by login fallback.', category: 'Auth', secret: true, targets: ['server', 'all'] },
28
28
  { key: 'TASKFORCE_AUTH_PUBLIC_BASE_URL', description: 'Public auth URL used in email links.', category: 'Auth', example: 'https://performance-app.taskforcehq.ai', targets: ['server', 'all'] },
29
- { key: 'TASKFORCE_AUTH_EXPOSE_DEV_TOKENS', description: 'Expose dev auth tokens in responses.', category: 'Auth', defaultValue: 'false', targets: ['server', 'all'] },
29
+ { key: 'TASKFORCE_AUTH_EXPOSE_DEV_TOKENS', description: 'Explicitly expose auth tokens in non-production responses for test automation.', category: 'Auth', defaultValue: 'false', targets: ['server', 'all'] },
30
30
  { key: 'TASKFORCE_CLOUD_PROXY_BASE_URL', description: 'Optional upstream cloud base URL for local proxying of auth/sync API calls.', category: 'Auth', example: 'https://performance-app.taskforcehq.ai', targets: ['server', 'all'] },
31
31
  { key: 'TASKFORCE_CF_ACCESS_CLIENT_ID', description: 'Optional Cloudflare Access service token client id for local cloud proxy.', category: 'Cloudflare', secret: true, targets: ['server', 'all'] },
32
32
  { key: 'TASKFORCE_CF_ACCESS_CLIENT_SECRET', description: 'Optional Cloudflare Access service token client secret for local cloud proxy.', category: 'Cloudflare', secret: true, targets: ['server', 'all'] },
@@ -50,7 +50,12 @@ export interface AiProfileServiceDeps {
50
50
  aiProfileRowToRecord: (row: Record<string, unknown>) => AiProfileRecordLike;
51
51
  generateEntityId: (prefix: string) => string;
52
52
  issueProfileToken: () => string;
53
- createRuleError: (message: string, statusCode?: number, code?: string) => Error;
53
+ countAiProfileUsage: (workspaceId: string) => {
54
+ used: number;
55
+ limit: number | null;
56
+ remaining: number | null;
57
+ };
58
+ createRuleError: (message: string, statusCode?: number, code?: string, details?: unknown) => Error;
54
59
  }
55
60
  export declare function listAiProfilesService(deps: AiProfileServiceDeps, workspaceId: string): AiProfileRecordLike[];
56
61
  export declare function getAiProfileByTokenService(deps: AiProfileServiceDeps, input: {
@@ -23,6 +23,112 @@ function normalizeAiProfileSurfaceTypeInput(deps, value) {
23
23
  }
24
24
  return normalized;
25
25
  }
26
+ function inferAiProfileProviderModelDefaults(name) {
27
+ const normalized = String(name || '').trim().toLowerCase();
28
+ if (!normalized)
29
+ return {};
30
+ if (normalized.includes('claude code'))
31
+ return { provider: 'Anthropic', model: 'Claude Code' };
32
+ if (normalized.includes('claude'))
33
+ return { provider: 'Anthropic', model: 'Claude' };
34
+ if (normalized.includes('chatgpt'))
35
+ return { provider: 'OpenAI', model: 'ChatGPT' };
36
+ if (normalized.includes('codex'))
37
+ return { provider: 'OpenAI', model: 'Codex' };
38
+ if (normalized.includes('gemini'))
39
+ return { provider: 'Google', model: 'Gemini' };
40
+ return {};
41
+ }
42
+ function inferAiProfileMetadataDefaults(name, surfaceType) {
43
+ const inferredProviderModel = inferAiProfileProviderModelDefaults(name);
44
+ switch (surfaceType) {
45
+ case 'agent':
46
+ return {
47
+ description: `${name} AI collaborator registered in Taskforce.`,
48
+ role: 'AI Collaborator',
49
+ ...inferredProviderModel
50
+ };
51
+ case 'coding_tool':
52
+ return {
53
+ description: `${name} coding agent connected through MCP.`,
54
+ role: 'Coding Agent',
55
+ ...inferredProviderModel
56
+ };
57
+ case 'chat_app':
58
+ return {
59
+ description: `${name} conversational AI connected through Taskforce.`,
60
+ role: 'AI Assistant',
61
+ ...inferredProviderModel
62
+ };
63
+ case 'ide':
64
+ return {
65
+ description: `${name} IDE-connected AI assistant.`,
66
+ role: 'Coding Assistant',
67
+ ...inferredProviderModel
68
+ };
69
+ case 'other':
70
+ return {
71
+ description: `${name} AI profile connected through Taskforce.`,
72
+ role: 'AI Assistant',
73
+ ...inferredProviderModel
74
+ };
75
+ default:
76
+ return {};
77
+ }
78
+ }
79
+ function resolveOptionalProfileField(inputValue, existingValue, fallbackValue) {
80
+ const explicitValue = normalizeOptionalProfileField(inputValue);
81
+ if (explicitValue !== undefined)
82
+ return explicitValue;
83
+ const preservedValue = normalizeOptionalProfileField(existingValue);
84
+ if (preservedValue !== undefined)
85
+ return preservedValue;
86
+ return normalizeOptionalProfileField(fallbackValue);
87
+ }
88
+ function resolveAiProfileMetadataFields(deps, input, existing) {
89
+ const surfaceTypeInput = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
90
+ const effectiveSurfaceType = surfaceTypeInput === undefined
91
+ ? existing?.surfaceType ?? null
92
+ : surfaceTypeInput;
93
+ const inferredDefaults = inferAiProfileMetadataDefaults(input.name, effectiveSurfaceType);
94
+ return {
95
+ description: resolveOptionalProfileField(input.description, existing?.description, inferredDefaults.description) ?? null,
96
+ role: resolveOptionalProfileField(input.role, existing?.role, inferredDefaults.role) ?? null,
97
+ provider: resolveOptionalProfileField(input.provider, existing?.provider, inferredDefaults.provider) ?? null,
98
+ model: resolveOptionalProfileField(input.model, existing?.model, inferredDefaults.model) ?? null,
99
+ surfaceType: effectiveSurfaceType,
100
+ surfaceTypeProvided: surfaceTypeInput !== undefined,
101
+ };
102
+ }
103
+ function applyAiProfileMetadataDefaults(record) {
104
+ const inferredDefaults = inferAiProfileMetadataDefaults(record.name, record.surfaceType);
105
+ return {
106
+ ...record,
107
+ description: resolveOptionalProfileField(undefined, record.description, inferredDefaults.description) ?? null,
108
+ role: resolveOptionalProfileField(undefined, record.role, inferredDefaults.role) ?? null,
109
+ provider: resolveOptionalProfileField(undefined, record.provider, inferredDefaults.provider) ?? null,
110
+ model: resolveOptionalProfileField(undefined, record.model, inferredDefaults.model) ?? null,
111
+ };
112
+ }
113
+ function hydrateAiProfileRecord(deps, row) {
114
+ const record = deps.aiProfileRowToRecord(row);
115
+ const hydrated = applyAiProfileMetadataDefaults(record);
116
+ const metadataChanged = record.description !== hydrated.description
117
+ || record.role !== hydrated.role
118
+ || record.provider !== hydrated.provider
119
+ || record.model !== hydrated.model;
120
+ if (metadataChanged) {
121
+ deps.db.prepare(`
122
+ UPDATE ai_profiles
123
+ SET description = ?,
124
+ role = ?,
125
+ provider = ?,
126
+ model = ?
127
+ WHERE tenant_id = ? AND id = ?
128
+ `).run(hydrated.description ?? null, hydrated.role ?? null, hydrated.provider ?? null, hydrated.model ?? null, deps.tenantId, record.id);
129
+ }
130
+ return hydrated;
131
+ }
26
132
  export function listAiProfilesService(deps, workspaceId) {
27
133
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
28
134
  const rows = deps.db.prepare(`
@@ -48,7 +154,7 @@ export function listAiProfilesService(deps, workspaceId) {
48
154
  AND workspace_id = ?
49
155
  ORDER BY COALESCE(updated_at, created_at) DESC, LOWER(name) ASC
50
156
  `).all(deps.tenantId, normalizedWorkspaceId);
51
- return rows.map((row) => deps.aiProfileRowToRecord(row));
157
+ return rows.map((row) => hydrateAiProfileRecord(deps, row));
52
158
  }
53
159
  export function getAiProfileByTokenService(deps, input) {
54
160
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
@@ -63,7 +169,7 @@ export function getAiProfileByTokenService(deps, input) {
63
169
  AND profile_token = ?
64
170
  LIMIT 1
65
171
  `).get(deps.tenantId, normalizedWorkspaceId, profileToken);
66
- return row ? deps.aiProfileRowToRecord(row) : null;
172
+ return row ? hydrateAiProfileRecord(deps, row) : null;
67
173
  }
68
174
  export function upsertAiProfileForSyncService(deps, profile) {
69
175
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(profile.workspaceId);
@@ -77,7 +183,6 @@ export function upsertAiProfileForSyncService(deps, profile) {
77
183
  const normalizedLastActiveAt = typeof profile.lastActiveAt === 'string' && profile.lastActiveAt.trim().length > 0
78
184
  ? profile.lastActiveAt.trim()
79
185
  : null;
80
- const normalizedSurfaceType = normalizeAiProfileSurfaceTypeInput(deps, profile.surfaceType);
81
186
  const existingRow = deps.db.prepare(`
82
187
  SELECT *
83
188
  FROM ai_profiles
@@ -87,9 +192,18 @@ export function upsertAiProfileForSyncService(deps, profile) {
87
192
  if (existingRow) {
88
193
  const existingUpdatedAt = String(existingRow.updated_at || existingRow.created_at || '').trim();
89
194
  if (existingUpdatedAt && existingUpdatedAt > normalizedUpdatedAt) {
90
- return deps.aiProfileRowToRecord(existingRow);
195
+ return hydrateAiProfileRecord(deps, existingRow);
91
196
  }
92
197
  }
198
+ const existingProfile = existingRow ? deps.aiProfileRowToRecord(existingRow) : null;
199
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
200
+ name: profile.name,
201
+ description: profile.description,
202
+ role: profile.role,
203
+ provider: profile.provider,
204
+ model: profile.model,
205
+ surfaceType: profile.surfaceType,
206
+ }, existingProfile);
93
207
  deps.db.prepare(`
94
208
  INSERT INTO ai_profiles (
95
209
  id, tenant_id, workspace_id, profile_token, name, username, icon, color,
@@ -111,7 +225,7 @@ export function upsertAiProfileForSyncService(deps, profile) {
111
225
  created_at = excluded.created_at,
112
226
  updated_at = excluded.updated_at,
113
227
  last_active_at = excluded.last_active_at
114
- `).run(profileId, deps.tenantId, normalizedWorkspaceId, String(profile.profileToken || '').trim(), deps.normalizeAiProfileName(profile.name), deps.normalizeAiProfileUsername(profile.username, profile.name), deps.normalizeAiProfileIcon(profile.icon), deps.normalizeAiProfileColor(profile.color), normalizeOptionalProfileField(profile.description) ?? null, normalizeOptionalProfileField(profile.role) ?? null, normalizeOptionalProfileField(profile.provider) ?? null, normalizeOptionalProfileField(profile.model) ?? null, normalizedSurfaceType, profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
228
+ `).run(profileId, deps.tenantId, normalizedWorkspaceId, String(profile.profileToken || '').trim(), deps.normalizeAiProfileName(profile.name), deps.normalizeAiProfileUsername(profile.username, profile.name), deps.normalizeAiProfileIcon(profile.icon), deps.normalizeAiProfileColor(profile.color), resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
115
229
  ? JSON.stringify(profile.providerMetadata)
116
230
  : null, normalizedCreatedAt, normalizedUpdatedAt, normalizedLastActiveAt);
117
231
  const refreshed = deps.db.prepare(`
@@ -123,7 +237,7 @@ export function upsertAiProfileForSyncService(deps, profile) {
123
237
  if (!refreshed) {
124
238
  throw deps.createRuleError('Failed to upsert AI profile during sync', 500, 'AI_PROFILE_SYNC_UPSERT_FAILED');
125
239
  }
126
- return deps.aiProfileRowToRecord(refreshed);
240
+ return hydrateAiProfileRecord(deps, refreshed);
127
241
  }
128
242
  export function mergeAiProfilesService(deps, input) {
129
243
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
@@ -165,7 +279,7 @@ export function mergeAiProfilesService(deps, input) {
165
279
  const refreshed = deps.db.prepare(`
166
280
  SELECT * FROM ai_profiles WHERE tenant_id = ? AND id = ? LIMIT 1
167
281
  `).get(deps.tenantId, keepId);
168
- return deps.aiProfileRowToRecord(refreshed);
282
+ return hydrateAiProfileRecord(deps, refreshed);
169
283
  }
170
284
  export function resolveAiProfileService(deps, input) {
171
285
  const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
@@ -197,7 +311,14 @@ export function resolveAiProfileService(deps, input) {
197
311
  const nextRole = normalizeOptionalProfileField(input.role);
198
312
  const nextProvider = normalizeOptionalProfileField(input.provider);
199
313
  const nextModel = normalizeOptionalProfileField(input.model);
200
- const nextSurfaceType = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
314
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
315
+ name: nextName,
316
+ description: nextDescription,
317
+ role: nextRole,
318
+ provider: nextProvider,
319
+ model: nextModel,
320
+ surfaceType: input.surfaceType,
321
+ }, existingProfile);
201
322
  deps.db.prepare(`
202
323
  UPDATE ai_profiles
203
324
  SET name = ?,
@@ -215,7 +336,7 @@ export function resolveAiProfileService(deps, input) {
215
336
  last_active_at = ?,
216
337
  updated_at = ?
217
338
  WHERE tenant_id = ? AND id = ?
218
- `).run(nextName, nextUsername, nextIcon, nextColor, nextDescription ?? null, nextRole ?? null, nextProvider ?? null, nextModel ?? null, nextSurfaceType !== undefined ? 1 : 0, nextSurfaceType, now, now, deps.tenantId, String(existingRow.id || '').trim());
339
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, now, now, deps.tenantId, String(existingRow.id || '').trim());
219
340
  const refreshed = deps.db.prepare(`
220
341
  SELECT *
221
342
  FROM ai_profiles
@@ -227,7 +348,7 @@ export function resolveAiProfileService(deps, input) {
227
348
  }
228
349
  return {
229
350
  created: false,
230
- profile: deps.aiProfileRowToRecord(refreshed)
351
+ profile: hydrateAiProfileRecord(deps, refreshed)
231
352
  };
232
353
  }
233
354
  const exactMatches = deps.db.prepare(`
@@ -251,7 +372,15 @@ export function resolveAiProfileService(deps, input) {
251
372
  const nextRole = normalizeOptionalProfileField(input.role);
252
373
  const nextProvider = normalizeOptionalProfileField(input.provider);
253
374
  const nextModel = normalizeOptionalProfileField(input.model);
254
- const nextSurfaceType = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
375
+ const existingProfile = deps.aiProfileRowToRecord(exactMatches[0]);
376
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
377
+ name: nextName,
378
+ description: nextDescription,
379
+ role: nextRole,
380
+ provider: nextProvider,
381
+ model: nextModel,
382
+ surfaceType: input.surfaceType,
383
+ }, existingProfile);
255
384
  deps.db.prepare(`
256
385
  UPDATE ai_profiles
257
386
  SET name = ?,
@@ -269,7 +398,7 @@ export function resolveAiProfileService(deps, input) {
269
398
  ELSE surface_type
270
399
  END
271
400
  WHERE tenant_id = ? AND id = ?
272
- `).run(nextName, nextUsername, nextIcon, nextColor, nextDescription ?? null, nextRole ?? null, nextProvider ?? null, nextModel ?? null, now, now, nextSurfaceType !== undefined ? 1 : 0, nextSurfaceType, deps.tenantId, canonicalId);
401
+ `).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, now, now, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, deps.tenantId, canonicalId);
273
402
  const reused = deps.db.prepare(`
274
403
  SELECT *
275
404
  FROM ai_profiles
@@ -281,7 +410,7 @@ export function resolveAiProfileService(deps, input) {
281
410
  }
282
411
  return {
283
412
  created: false,
284
- profile: deps.aiProfileRowToRecord(reused),
413
+ profile: hydrateAiProfileRecord(deps, reused),
285
414
  ...(exactMatches.length > 1 ? {
286
415
  duplicateWarning: {
287
416
  existingProfileId: canonicalId,
@@ -290,6 +419,15 @@ export function resolveAiProfileService(deps, input) {
290
419
  } : {})
291
420
  };
292
421
  }
422
+ const aiProfileUsage = deps.countAiProfileUsage(normalizedWorkspaceId);
423
+ if (aiProfileUsage.limit !== null && aiProfileUsage.used >= aiProfileUsage.limit) {
424
+ throw deps.createRuleError('No AI seats are available for this workspace. Remove an existing AI profile or upgrade your plan.', 403, 'AI_PROFILE_LIMIT_REACHED', {
425
+ workspaceId: normalizedWorkspaceId,
426
+ limit: aiProfileUsage.limit,
427
+ used: aiProfileUsage.used,
428
+ remaining: aiProfileUsage.remaining
429
+ });
430
+ }
293
431
  const id = deps.generateEntityId('ai-profile');
294
432
  const issuedProfileToken = deps.issueProfileToken();
295
433
  const username = deps.normalizeAiProfileUsername(undefined, name);
@@ -299,7 +437,14 @@ export function resolveAiProfileService(deps, input) {
299
437
  const role = normalizeOptionalProfileField(input.role);
300
438
  const provider = normalizeOptionalProfileField(input.provider);
301
439
  const model = normalizeOptionalProfileField(input.model);
302
- const surfaceType = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
440
+ const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
441
+ name,
442
+ description,
443
+ role,
444
+ provider,
445
+ model,
446
+ surfaceType: input.surfaceType,
447
+ }, null);
303
448
  const providerMetadata = input.providerMetadata && typeof input.providerMetadata === 'object'
304
449
  ? input.providerMetadata
305
450
  : null;
@@ -308,7 +453,7 @@ export function resolveAiProfileService(deps, input) {
308
453
  id, tenant_id, workspace_id, profile_token, name, username, icon, color,
309
454
  description, role, provider, model, surface_type, provider_metadata_json, created_at, updated_at, last_active_at
310
455
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
311
- `).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, description ?? null, role ?? null, provider ?? null, model ?? null, surfaceType, providerMetadata ? JSON.stringify(providerMetadata) : null, now, now, now);
456
+ `).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, providerMetadata ? JSON.stringify(providerMetadata) : null, now, now, now);
312
457
  const created = deps.db.prepare(`
313
458
  SELECT *
314
459
  FROM ai_profiles
@@ -320,6 +465,6 @@ export function resolveAiProfileService(deps, input) {
320
465
  }
321
466
  return {
322
467
  created: true,
323
- profile: deps.aiProfileRowToRecord(created),
468
+ profile: hydrateAiProfileRecord(deps, created),
324
469
  };
325
470
  }
@@ -56,10 +56,12 @@ function createDeps(overrides) {
56
56
  }),
57
57
  generateEntityId: (prefix) => `${prefix}-1`,
58
58
  issueProfileToken: () => 'tfp_test',
59
- createRuleError: (message, statusCode = 400, code = 'TASK_RULE_VIOLATION') => {
59
+ countAiProfileUsage: () => ({ used: 0, limit: null, remaining: null }),
60
+ createRuleError: (message, statusCode = 400, code = 'TASK_RULE_VIOLATION', details) => {
60
61
  const error = new Error(message);
61
62
  error.statusCode = statusCode;
62
63
  error.code = code;
64
+ error.details = details;
63
65
  return error;
64
66
  },
65
67
  ...overrides
@@ -10,6 +10,28 @@ function createProjectRoot(prefix = 'taskforce-ai-profiles-') {
10
10
  return root;
11
11
  }
12
12
  const createdRoots = [];
13
+ function enableAiProfileSeatLimit(core, workspaceId, userId, limit) {
14
+ core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
15
+ core.ensureBootstrapUserAccess({
16
+ userId,
17
+ workspaceId,
18
+ role: 'owner',
19
+ email: `${userId}@example.com`
20
+ });
21
+ core.updatePlanVersionFeatures({
22
+ planVersionId: 'free-v1',
23
+ features: [
24
+ { featureKey: 'collaboration.ai_profiles', access: 'limited', config: { maxAiProfiles: limit } }
25
+ ]
26
+ });
27
+ core.updateAccountEntitlement({
28
+ accountId: userId,
29
+ planVersionId: 'free-v1',
30
+ entitlementState: 'active',
31
+ actorUserId: 'system-admin',
32
+ actorRole: 'system_admin'
33
+ });
34
+ }
13
35
  afterEach(() => {
14
36
  while (createdRoots.length > 0) {
15
37
  const root = createdRoots.pop();
@@ -103,6 +125,125 @@ describe('TaskforceCore ai profiles', () => {
103
125
  expect(second.profile.surfaceType).toBe('coding_tool');
104
126
  expect(second.profile.color).toBe('#0ea5e9');
105
127
  });
128
+ it('applies default metadata for named agent profiles when optional fields are omitted', () => {
129
+ const projectRoot = createProjectRoot();
130
+ createdRoots.push(projectRoot);
131
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
132
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
133
+ const resolved = core.resolveAiProfile({
134
+ workspaceId: 'workspace-1',
135
+ name: 'John Drake',
136
+ surfaceType: 'agent'
137
+ });
138
+ expect(resolved.profile.description).toBe('John Drake AI collaborator registered in Taskforce.');
139
+ expect(resolved.profile.role).toBe('AI Collaborator');
140
+ expect(resolved.profile.provider).toBeNull();
141
+ expect(resolved.profile.model).toBeNull();
142
+ expect(resolved.profile.surfaceType).toBe('agent');
143
+ });
144
+ it('self-heals sparse named agent profiles on re-resolve', () => {
145
+ const projectRoot = createProjectRoot();
146
+ createdRoots.push(projectRoot);
147
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
148
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
149
+ const first = core.resolveAiProfile({
150
+ workspaceId: 'workspace-1',
151
+ name: 'John Drake'
152
+ });
153
+ expect(first.profile.description).toBeNull();
154
+ expect(first.profile.role).toBeNull();
155
+ const second = core.resolveAiProfile({
156
+ workspaceId: 'workspace-1',
157
+ name: 'John Drake',
158
+ surfaceType: 'agent',
159
+ profileToken: first.profile.profileToken
160
+ });
161
+ expect(second.profile.id).toBe(first.profile.id);
162
+ expect(second.profile.description).toBe('John Drake AI collaborator registered in Taskforce.');
163
+ expect(second.profile.role).toBe('AI Collaborator');
164
+ expect(second.profile.provider).toBeNull();
165
+ expect(second.profile.model).toBeNull();
166
+ expect(second.profile.surfaceType).toBe('agent');
167
+ });
168
+ it('durably repairs sparse named agent profiles when listing profiles', () => {
169
+ const projectRoot = createProjectRoot();
170
+ createdRoots.push(projectRoot);
171
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
172
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
173
+ const first = core.resolveAiProfile({
174
+ workspaceId: 'workspace-1',
175
+ name: 'John Drake'
176
+ });
177
+ expect(first.profile.description).toBeNull();
178
+ expect(first.profile.role).toBeNull();
179
+ core.resolveAiProfile({
180
+ workspaceId: 'workspace-1',
181
+ name: 'John Drake',
182
+ surfaceType: 'agent',
183
+ profileToken: first.profile.profileToken
184
+ });
185
+ core.db.prepare(`
186
+ UPDATE ai_profiles
187
+ SET description = NULL, role = NULL, provider = NULL, model = NULL
188
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
189
+ `).run('default', 'workspace-1', first.profile.id);
190
+ const listed = core.listAiProfiles('workspace-1')[0];
191
+ expect(listed.description).toBe('John Drake AI collaborator registered in Taskforce.');
192
+ expect(listed.role).toBe('AI Collaborator');
193
+ expect(listed.provider).toBeNull();
194
+ expect(listed.model).toBeNull();
195
+ const repaired = core.db.prepare(`
196
+ SELECT description, role, provider, model
197
+ FROM ai_profiles
198
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
199
+ LIMIT 1
200
+ `).get('default', 'workspace-1', first.profile.id);
201
+ expect(repaired).toEqual({
202
+ description: 'John Drake AI collaborator registered in Taskforce.',
203
+ role: 'AI Collaborator',
204
+ provider: null,
205
+ model: null
206
+ });
207
+ });
208
+ it('durably repairs sparse coding-tool profiles when resolving by token lookup', () => {
209
+ const projectRoot = createProjectRoot();
210
+ createdRoots.push(projectRoot);
211
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
212
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
213
+ const resolved = core.resolveAiProfile({
214
+ workspaceId: 'workspace-1',
215
+ name: 'Codex',
216
+ surfaceType: 'coding_tool'
217
+ });
218
+ core.db.prepare(`
219
+ UPDATE ai_profiles
220
+ SET description = NULL, role = NULL, provider = NULL, model = NULL
221
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
222
+ `).run('default', 'workspace-1', resolved.profile.id);
223
+ const fetched = core.getAiProfileByToken({
224
+ workspaceId: 'workspace-1',
225
+ profileToken: resolved.profile.profileToken
226
+ });
227
+ expect(fetched).toEqual(expect.objectContaining({
228
+ description: 'Codex coding agent connected through MCP.',
229
+ role: 'Coding Agent',
230
+ provider: 'OpenAI',
231
+ model: 'Codex',
232
+ surfaceType: 'coding_tool'
233
+ }));
234
+ const repaired = core.db.prepare(`
235
+ SELECT description, role, provider, model
236
+ FROM ai_profiles
237
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
238
+ LIMIT 1
239
+ `).get('default', 'workspace-1', resolved.profile.id);
240
+ expect(repaired).toEqual({
241
+ description: 'Codex coding agent connected through MCP.',
242
+ role: 'Coding Agent',
243
+ provider: 'OpenAI',
244
+ model: 'Codex'
245
+ });
246
+ });
106
247
  it('ignores empty-string optional ai profile updates instead of clearing stored values', () => {
107
248
  const projectRoot = createProjectRoot();
108
249
  createdRoots.push(projectRoot);
@@ -368,6 +509,65 @@ describe('TaskforceCore ai profiles', () => {
368
509
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
369
510
  expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
370
511
  });
512
+ it('allows token-based re-resolve even when the ai profile limit is full', () => {
513
+ const projectRoot = createProjectRoot();
514
+ createdRoots.push(projectRoot);
515
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
516
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
517
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
518
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
519
+ const second = core.resolveAiProfile({
520
+ workspaceId: 'workspace-1',
521
+ name: 'Codex Prime',
522
+ icon: 'Sparkles',
523
+ profileToken: first.profile.profileToken
524
+ });
525
+ expect(second.created).toBe(false);
526
+ expect(second.profile.id).toBe(first.profile.id);
527
+ expect(second.profile.name).toBe('Codex Prime');
528
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
529
+ });
530
+ it('reuses an exact-match ai profile even when the ai profile limit is full', () => {
531
+ const projectRoot = createProjectRoot();
532
+ createdRoots.push(projectRoot);
533
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
534
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
535
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
536
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' });
537
+ const second = core.resolveAiProfile({
538
+ workspaceId: 'workspace-1',
539
+ name: 'Automation Codex',
540
+ role: 'Assistant'
541
+ });
542
+ expect(second.created).toBe(false);
543
+ expect(second.profile.id).toBe(first.profile.id);
544
+ expect(second.profile.role).toBe('Assistant');
545
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
546
+ });
547
+ it('blocks net-new ai profile creation when the ai profile limit is full', () => {
548
+ const projectRoot = createProjectRoot();
549
+ createdRoots.push(projectRoot);
550
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
551
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
552
+ enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
553
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
554
+ expect(first.created).toBe(true);
555
+ try {
556
+ core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude' });
557
+ throw new Error('Expected AI profile creation to be blocked');
558
+ }
559
+ catch (error) {
560
+ expect(error.message).toContain('No AI seats are available for this workspace');
561
+ expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
562
+ expect(error.statusCode).toBe(403);
563
+ expect(error.details).toEqual({
564
+ workspaceId: 'workspace-1',
565
+ limit: 1,
566
+ used: 1,
567
+ remaining: 0
568
+ });
569
+ }
570
+ });
371
571
  it('merges two duplicate profiles, reassigning task assignees and comment authors', () => {
372
572
  const projectRoot = createProjectRoot();
373
573
  createdRoots.push(projectRoot);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { TaskforceCore } from './Taskforce';
6
+ function makeCore() {
7
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-auth-token-test-'));
8
+ const core = new TaskforceCore({
9
+ projectRoot,
10
+ storagePath: path.join(projectRoot, '.taskforce')
11
+ });
12
+ core.updateGlobalSettings({
13
+ billing: {
14
+ onboardingPolicy: {
15
+ mode: 'require_plan_selection',
16
+ planVersionId: null
17
+ }
18
+ }
19
+ });
20
+ core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
21
+ return {
22
+ core,
23
+ cleanup: () => {
24
+ core.close();
25
+ fs.rmSync(projectRoot, { recursive: true, force: true });
26
+ }
27
+ };
28
+ }
29
+ describe('Auth token lifecycle', () => {
30
+ it('keeps previously issued verification tokens valid until they are used or expire', () => {
31
+ const { core, cleanup } = makeCore();
32
+ try {
33
+ core.registerPasswordUser({
34
+ email: 'verify@example.com',
35
+ password: 'password123',
36
+ displayName: 'Verify User',
37
+ betaAccess: true
38
+ });
39
+ const first = String(core.requestEmailVerification({ email: 'verify@example.com' }).token || '');
40
+ const second = String(core.requestEmailVerification({ email: 'verify@example.com' }).token || '');
41
+ expect(first).toMatch(/^[a-f0-9]{48}$/);
42
+ expect(second).toMatch(/^[a-f0-9]{48}$/);
43
+ expect(first).not.toBe(second);
44
+ expect(core.inspectOneTimeAuthToken(first, 'email_verification').state).toBe('valid');
45
+ expect(core.inspectOneTimeAuthToken(second, 'email_verification').state).toBe('valid');
46
+ expect(core.verifyEmailWithToken(first)).toBe(true);
47
+ expect(core.inspectOneTimeAuthToken(first, 'email_verification').state).toBe('used');
48
+ expect(core.inspectOneTimeAuthToken(second, 'email_verification').state).toBe('valid');
49
+ }
50
+ finally {
51
+ cleanup();
52
+ }
53
+ });
54
+ it('keeps previously issued password reset tokens valid until they are used or expire', () => {
55
+ const { core, cleanup } = makeCore();
56
+ try {
57
+ core.registerPasswordUser({
58
+ email: 'reset@example.com',
59
+ password: 'password123',
60
+ displayName: 'Reset User',
61
+ betaAccess: true
62
+ });
63
+ const first = String(core.requestPasswordReset({ email: 'reset@example.com' }).token || '');
64
+ const second = String(core.requestPasswordReset({ email: 'reset@example.com' }).token || '');
65
+ expect(first).toMatch(/^[a-f0-9]{48}$/);
66
+ expect(second).toMatch(/^[a-f0-9]{48}$/);
67
+ expect(first).not.toBe(second);
68
+ expect(core.inspectOneTimeAuthToken(first, 'password_reset').state).toBe('valid');
69
+ expect(core.inspectOneTimeAuthToken(second, 'password_reset').state).toBe('valid');
70
+ expect(core.resetPasswordWithToken({ token: first, password: 'UpdatedPass456!' })).toBe(true);
71
+ expect(core.inspectOneTimeAuthToken(first, 'password_reset').state).toBe('used');
72
+ expect(core.inspectOneTimeAuthToken(second, 'password_reset').state).toBe('valid');
73
+ }
74
+ finally {
75
+ cleanup();
76
+ }
77
+ });
78
+ });