@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
@@ -0,0 +1,319 @@
1
+ import { createHash } from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { normalizeTaskDataRelativePath } from '../storage/objectStorageClient.js';
5
+ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
6
+ 'image/gif',
7
+ 'image/jpeg',
8
+ 'image/png',
9
+ 'image/webp',
10
+ ]);
11
+ const AVATAR_MAX_BYTES = 5 * 1024 * 1024;
12
+ function normalizeId(value) {
13
+ return String(value || '').trim();
14
+ }
15
+ function sanitizeStorageSegment(value, fallback) {
16
+ const normalized = normalizeId(value)
17
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
18
+ .replace(/-+/g, '-')
19
+ .replace(/^-+|-+$/g, '');
20
+ return normalized || fallback;
21
+ }
22
+ function normalizeImageMimeType(value) {
23
+ return normalizeId(value).toLowerCase();
24
+ }
25
+ function extensionForMimeType(mimeType) {
26
+ if (mimeType === 'image/gif')
27
+ return 'gif';
28
+ if (mimeType === 'image/jpeg')
29
+ return 'jpg';
30
+ if (mimeType === 'image/png')
31
+ return 'png';
32
+ return 'webp';
33
+ }
34
+ function stableAssetId(workspaceId, profileId, slot) {
35
+ const digest = createHash('sha256')
36
+ .update(`${workspaceId}:${profileId}:${slot}`)
37
+ .digest('hex')
38
+ .slice(0, 32);
39
+ return `asset-${digest}`;
40
+ }
41
+ export function buildAiProfileAvatarStorageKey(input) {
42
+ const workspaceId = sanitizeStorageSegment(input.workspaceId, 'default');
43
+ const profileId = sanitizeStorageSegment(input.profileId, 'ai-profile');
44
+ const ext = extensionForMimeType(input.mimeType);
45
+ return `workspaces/${workspaceId}/assets/images/ai-profiles/${profileId}/avatar/${input.slot}.${ext}`;
46
+ }
47
+ function contextUrlForStorageKey(storageKey) {
48
+ return `/api/taskforce/context/${encodeURIComponent(storageKey)}`;
49
+ }
50
+ export function storageKeyFromContextUrl(url) {
51
+ const raw = normalizeId(url);
52
+ if (!raw)
53
+ return null;
54
+ const contextPrefix = '/api/taskforce/context/';
55
+ if (!raw.startsWith(contextPrefix))
56
+ return null;
57
+ const encodedPath = raw.slice(contextPrefix.length).split('?')[0] || '';
58
+ try {
59
+ return normalizeTaskDataRelativePath(decodeURIComponent(encodedPath));
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ function validatePayload(payload, label) {
66
+ const mimeType = normalizeImageMimeType(payload.mimeType);
67
+ if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) {
68
+ throw new Error(`${label} must be a PNG, JPEG, WebP, or GIF image.`);
69
+ }
70
+ if (!Buffer.isBuffer(payload.buffer) || payload.buffer.length === 0) {
71
+ throw new Error(`${label} is empty.`);
72
+ }
73
+ if (payload.buffer.length > AVATAR_MAX_BYTES) {
74
+ throw new Error(`${label} must be 5 MB or smaller.`);
75
+ }
76
+ return mimeType;
77
+ }
78
+ function storageKeyBelongsToAiProfileAvatar(workspaceId, profileId, storageKey) {
79
+ const workspaceIdSafe = sanitizeStorageSegment(workspaceId, 'default');
80
+ const profileIdSafe = sanitizeStorageSegment(profileId, 'ai-profile');
81
+ return storageKey.startsWith(`workspaces/${workspaceIdSafe}/assets/images/ai-profiles/${profileIdSafe}/avatar/`);
82
+ }
83
+ async function writeAvatarObject(deps, storageKey, payload, provider) {
84
+ if (provider === 'r2') {
85
+ const objectStorageClient = deps.getObjectStorageClient();
86
+ if (!objectStorageClient)
87
+ throw new Error('Object storage is not configured.');
88
+ await objectStorageClient.putObject(storageKey, payload.buffer, payload.mimeType);
89
+ return;
90
+ }
91
+ const { basePath } = deps.core.getPaths();
92
+ const destination = deps.ensureWithinDir(path.join(basePath, storageKey), basePath);
93
+ if (!destination)
94
+ throw new Error('Invalid AI profile avatar storage path.');
95
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
96
+ fs.writeFileSync(destination, payload.buffer);
97
+ }
98
+ async function deleteAvatarObject(deps, storageKey, provider) {
99
+ try {
100
+ deps.markAssetDeleted(storageKey);
101
+ }
102
+ catch {
103
+ // Best-effort legacy asset index cleanup should not make the canonical
104
+ // workspace asset/profile update fail after the new slots are written.
105
+ }
106
+ if (provider === 'r2') {
107
+ const objectStorageClient = deps.getObjectStorageClient();
108
+ if (objectStorageClient) {
109
+ await objectStorageClient.deleteObject(storageKey).catch(() => false);
110
+ }
111
+ return;
112
+ }
113
+ const { basePath } = deps.core.getPaths();
114
+ const destination = deps.ensureWithinDir(path.join(basePath, storageKey), basePath);
115
+ if (destination && fs.existsSync(destination)) {
116
+ fs.rmSync(destination, { force: true });
117
+ }
118
+ }
119
+ function upsertAvatarAsset(input) {
120
+ if (!input.deps.workspaceAssetStore) {
121
+ throw new Error('Workspace asset storage is required for AI profile avatars.');
122
+ }
123
+ const contentSha256 = createHash('sha256').update(input.payload.buffer).digest('hex');
124
+ const existing = input.deps.workspaceAssetStore.getByStorageKey(input.storageKey, input.workspaceId);
125
+ return input.deps.workspaceAssetStore.upsertAsset({
126
+ assetId: existing?.assetId || stableAssetId(input.workspaceId, input.profileId, input.slot),
127
+ workspaceId: input.workspaceId,
128
+ kind: 'image',
129
+ logicalName: `AI profile avatar ${input.slot}`,
130
+ originalFilename: normalizeId(input.payload.originalName) || `${input.slot}.${extensionForMimeType(input.payload.mimeType)}`,
131
+ mimeType: input.payload.mimeType,
132
+ storageProvider: input.provider,
133
+ storageKey: input.storageKey,
134
+ contentSha256,
135
+ sizeBytes: input.payload.buffer.length,
136
+ documentId: null,
137
+ version: null,
138
+ isLatest: true,
139
+ scanStatus: 'clean',
140
+ scanEngine: null,
141
+ scanDetails: null,
142
+ createdByUserId: existing?.createdByUserId || normalizeId(input.actorId) || null,
143
+ createdAt: existing?.createdAt || input.nowIso,
144
+ updatedAt: input.nowIso,
145
+ deletedAt: null,
146
+ purgeAfter: null,
147
+ });
148
+ }
149
+ async function cleanupAiProfileAvatarAssets(input) {
150
+ const keep = new Set(Array.from(input.keepStorageKeys || []).filter(Boolean));
151
+ const candidates = new Map();
152
+ for (const storageKey of input.explicitStorageKeys || []) {
153
+ const normalized = normalizeTaskDataRelativePath(normalizeId(storageKey));
154
+ if (!normalized || keep.has(normalized))
155
+ continue;
156
+ if (storageKeyBelongsToAiProfileAvatar(input.workspaceId, input.profileId, normalized)) {
157
+ candidates.set(normalized, input.providerFallback);
158
+ }
159
+ }
160
+ if (input.deps.workspaceAssetStore) {
161
+ const assets = input.deps.workspaceAssetStore.listAllByWorkspace(input.workspaceId, {
162
+ includeDeleted: false,
163
+ kind: 'image',
164
+ });
165
+ for (const asset of assets) {
166
+ if (!storageKeyBelongsToAiProfileAvatar(input.workspaceId, input.profileId, asset.storageKey))
167
+ continue;
168
+ if (keep.has(asset.storageKey))
169
+ continue;
170
+ candidates.set(asset.storageKey, asset.storageProvider === 'r2' ? 'r2' : 'local');
171
+ }
172
+ }
173
+ for (const [storageKey, provider] of candidates) {
174
+ try {
175
+ const asset = input.deps.workspaceAssetStore?.getByStorageKey(storageKey, input.workspaceId);
176
+ if (asset && !asset.deletedAt) {
177
+ input.deps.workspaceAssetStore?.markDeleted(asset.assetId, input.workspaceId);
178
+ }
179
+ }
180
+ catch {
181
+ input.deps.appendStorageTelemetry('ai_avatar.cleanup', {
182
+ path: storageKey,
183
+ provider,
184
+ status: 'metadata-delete-failed',
185
+ });
186
+ }
187
+ try {
188
+ await deleteAvatarObject(input.deps, storageKey, provider);
189
+ input.deps.appendStorageTelemetry('ai_avatar.cleanup', {
190
+ path: storageKey,
191
+ provider,
192
+ status: 'deleted',
193
+ });
194
+ }
195
+ catch {
196
+ input.deps.appendStorageTelemetry('ai_avatar.cleanup', {
197
+ path: storageKey,
198
+ provider,
199
+ status: 'object-delete-failed',
200
+ });
201
+ }
202
+ }
203
+ }
204
+ export async function saveAiProfileAvatarAssets(deps, input) {
205
+ const workspaceId = normalizeId(input.workspaceId);
206
+ const profileId = normalizeId(input.profileId);
207
+ if (!workspaceId)
208
+ throw new Error('workspaceId is required.');
209
+ if (!profileId)
210
+ throw new Error('profileId is required.');
211
+ const existingProfile = deps.core.listAiProfiles(workspaceId)
212
+ .find((profile) => profile.id === profileId);
213
+ if (!existingProfile)
214
+ throw new Error(`Profile ${profileId} not found.`);
215
+ const displayMimeType = validatePayload(input.display, 'Display avatar');
216
+ const sourcePayload = input.source || input.display;
217
+ const sourceMimeType = validatePayload(sourcePayload, 'Source avatar');
218
+ const displayPayload = { ...input.display, mimeType: displayMimeType };
219
+ const normalizedSourcePayload = { ...sourcePayload, mimeType: sourceMimeType };
220
+ const displayStorageKey = buildAiProfileAvatarStorageKey({
221
+ workspaceId,
222
+ profileId,
223
+ slot: 'display',
224
+ mimeType: displayMimeType,
225
+ });
226
+ const sourceStorageKey = buildAiProfileAvatarStorageKey({
227
+ workspaceId,
228
+ profileId,
229
+ slot: 'source',
230
+ mimeType: sourceMimeType,
231
+ });
232
+ const objectStorageClient = deps.getObjectStorageClient();
233
+ const provider = objectStorageClient ? 'r2' : 'local';
234
+ const nowIso = new Date().toISOString();
235
+ await writeAvatarObject(deps, displayStorageKey, displayPayload, provider);
236
+ await writeAvatarObject(deps, sourceStorageKey, normalizedSourcePayload, provider);
237
+ const displayAsset = upsertAvatarAsset({
238
+ deps,
239
+ workspaceId,
240
+ profileId,
241
+ slot: 'display',
242
+ storageKey: displayStorageKey,
243
+ payload: displayPayload,
244
+ provider,
245
+ actorId: input.actorId,
246
+ nowIso,
247
+ });
248
+ const sourceAsset = upsertAvatarAsset({
249
+ deps,
250
+ workspaceId,
251
+ profileId,
252
+ slot: 'source',
253
+ storageKey: sourceStorageKey,
254
+ payload: normalizedSourcePayload,
255
+ provider,
256
+ actorId: input.actorId,
257
+ nowIso,
258
+ });
259
+ deps.core.notifyDocumentMetadataMutation(workspaceId, [displayStorageKey, sourceStorageKey], 'upsert');
260
+ deps.appendStorageTelemetry('ai_avatar.upload', {
261
+ path: displayStorageKey,
262
+ provider,
263
+ status: 'stored',
264
+ });
265
+ const profile = deps.core.updateAiProfileAvatar({
266
+ workspaceId,
267
+ profileId,
268
+ avatarUrl: contextUrlForStorageKey(displayStorageKey),
269
+ avatarSourceUrl: contextUrlForStorageKey(sourceStorageKey),
270
+ });
271
+ await cleanupAiProfileAvatarAssets({
272
+ deps,
273
+ workspaceId,
274
+ profileId,
275
+ keepStorageKeys: [displayStorageKey, sourceStorageKey],
276
+ explicitStorageKeys: [
277
+ storageKeyFromContextUrl(existingProfile.avatarUrl),
278
+ storageKeyFromContextUrl(existingProfile.avatarSourceUrl),
279
+ ],
280
+ providerFallback: provider,
281
+ });
282
+ return {
283
+ profile,
284
+ displayAsset,
285
+ sourceAsset,
286
+ displayStorageKey,
287
+ sourceStorageKey,
288
+ };
289
+ }
290
+ export async function clearAiProfileAvatarAssets(deps, input) {
291
+ const workspaceId = normalizeId(input.workspaceId);
292
+ const profileId = normalizeId(input.profileId);
293
+ if (!workspaceId)
294
+ throw new Error('workspaceId is required.');
295
+ if (!profileId)
296
+ throw new Error('profileId is required.');
297
+ const existingProfile = deps.core.listAiProfiles(workspaceId)
298
+ .find((profile) => profile.id === profileId);
299
+ const objectStorageClient = deps.getObjectStorageClient();
300
+ const provider = objectStorageClient ? 'r2' : 'local';
301
+ const profile = deps.core.updateAiProfileAvatar({
302
+ workspaceId,
303
+ profileId,
304
+ avatarUrl: null,
305
+ avatarSourceUrl: null,
306
+ });
307
+ await cleanupAiProfileAvatarAssets({
308
+ deps,
309
+ workspaceId,
310
+ profileId,
311
+ keepStorageKeys: [],
312
+ explicitStorageKeys: [
313
+ storageKeyFromContextUrl(existingProfile?.avatarUrl),
314
+ storageKeyFromContextUrl(existingProfile?.avatarSourceUrl),
315
+ ],
316
+ providerFallback: provider,
317
+ });
318
+ return { profile };
319
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,213 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { TaskforceCore } from '../core/Taskforce';
6
+ import { WorkspaceAssetStore } from '../storage/workspaceAssetStore';
7
+ import { buildAiProfileAvatarStorageKey, saveAiProfileAvatarAssets, } from './aiProfileAvatarAssets';
8
+ const roots = [];
9
+ function createFixture() {
10
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-ai-avatar-assets-'));
11
+ roots.push(projectRoot);
12
+ const core = new TaskforceCore({ projectRoot });
13
+ const workspaceAssetStore = new WorkspaceAssetStore(core.getDatabaseAdapter(), core.getTenantId());
14
+ const ensureWithinDir = (candidatePath, rootDir) => {
15
+ const resolvedRoot = path.resolve(rootDir);
16
+ const resolvedCandidate = path.resolve(candidatePath);
17
+ return resolvedCandidate.startsWith(resolvedRoot) ? resolvedCandidate : null;
18
+ };
19
+ return {
20
+ core,
21
+ workspaceAssetStore,
22
+ deps: {
23
+ core,
24
+ workspaceAssetStore,
25
+ getObjectStorageClient: () => null,
26
+ ensureWithinDir,
27
+ markAssetDeleted: vi.fn(),
28
+ appendStorageTelemetry: vi.fn(),
29
+ }
30
+ };
31
+ }
32
+ afterEach(() => {
33
+ while (roots.length > 0) {
34
+ const root = roots.pop();
35
+ if (root)
36
+ fs.rmSync(root, { recursive: true, force: true });
37
+ }
38
+ });
39
+ describe('AI profile avatar stable assets', () => {
40
+ it('builds deterministic stable slot paths', () => {
41
+ expect(buildAiProfileAvatarStorageKey({
42
+ workspaceId: 'workspace-1',
43
+ profileId: 'ai-profile-1',
44
+ slot: 'display',
45
+ mimeType: 'image/webp',
46
+ })).toBe('workspaces/workspace-1/assets/images/ai-profiles/ai-profile-1/avatar/display.webp');
47
+ expect(buildAiProfileAvatarStorageKey({
48
+ workspaceId: 'workspace-1',
49
+ profileId: 'ai-profile-1',
50
+ slot: 'source',
51
+ mimeType: 'image/gif',
52
+ })).toBe('workspaces/workspace-1/assets/images/ai-profiles/ai-profile-1/avatar/source.gif');
53
+ });
54
+ it('overwrites the same display/source assets and advances profile revision', async () => {
55
+ const { core, workspaceAssetStore, deps } = createFixture();
56
+ const workspaceId = 'workspace-1';
57
+ const resolved = core.resolveAiProfile({
58
+ workspaceId,
59
+ name: 'Codex',
60
+ provider: 'OpenAI',
61
+ model: 'gpt-5',
62
+ });
63
+ const profileId = resolved.profile.id;
64
+ const first = await saveAiProfileAvatarAssets(deps, {
65
+ workspaceId,
66
+ profileId,
67
+ display: {
68
+ buffer: Buffer.from('display-one'),
69
+ mimeType: 'image/webp',
70
+ originalName: 'display.webp',
71
+ },
72
+ source: {
73
+ buffer: Buffer.from('source-one'),
74
+ mimeType: 'image/jpeg',
75
+ originalName: 'source.jpg',
76
+ },
77
+ actorId: 'user-1',
78
+ });
79
+ const second = await saveAiProfileAvatarAssets(deps, {
80
+ workspaceId,
81
+ profileId,
82
+ display: {
83
+ buffer: Buffer.from('display-two'),
84
+ mimeType: 'image/webp',
85
+ originalName: 'display.webp',
86
+ },
87
+ source: {
88
+ buffer: Buffer.from('source-two'),
89
+ mimeType: 'image/jpeg',
90
+ originalName: 'source.jpg',
91
+ },
92
+ actorId: 'user-1',
93
+ });
94
+ expect(second.displayStorageKey).toBe(first.displayStorageKey);
95
+ expect(second.sourceStorageKey).toBe(first.sourceStorageKey);
96
+ expect(second.profile.avatarRevision).toBe(2);
97
+ expect(second.profile.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(second.displayStorageKey)}`);
98
+ expect(second.profile.avatarSourceUrl).toBe(`/api/taskforce/context/${encodeURIComponent(second.sourceStorageKey)}`);
99
+ const assets = workspaceAssetStore.listAllByWorkspace(workspaceId, { kind: 'image' });
100
+ expect(assets.map((asset) => asset.storageKey).sort()).toEqual([
101
+ second.displayStorageKey,
102
+ second.sourceStorageKey,
103
+ ].sort());
104
+ const displayAsset = workspaceAssetStore.getByStorageKey(second.displayStorageKey, workspaceId);
105
+ expect(displayAsset?.contentSha256).not.toBe(first.displayAsset.contentSha256);
106
+ const displayPath = path.join(core.getPaths().basePath, second.displayStorageKey);
107
+ expect(fs.readFileSync(displayPath, 'utf8')).toBe('display-two');
108
+ core.close();
109
+ });
110
+ it('tombstones old generated avatar assets after a stable replacement succeeds', async () => {
111
+ const { core, workspaceAssetStore, deps } = createFixture();
112
+ const workspaceId = 'workspace-1';
113
+ const resolved = core.resolveAiProfile({
114
+ workspaceId,
115
+ name: 'Harry Palmer',
116
+ provider: 'OpenClaw',
117
+ model: 'gpt-5.4',
118
+ });
119
+ const profileId = resolved.profile.id;
120
+ const oldStorageKey = `workspaces/${workspaceId}/assets/images/ai-profiles/${profileId}/avatar/asset-old-cropped-harry.webp`;
121
+ fs.mkdirSync(path.dirname(path.join(core.getPaths().basePath, oldStorageKey)), { recursive: true });
122
+ fs.writeFileSync(path.join(core.getPaths().basePath, oldStorageKey), 'old-avatar');
123
+ workspaceAssetStore.upsertAsset({
124
+ assetId: 'asset-old-harry-avatar-000000000001',
125
+ workspaceId,
126
+ kind: 'image',
127
+ logicalName: 'old avatar',
128
+ originalFilename: 'cropped-harry.webp',
129
+ mimeType: 'image/webp',
130
+ storageProvider: 'local',
131
+ storageKey: oldStorageKey,
132
+ contentSha256: 'old-sha',
133
+ sizeBytes: 10,
134
+ });
135
+ core.updateAiProfileAvatar({
136
+ workspaceId,
137
+ profileId,
138
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(oldStorageKey)}`,
139
+ avatarSourceUrl: null,
140
+ });
141
+ await saveAiProfileAvatarAssets(deps, {
142
+ workspaceId,
143
+ profileId,
144
+ display: {
145
+ buffer: Buffer.from('display'),
146
+ mimeType: 'image/webp',
147
+ originalName: 'display.webp',
148
+ },
149
+ source: {
150
+ buffer: Buffer.from('source'),
151
+ mimeType: 'image/webp',
152
+ originalName: 'source.webp',
153
+ },
154
+ });
155
+ expect(workspaceAssetStore.getByStorageKey(oldStorageKey, workspaceId)?.deletedAt).toBeTruthy();
156
+ expect(fs.existsSync(path.join(core.getPaths().basePath, oldStorageKey))).toBe(false);
157
+ expect(deps.markAssetDeleted).toHaveBeenCalledWith(oldStorageKey);
158
+ core.close();
159
+ });
160
+ it('does not fail a stable replacement when legacy cleanup markers fail', async () => {
161
+ const { core, workspaceAssetStore, deps } = createFixture();
162
+ const workspaceId = 'workspace-1';
163
+ const resolved = core.resolveAiProfile({
164
+ workspaceId,
165
+ name: 'Control',
166
+ provider: 'OpenAI',
167
+ model: 'gpt-5',
168
+ });
169
+ const profileId = resolved.profile.id;
170
+ const oldStorageKey = `workspaces/${workspaceId}/assets/images/ai-profiles/${profileId}/avatar/asset-old-control.webp`;
171
+ fs.mkdirSync(path.dirname(path.join(core.getPaths().basePath, oldStorageKey)), { recursive: true });
172
+ fs.writeFileSync(path.join(core.getPaths().basePath, oldStorageKey), 'old-avatar');
173
+ workspaceAssetStore.upsertAsset({
174
+ assetId: 'asset-old-control-avatar-0000001',
175
+ workspaceId,
176
+ kind: 'image',
177
+ logicalName: 'old avatar',
178
+ originalFilename: 'control.webp',
179
+ mimeType: 'image/webp',
180
+ storageProvider: 'local',
181
+ storageKey: oldStorageKey,
182
+ contentSha256: 'old-sha',
183
+ sizeBytes: 10,
184
+ });
185
+ core.updateAiProfileAvatar({
186
+ workspaceId,
187
+ profileId,
188
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(oldStorageKey)}`,
189
+ avatarSourceUrl: null,
190
+ });
191
+ deps.markAssetDeleted.mockImplementation(() => {
192
+ throw new Error('legacy marker failed');
193
+ });
194
+ const result = await saveAiProfileAvatarAssets(deps, {
195
+ workspaceId,
196
+ profileId,
197
+ display: {
198
+ buffer: Buffer.from('display'),
199
+ mimeType: 'image/webp',
200
+ originalName: 'display.webp',
201
+ },
202
+ source: {
203
+ buffer: Buffer.from('source'),
204
+ mimeType: 'image/webp',
205
+ originalName: 'source.webp',
206
+ },
207
+ });
208
+ expect(result.profile.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(result.displayStorageKey)}`);
209
+ expect(workspaceAssetStore.getByStorageKey(oldStorageKey, workspaceId)?.deletedAt).toBeTruthy();
210
+ expect(fs.existsSync(path.join(core.getPaths().basePath, oldStorageKey))).toBe(false);
211
+ core.close();
212
+ });
213
+ });
@@ -199,7 +199,69 @@ describe('annotated attachment routes', () => {
199
199
  fs.rmSync(projectRoot, { recursive: true, force: true });
200
200
  }
201
201
  });
202
- it('allows local mutation attribution fallback when local runtime has an active user even if auth is enabled', async () => {
202
+ it('lists canonical image assets for the Image Notes tray', async () => {
203
+ const projectRoot = createProjectRoot();
204
+ const core = new TaskforceCore({
205
+ projectRoot,
206
+ storagePath: path.join(projectRoot, '.taskforce'),
207
+ });
208
+ try {
209
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
210
+ const category = core.getCategories()[0]?.value;
211
+ const type = core.getTypes()[0]?.value;
212
+ const task = core.createTask({ title: 'Review image', category, type }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
213
+ const assetStore = core.getWorkspaceAssetStore();
214
+ const asset = assetStore.upsertAsset({
215
+ assetId: 'asset-image-1',
216
+ workspaceId: 'workspace-1',
217
+ kind: 'image',
218
+ originalFilename: 'ui-review.png',
219
+ logicalName: 'UI Review',
220
+ mimeType: 'image/png',
221
+ storageProvider: 'local',
222
+ storageKey: 'workspaces/workspace-1/assets/images/asset-image-1-ui-review.png',
223
+ contentSha256: 'sha-image-1',
224
+ sizeBytes: 256,
225
+ });
226
+ assetStore.upsertLink({
227
+ workspaceId: 'workspace-1',
228
+ assetId: asset.assetId,
229
+ taskId: task.id,
230
+ role: 'attachment',
231
+ });
232
+ core.createAnnotatedAttachmentSession({
233
+ workspaceId: 'workspace-1',
234
+ taskId: '',
235
+ baseImageAssetId: asset.assetId,
236
+ title: 'UI Review session',
237
+ actorId: 'owner-1',
238
+ });
239
+ const routes = createRoutes(core);
240
+ const listImagesMatch = matchRoute('GET', '/api/taskforce/annotated-attachments/images', routes);
241
+ const listImagesReq = createMockReq('GET', '/api/taskforce/annotated-attachments/images?workspaceId=workspace-1');
242
+ const listImagesRes = createMockRes();
243
+ await listImagesMatch?.handler(listImagesReq, listImagesRes, {});
244
+ expect(listImagesRes.statusCode).toBe(200);
245
+ expect(listImagesRes.getHeader('cache-control')).toBe('no-store');
246
+ const listed = JSON.parse(listImagesRes.__getBody());
247
+ expect(listed.images).toHaveLength(1);
248
+ expect(listed.images[0]).toMatchObject({
249
+ assetId: asset.assetId,
250
+ displayName: 'UI Review',
251
+ path: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fasset-image-1-ui-review.png',
252
+ attachmentCount: 1,
253
+ sessionCount: 1,
254
+ taskId: task.id,
255
+ taskReferenceLabel: task.referenceLabel,
256
+ });
257
+ expect(listed.images[0].imageReferenceLabel).toMatch(/^I-\d+$/);
258
+ }
259
+ finally {
260
+ core.close();
261
+ fs.rmSync(projectRoot, { recursive: true, force: true });
262
+ }
263
+ });
264
+ it('rejects local mutation attribution when only an active workspace user fallback is available', async () => {
203
265
  const projectRoot = createProjectRoot();
204
266
  const core = new TaskforceCore({
205
267
  projectRoot,
@@ -252,13 +314,10 @@ describe('annotated attachment routes', () => {
252
314
  });
253
315
  const createRes = createMockRes();
254
316
  await createMatch?.handler(createReq, createRes, {});
255
- expect(createRes.statusCode).toBe(200);
317
+ expect(createRes.statusCode).toBe(401);
256
318
  expect(JSON.parse(createRes.__getBody())).toMatchObject({
257
- success: true,
258
- session: expect.objectContaining({
259
- createdByActorId: 'user-local-1',
260
- updatedByActorId: 'user-local-1',
261
- }),
319
+ success: false,
320
+ code: 'MUTATION_AUTH_REQUIRED',
262
321
  });
263
322
  }
264
323
  finally {
@@ -254,6 +254,10 @@ export function resolveMaxPayloadBytesForPath(pathname, defaultMaxBytes) {
254
254
  // Sync routes (especially applying local changes) often include large binary attachments
255
255
  return Math.max(defaultMaxBytes, 50 * 1024 * 1024);
256
256
  }
257
+ if (pathname === '/api/taskforce/ui-state') {
258
+ // Workspace-sync UI state can carry large baselines; keep the global guard aligned with its route parser.
259
+ return Math.max(defaultMaxBytes, 50 * 1024 * 1024);
260
+ }
257
261
  return defaultMaxBytes;
258
262
  }
259
263
  export function shouldRewriteSetCookieForLocalDevHost(hostHeader) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { ensureBillingWebhookAuthExclusion, ensurePublicSiteConfigAuthExclusion, configureHttpServerTimeouts, isBillingWebhookPath, rewriteProxySetCookieForLocalHost, resolveHttpServerTimeoutSettings, resolveEffectiveRequestUserId, resolveAuthorizedWorkspaceAccess, resolveDisabledAccessError, shouldApplyReadOnlyMutationGuard, shouldRewriteProxySetCookieDomain, shouldApplyAuthRateLimitForPath, shouldProxyCloudPath, writeJsonErrorIfPossible } from './index.js';
2
+ import { ensureBillingWebhookAuthExclusion, ensurePublicSiteConfigAuthExclusion, configureHttpServerTimeouts, isBillingWebhookPath, rewriteProxySetCookieForLocalHost, resolveHttpServerTimeoutSettings, resolveEffectiveRequestUserId, resolveAuthorizedWorkspaceAccess, resolveDisabledAccessError, resolveMaxPayloadBytesForPath, shouldApplyReadOnlyMutationGuard, shouldRewriteProxySetCookieDomain, shouldApplyAuthRateLimitForPath, shouldProxyCloudPath, writeJsonErrorIfPossible } from './index.js';
3
3
  describe('server index helper policies', () => {
4
4
  it('uses proxy-friendly HTTP server timeouts by default', () => {
5
5
  const settings = resolveHttpServerTimeoutSettings({});
@@ -60,6 +60,11 @@ describe('server index helper policies', () => {
60
60
  expect(shouldApplyAuthRateLimitForPath('/api/taskforce/auth/session')).toBe(false);
61
61
  expect(shouldApplyAuthRateLimitForPath('/api/taskforce/billing/webhook')).toBe(false);
62
62
  });
63
+ it('allows workspace sync-sized ui-state payloads through the global guard', () => {
64
+ expect(resolveMaxPayloadBytesForPath('/api/taskforce/ui-state', 1_048_576)).toBe(50 * 1024 * 1024);
65
+ expect(resolveMaxPayloadBytesForPath('/api/taskforce/sync/workspace/push', 1_048_576)).toBe(50 * 1024 * 1024);
66
+ expect(resolveMaxPayloadBytesForPath('/api/taskforce/task', 1_048_576)).toBe(1_048_576);
67
+ });
63
68
  it('writes a json error when headers are still available', () => {
64
69
  let headersSent = false;
65
70
  let writableEnded = false;