@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
@@ -0,0 +1,1004 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { createHash, randomUUID } from 'crypto';
4
+ export function resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId) {
5
+ if (!deps.workspaceAssetStore) {
6
+ return {
7
+ primaryLink: null,
8
+ effectiveUpdatedAt: String(asset.updatedAt || asset.createdAt || '').trim() || new Date().toISOString()
9
+ };
10
+ }
11
+ const links = deps.workspaceAssetStore.listLinksForAsset(asset.assetId, workspaceId, { includeDeleted: true });
12
+ const activeLinks = links.filter((entry) => !entry.deletedAt);
13
+ const primaryLink = activeLinks[0] || null;
14
+ let effectiveUpdatedAt = String(asset.updatedAt || asset.createdAt || '').trim() || new Date().toISOString();
15
+ for (const link of links) {
16
+ const linkUpdatedAt = String(link.updatedAt || link.createdAt || '').trim();
17
+ if (linkUpdatedAt && linkUpdatedAt > effectiveUpdatedAt)
18
+ effectiveUpdatedAt = linkUpdatedAt;
19
+ }
20
+ return { primaryLink, effectiveUpdatedAt };
21
+ }
22
+ export function getCanonicalSyncDocumentMetadata(deps, relativePath, workspaceId) {
23
+ if (!deps.workspaceAssetStore)
24
+ return {};
25
+ const asset = deps.workspaceAssetStore.getByStorageKey(relativePath, workspaceId);
26
+ if (!asset || asset.kind !== 'document')
27
+ return {};
28
+ const { primaryLink } = resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId);
29
+ return {
30
+ assetId: asset.assetId,
31
+ documentId: asset.documentId,
32
+ referenceNumber: asset.referenceNumber,
33
+ version: asset.version,
34
+ taskId: primaryLink?.taskId || null,
35
+ logicalName: asset.logicalName || null,
36
+ caption: primaryLink?.displayName || null,
37
+ originalFilename: asset.originalFilename || path.basename(relativePath),
38
+ linkRole: primaryLink?.role === 'reference' ? 'reference' : 'attachment'
39
+ };
40
+ }
41
+ export function hasDocumentSyncMetadataDrift(deps, workspaceId, doc, options) {
42
+ if (!deps.workspaceAssetStore)
43
+ return false;
44
+ const existingAsset = deps.workspaceAssetStore.getByStorageKey(doc.path, workspaceId);
45
+ if (!existingAsset || existingAsset.kind !== 'document')
46
+ return false;
47
+ const { primaryLink } = resolveCanonicalAssetSyncLinkState(deps, existingAsset, workspaceId);
48
+ const normalizedIncomingReference = Number.isFinite(Number(doc.referenceNumber))
49
+ ? Math.max(1, Math.floor(Number(doc.referenceNumber)))
50
+ : null;
51
+ const normalizedIncomingVersion = Number.isFinite(Number(doc.version))
52
+ ? Math.max(1, Math.floor(Number(doc.version)))
53
+ : null;
54
+ const normalizedIncomingDocumentId = typeof doc.documentId === 'string' && doc.documentId.trim().length > 0
55
+ ? doc.documentId.trim()
56
+ : null;
57
+ const normalizedIncomingLogicalName = typeof doc.logicalName === 'string' && doc.logicalName.trim().length > 0
58
+ ? doc.logicalName.trim()
59
+ : null;
60
+ const normalizedIncomingCaption = typeof doc.caption === 'string' && doc.caption.trim().length > 0
61
+ ? doc.caption.trim()
62
+ : null;
63
+ const normalizedIncomingFilename = typeof doc.originalFilename === 'string' && doc.originalFilename.trim().length > 0
64
+ ? doc.originalFilename.trim()
65
+ : null;
66
+ const normalizedIncomingTaskId = typeof doc.taskId === 'string' && doc.taskId.trim().length > 0
67
+ ? doc.taskId.trim()
68
+ : null;
69
+ const normalizedIncomingLinkRole = doc.linkRole === 'reference' ? 'reference' : 'attachment';
70
+ const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true;
71
+ const referenceNumberMismatch = Boolean(existingAsset.referenceNumber
72
+ && normalizedIncomingReference
73
+ && existingAsset.referenceNumber !== normalizedIncomingReference);
74
+ if (referenceNumberMismatch) {
75
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Detected document reference number mismatch during normal sync.', {
76
+ source: 'documents.referenceNumber.mismatch',
77
+ path: doc.path,
78
+ assetId: existingAsset.assetId,
79
+ existingReferenceNumber: existingAsset.referenceNumber,
80
+ incomingReferenceNumber: normalizedIncomingReference
81
+ });
82
+ }
83
+ return referenceNumberMismatch
84
+ || (allowReferenceNumberReassignment && existingAsset.referenceNumber !== normalizedIncomingReference)
85
+ || existingAsset.version !== normalizedIncomingVersion
86
+ || (existingAsset.documentId || null) !== normalizedIncomingDocumentId
87
+ || (existingAsset.logicalName || null) !== normalizedIncomingLogicalName
88
+ || (existingAsset.originalFilename || null) !== normalizedIncomingFilename
89
+ || (primaryLink?.taskId || null) !== normalizedIncomingTaskId
90
+ || (primaryLink?.displayName || null) !== normalizedIncomingCaption
91
+ || ((primaryLink?.role === 'reference' ? 'reference' : 'attachment')) !== normalizedIncomingLinkRole;
92
+ }
93
+ export function hasBinaryAssetSyncMetadataDrift(deps, workspaceId, asset, options) {
94
+ if (!deps.workspaceAssetStore)
95
+ return false;
96
+ const existingAsset = deps.workspaceAssetStore.getByStorageKey(asset.path, workspaceId);
97
+ if (!existingAsset || (existingAsset.kind !== 'image' && existingAsset.kind !== 'file'))
98
+ return false;
99
+ const { primaryLink } = resolveCanonicalAssetSyncLinkState(deps, existingAsset, workspaceId);
100
+ const normalizedIncomingReference = Number.isFinite(Number(asset.referenceNumber))
101
+ ? Math.max(1, Math.floor(Number(asset.referenceNumber)))
102
+ : null;
103
+ const normalizedIncomingLogicalName = typeof asset.logicalName === 'string' && asset.logicalName.trim().length > 0
104
+ ? asset.logicalName.trim()
105
+ : null;
106
+ const normalizedIncomingCaption = typeof asset.caption === 'string' && asset.caption.trim().length > 0
107
+ ? asset.caption.trim()
108
+ : null;
109
+ const normalizedIncomingFilename = typeof asset.originalFilename === 'string' && asset.originalFilename.trim().length > 0
110
+ ? asset.originalFilename.trim()
111
+ : null;
112
+ const normalizedIncomingTaskId = typeof asset.taskId === 'string' && asset.taskId.trim().length > 0
113
+ ? asset.taskId.trim()
114
+ : null;
115
+ const normalizedIncomingLinkRole = asset.linkRole === 'reference'
116
+ ? 'reference'
117
+ : asset.kind === 'image'
118
+ ? 'image'
119
+ : 'attachment';
120
+ const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true;
121
+ const referenceNumberMismatch = Boolean(existingAsset.referenceNumber
122
+ && normalizedIncomingReference
123
+ && existingAsset.referenceNumber !== normalizedIncomingReference);
124
+ if (referenceNumberMismatch) {
125
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Detected asset reference number mismatch during normal sync.', {
126
+ source: 'assets.referenceNumber.mismatch',
127
+ path: asset.path,
128
+ assetId: existingAsset.assetId,
129
+ existingReferenceNumber: existingAsset.referenceNumber,
130
+ incomingReferenceNumber: normalizedIncomingReference
131
+ });
132
+ }
133
+ return referenceNumberMismatch
134
+ || (allowReferenceNumberReassignment && existingAsset.referenceNumber !== normalizedIncomingReference)
135
+ || (existingAsset.logicalName || null) !== normalizedIncomingLogicalName
136
+ || (existingAsset.originalFilename || null) !== normalizedIncomingFilename
137
+ || (primaryLink?.taskId || null) !== normalizedIncomingTaskId
138
+ || (primaryLink?.displayName || null) !== normalizedIncomingCaption
139
+ || ((primaryLink?.role === 'reference'
140
+ ? 'reference'
141
+ : existingAsset.kind === 'image'
142
+ ? 'image'
143
+ : 'attachment')) !== normalizedIncomingLinkRole;
144
+ }
145
+ export function reconcileWorkspaceSyncLinks(deps, input) {
146
+ if (!deps.workspaceAssetStore)
147
+ return;
148
+ const workspaceId = String(input.workspaceId || '').trim() || 'default';
149
+ const assetId = String(input.assetId || '').trim();
150
+ if (!assetId)
151
+ return;
152
+ const desiredTaskId = String(input.taskId || '').trim();
153
+ const desiredRole = input.role === 'reference'
154
+ ? 'reference'
155
+ : input.role === 'image'
156
+ ? 'image'
157
+ : 'attachment';
158
+ const linkUpdatedAt = String(input.updatedAt || '').trim() || new Date().toISOString();
159
+ const existingLinks = deps.workspaceAssetStore.listLinksForAsset(assetId, workspaceId, { includeDeleted: true });
160
+ if (desiredTaskId) {
161
+ deps.workspaceAssetStore.upsertLink({
162
+ workspaceId,
163
+ assetId,
164
+ taskId: desiredTaskId,
165
+ role: desiredRole,
166
+ displayName: typeof input.displayName === 'string' && input.displayName.trim().length > 0 ? input.displayName.trim() : null,
167
+ updatedAt: linkUpdatedAt,
168
+ deletedAt: null
169
+ });
170
+ }
171
+ if (desiredTaskId) {
172
+ for (const link of existingLinks) {
173
+ if (link.taskId === desiredTaskId && link.role === desiredRole)
174
+ continue;
175
+ if (link.deletedAt)
176
+ continue;
177
+ deps.workspaceAssetStore.deleteLink({
178
+ workspaceId,
179
+ assetId,
180
+ taskId: link.taskId,
181
+ role: link.role
182
+ });
183
+ }
184
+ }
185
+ }
186
+ export function listWorkspaceSyncDocumentDeletesSnapshot(deps, workspaceId = 'default') {
187
+ if (!deps.workspaceAssetStore)
188
+ return [];
189
+ const entries = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
190
+ includeDeleted: true,
191
+ kind: 'document'
192
+ })
193
+ .filter((asset) => typeof asset.deletedAt === 'string' && asset.deletedAt.trim().length > 0)
194
+ .map((asset) => {
195
+ const normalizedPath = deps.normalizeWorkspaceSyncDocumentPath(asset.storageKey);
196
+ if (!normalizedPath)
197
+ return null;
198
+ return {
199
+ path: normalizedPath,
200
+ deletedAt: String(asset.deletedAt || '').trim(),
201
+ assetId: asset.assetId
202
+ };
203
+ });
204
+ return entries.filter((entry) => Boolean(entry && entry.path && entry.deletedAt));
205
+ }
206
+ export async function listWorkspaceSyncDocumentsSnapshot(deps, basePath, workspaceId = 'default') {
207
+ const root = path.resolve(basePath);
208
+ const objectStorageClient = deps.getObjectStorageClient();
209
+ const candidatePaths = new Set();
210
+ const docsRoot = path.join(root, 'docs');
211
+ const collectMarkdownPaths = (directory) => {
212
+ if (!fs.existsSync(directory))
213
+ return;
214
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
215
+ for (const entry of entries) {
216
+ const absolutePath = path.join(directory, entry.name);
217
+ if (entry.isDirectory()) {
218
+ collectMarkdownPaths(absolutePath);
219
+ continue;
220
+ }
221
+ if (!entry.isFile())
222
+ continue;
223
+ const relativeFromRoot = path.relative(root, absolutePath).replace(/\\/g, '/');
224
+ const normalized = deps.normalizeWorkspaceSyncDocumentPath(relativeFromRoot);
225
+ if (normalized)
226
+ candidatePaths.add(normalized);
227
+ }
228
+ };
229
+ collectMarkdownPaths(docsRoot);
230
+ if (deps.workspaceAssetStore) {
231
+ const canonicalAssets = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
232
+ includeDeleted: false,
233
+ kind: 'document'
234
+ });
235
+ for (const asset of canonicalAssets) {
236
+ const normalized = deps.normalizeWorkspaceSyncDocumentPath(asset.storageKey);
237
+ if (normalized)
238
+ candidatePaths.add(normalized);
239
+ }
240
+ }
241
+ const uniquePaths = Array.from(candidatePaths).sort((a, b) => a.localeCompare(b));
242
+ const workspaceCache = deps.workspaceDocumentSnapshotCache.get(workspaceId) || new Map();
243
+ deps.workspaceDocumentSnapshotCache.set(workspaceId, workspaceCache);
244
+ const remainingPaths = new Set(uniquePaths);
245
+ const docs = [];
246
+ for (const relativePath of uniquePaths) {
247
+ try {
248
+ const canonicalAsset = deps.workspaceAssetStore?.getByStorageKey(relativePath, workspaceId);
249
+ let stat = null;
250
+ let content = null;
251
+ let updatedAt = '';
252
+ if (canonicalAsset?.kind === 'document') {
253
+ updatedAt = resolveCanonicalAssetSyncLinkState(deps, canonicalAsset, workspaceId).effectiveUpdatedAt;
254
+ if (canonicalAsset.storageProvider === 'r2' && objectStorageClient) {
255
+ const object = await objectStorageClient.getObject(relativePath);
256
+ if (!object?.body)
257
+ continue;
258
+ content = object.body.toString('utf8');
259
+ }
260
+ else {
261
+ const absPath = path.resolve(path.join(root, relativePath));
262
+ if (absPath !== root && !absPath.startsWith(`${root}${path.sep}`))
263
+ continue;
264
+ if (!fs.existsSync(absPath))
265
+ continue;
266
+ stat = fs.statSync(absPath);
267
+ if (!stat.isFile())
268
+ continue;
269
+ content = fs.readFileSync(absPath, 'utf8');
270
+ }
271
+ }
272
+ else {
273
+ const absPath = path.resolve(path.join(basePath, relativePath));
274
+ const safeRoot = path.resolve(basePath);
275
+ if (absPath !== safeRoot && !absPath.startsWith(`${safeRoot}${path.sep}`))
276
+ continue;
277
+ stat = fs.statSync(absPath);
278
+ if (!stat.isFile())
279
+ continue;
280
+ content = fs.readFileSync(absPath, 'utf8');
281
+ }
282
+ if (content == null)
283
+ continue;
284
+ const cached = workspaceCache.get(relativePath);
285
+ if (cached
286
+ && cached.size === Buffer.byteLength(content, 'utf8')
287
+ && (canonicalAsset
288
+ ? cached.updatedAt === updatedAt
289
+ : (stat != null && cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs))) {
290
+ docs.push({
291
+ path: relativePath,
292
+ updatedAt: cached.updatedAt,
293
+ content: cached.content,
294
+ ...getCanonicalSyncDocumentMetadata(deps, relativePath, workspaceId)
295
+ });
296
+ continue;
297
+ }
298
+ if (!canonicalAsset) {
299
+ const contentHash = createHash('sha256').update(content).digest('hex');
300
+ const existing = deps.core.getDocumentWatermark(workspaceId, relativePath);
301
+ if (existing && existing.contentHash === contentHash) {
302
+ updatedAt = existing.updatedAt;
303
+ }
304
+ else {
305
+ updatedAt = stat && Number.isFinite(stat.mtimeMs)
306
+ ? new Date(stat.mtimeMs).toISOString()
307
+ : new Date().toISOString();
308
+ try {
309
+ deps.core.upsertDocumentWatermark(workspaceId, relativePath, contentHash, updatedAt);
310
+ }
311
+ catch (error) {
312
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to persist document watermark during snapshot.', {
313
+ source: 'documents.snapshot.upsertWatermark',
314
+ path: relativePath,
315
+ error: String(error?.message || error || '')
316
+ });
317
+ }
318
+ }
319
+ }
320
+ docs.push({
321
+ path: relativePath,
322
+ updatedAt,
323
+ content,
324
+ ...getCanonicalSyncDocumentMetadata(deps, relativePath, workspaceId)
325
+ });
326
+ workspaceCache.set(relativePath, {
327
+ path: relativePath,
328
+ mtimeMs: stat?.mtimeMs ?? 0,
329
+ ctimeMs: stat?.ctimeMs ?? 0,
330
+ size: Buffer.byteLength(content, 'utf8'),
331
+ updatedAt,
332
+ content
333
+ });
334
+ }
335
+ catch (error) {
336
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to read sync document snapshot entry.', {
337
+ source: 'documents.snapshot.readEntry',
338
+ path: relativePath,
339
+ error: String(error?.message || error || '')
340
+ });
341
+ }
342
+ }
343
+ for (const cachedPath of Array.from(workspaceCache.keys())) {
344
+ if (!remainingPaths.has(cachedPath))
345
+ workspaceCache.delete(cachedPath);
346
+ }
347
+ return docs;
348
+ }
349
+ export async function listWorkspaceSyncDocumentIndex(deps, basePath, workspaceId = 'default') {
350
+ const root = path.resolve(basePath);
351
+ const candidatePaths = new Set();
352
+ const docsRoot = path.join(root, 'docs');
353
+ const collectMarkdownPaths = (directory) => {
354
+ if (!fs.existsSync(directory))
355
+ return;
356
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
357
+ for (const entry of entries) {
358
+ const absolutePath = path.join(directory, entry.name);
359
+ if (entry.isDirectory()) {
360
+ collectMarkdownPaths(absolutePath);
361
+ continue;
362
+ }
363
+ if (!entry.isFile())
364
+ continue;
365
+ const relativeFromRoot = path.relative(root, absolutePath).replace(/\\/g, '/');
366
+ const normalized = deps.normalizeWorkspaceSyncDocumentPath(relativeFromRoot);
367
+ if (normalized)
368
+ candidatePaths.add(normalized);
369
+ }
370
+ };
371
+ collectMarkdownPaths(docsRoot);
372
+ if (deps.workspaceAssetStore) {
373
+ const canonicalAssets = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
374
+ includeDeleted: false,
375
+ kind: 'document'
376
+ });
377
+ for (const asset of canonicalAssets) {
378
+ const normalized = deps.normalizeWorkspaceSyncDocumentPath(asset.storageKey);
379
+ if (normalized)
380
+ candidatePaths.add(normalized);
381
+ }
382
+ }
383
+ const docs = [];
384
+ for (const relativePath of Array.from(candidatePaths).sort((a, b) => a.localeCompare(b))) {
385
+ try {
386
+ const canonicalAsset = deps.workspaceAssetStore?.getByStorageKey(relativePath, workspaceId);
387
+ let updatedAt = '';
388
+ if (canonicalAsset?.kind === 'document') {
389
+ updatedAt = resolveCanonicalAssetSyncLinkState(deps, canonicalAsset, workspaceId).effectiveUpdatedAt;
390
+ }
391
+ else {
392
+ const absPath = path.resolve(path.join(basePath, relativePath));
393
+ const safeRoot = path.resolve(basePath);
394
+ if (absPath !== safeRoot && !absPath.startsWith(`${safeRoot}${path.sep}`))
395
+ continue;
396
+ if (!fs.existsSync(absPath))
397
+ continue;
398
+ const stat = fs.statSync(absPath);
399
+ if (!stat.isFile())
400
+ continue;
401
+ const existing = deps.core.getDocumentWatermark(workspaceId, relativePath);
402
+ updatedAt = existing?.updatedAt
403
+ || (Number.isFinite(stat.mtimeMs) ? new Date(stat.mtimeMs).toISOString() : new Date().toISOString());
404
+ }
405
+ if (!Number.isFinite(Date.parse(String(updatedAt || '').trim())))
406
+ continue;
407
+ docs.push({
408
+ path: relativePath,
409
+ updatedAt: String(updatedAt || '').trim(),
410
+ ...getCanonicalSyncDocumentMetadata(deps, relativePath, workspaceId)
411
+ });
412
+ }
413
+ catch (error) {
414
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to index sync document snapshot entry.', {
415
+ source: 'documents.snapshot.indexEntry',
416
+ path: relativePath,
417
+ error: String(error?.message || error || '')
418
+ });
419
+ }
420
+ }
421
+ return docs;
422
+ }
423
+ export async function listWorkspaceSyncBinaryAssetsSnapshot(deps, basePath, workspaceId = 'default') {
424
+ if (!deps.workspaceAssetStore)
425
+ return [];
426
+ const root = path.resolve(basePath);
427
+ const objectStorageClient = deps.getObjectStorageClient();
428
+ const assets = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
429
+ includeDeleted: false
430
+ });
431
+ const snapshots = [];
432
+ for (const asset of assets) {
433
+ if (asset.kind !== 'image' && asset.kind !== 'file')
434
+ continue;
435
+ if (asset.storageProvider === 'external')
436
+ continue;
437
+ const storageKey = String(asset.storageKey || '').trim();
438
+ if (!storageKey)
439
+ continue;
440
+ if (!deps.normalizeTaskDataRelativePath(storageKey))
441
+ continue;
442
+ try {
443
+ let buffer = null;
444
+ if (asset.storageProvider === 'r2' && objectStorageClient) {
445
+ const object = await objectStorageClient.getObject(storageKey);
446
+ buffer = object?.body || null;
447
+ }
448
+ else {
449
+ const absolutePath = path.resolve(root, storageKey);
450
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`))
451
+ continue;
452
+ if (!fs.existsSync(absolutePath)) {
453
+ deps.recordSyncDiagnostic(workspaceId, 'push', 'Asset registered in DB but file missing from disk — skipped from sync payload.', {
454
+ source: 'assets.snapshot.missingFile',
455
+ assetId: asset.assetId,
456
+ path: storageKey
457
+ });
458
+ continue;
459
+ }
460
+ buffer = fs.readFileSync(absolutePath);
461
+ }
462
+ if (!buffer) {
463
+ deps.recordSyncDiagnostic(workspaceId, 'push', 'Asset registered in DB but content could not be read — skipped from sync payload.', {
464
+ source: 'assets.snapshot.emptyBuffer',
465
+ assetId: asset.assetId,
466
+ path: storageKey,
467
+ storageProvider: asset.storageProvider
468
+ });
469
+ continue;
470
+ }
471
+ const { primaryLink, effectiveUpdatedAt } = resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId);
472
+ snapshots.push({
473
+ path: storageKey,
474
+ updatedAt: effectiveUpdatedAt,
475
+ contentBase64: buffer.toString('base64'),
476
+ assetId: asset.assetId,
477
+ kind: asset.kind,
478
+ mimeType: asset.mimeType || 'application/octet-stream',
479
+ referenceNumber: asset.referenceNumber,
480
+ taskId: primaryLink?.taskId || null,
481
+ logicalName: asset.logicalName || null,
482
+ caption: primaryLink?.displayName || null,
483
+ originalFilename: asset.originalFilename || path.basename(storageKey),
484
+ linkRole: primaryLink?.role || (asset.kind === 'image' ? 'image' : 'attachment')
485
+ });
486
+ }
487
+ catch (error) {
488
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to read binary asset snapshot entry.', {
489
+ source: 'assets.snapshot.readEntry',
490
+ path: storageKey,
491
+ error: String(error?.message || error || '')
492
+ });
493
+ }
494
+ }
495
+ return snapshots;
496
+ }
497
+ export async function listWorkspaceSyncBinaryAssetIndex(deps, workspaceId = 'default') {
498
+ if (!deps.workspaceAssetStore)
499
+ return [];
500
+ const assets = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
501
+ includeDeleted: false
502
+ });
503
+ return assets
504
+ .filter((asset) => asset.kind === 'image' || asset.kind === 'file')
505
+ .filter((asset) => asset.storageProvider !== 'external')
506
+ .filter((asset) => {
507
+ const storageKey = String(asset.storageKey || '').trim();
508
+ return Boolean(storageKey && deps.normalizeTaskDataRelativePath(storageKey));
509
+ })
510
+ .map((asset) => {
511
+ const storageKey = String(asset.storageKey || '').trim();
512
+ const { primaryLink, effectiveUpdatedAt } = resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId);
513
+ const assetKind = asset.kind === 'image' ? 'image' : 'file';
514
+ return {
515
+ path: storageKey,
516
+ updatedAt: effectiveUpdatedAt,
517
+ assetId: asset.assetId,
518
+ kind: assetKind,
519
+ mimeType: asset.mimeType || 'application/octet-stream',
520
+ referenceNumber: asset.referenceNumber,
521
+ taskId: primaryLink?.taskId || null,
522
+ logicalName: asset.logicalName || null,
523
+ caption: primaryLink?.displayName || null,
524
+ originalFilename: asset.originalFilename || path.basename(storageKey),
525
+ linkRole: primaryLink?.role || (asset.kind === 'image' ? 'image' : 'attachment')
526
+ };
527
+ })
528
+ .filter((entry) => Number.isFinite(Date.parse(entry.updatedAt)) && entry.path.length > 0);
529
+ }
530
+ export async function loadWorkspaceSyncDocumentPayload(deps, basePath, workspaceId, relativePath) {
531
+ const normalizedPath = deps.normalizeWorkspaceSyncDocumentPath(relativePath);
532
+ if (!normalizedPath)
533
+ return null;
534
+ const root = path.resolve(basePath);
535
+ const absolutePath = path.resolve(path.join(root, normalizedPath));
536
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`))
537
+ return null;
538
+ let content = '';
539
+ let stat = null;
540
+ try {
541
+ if (!fs.existsSync(absolutePath))
542
+ return null;
543
+ stat = fs.statSync(absolutePath);
544
+ if (!stat.isFile())
545
+ return null;
546
+ content = fs.readFileSync(absolutePath, 'utf8');
547
+ }
548
+ catch (error) {
549
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to load targeted sync document payload.', {
550
+ source: 'documents.snapshot.loadPayload',
551
+ path: normalizedPath,
552
+ error: String(error?.message || error || '')
553
+ });
554
+ return null;
555
+ }
556
+ const workspaceCache = deps.workspaceDocumentSnapshotCache.get(workspaceId)
557
+ ?? (() => {
558
+ const next = new Map();
559
+ deps.workspaceDocumentSnapshotCache.set(workspaceId, next);
560
+ return next;
561
+ })();
562
+ const cached = workspaceCache.get(normalizedPath);
563
+ const canonicalAsset = deps.workspaceAssetStore?.getByStorageKey(normalizedPath, workspaceId);
564
+ let updatedAt = '';
565
+ if (canonicalAsset?.kind === 'document') {
566
+ updatedAt = resolveCanonicalAssetSyncLinkState(deps, canonicalAsset, workspaceId).effectiveUpdatedAt;
567
+ }
568
+ else {
569
+ const contentHash = createHash('sha256').update(content).digest('hex');
570
+ const existing = deps.core.getDocumentWatermark(workspaceId, normalizedPath);
571
+ if (existing && existing.contentHash === contentHash) {
572
+ updatedAt = existing.updatedAt;
573
+ }
574
+ else {
575
+ updatedAt = stat && Number.isFinite(stat.mtimeMs)
576
+ ? new Date(stat.mtimeMs).toISOString()
577
+ : new Date().toISOString();
578
+ try {
579
+ deps.core.upsertDocumentWatermark(workspaceId, normalizedPath, contentHash, updatedAt);
580
+ }
581
+ catch (error) {
582
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to persist document watermark during targeted payload load.', {
583
+ source: 'documents.snapshot.loadPayload.upsertWatermark',
584
+ path: normalizedPath,
585
+ error: String(error?.message || error || '')
586
+ });
587
+ }
588
+ }
589
+ }
590
+ if (cached
591
+ && stat
592
+ && cached.size === Buffer.byteLength(content, 'utf8')
593
+ && (canonicalAsset
594
+ ? cached.updatedAt === updatedAt
595
+ : cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs)) {
596
+ return {
597
+ path: normalizedPath,
598
+ updatedAt: cached.updatedAt,
599
+ content: cached.content,
600
+ ...getCanonicalSyncDocumentMetadata(deps, normalizedPath, workspaceId)
601
+ };
602
+ }
603
+ workspaceCache.set(normalizedPath, {
604
+ path: normalizedPath,
605
+ mtimeMs: stat?.mtimeMs ?? 0,
606
+ ctimeMs: stat?.ctimeMs ?? 0,
607
+ size: Buffer.byteLength(content, 'utf8'),
608
+ updatedAt,
609
+ content
610
+ });
611
+ return {
612
+ path: normalizedPath,
613
+ updatedAt,
614
+ content,
615
+ ...getCanonicalSyncDocumentMetadata(deps, normalizedPath, workspaceId)
616
+ };
617
+ }
618
+ export async function loadWorkspaceSyncBinaryAssetPayload(deps, basePath, workspaceId, assetPath) {
619
+ const normalizedPath = deps.normalizeTaskDataRelativePath(assetPath);
620
+ if (!normalizedPath)
621
+ return null;
622
+ if (!deps.workspaceAssetStore)
623
+ return null;
624
+ const asset = deps.workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
625
+ if (!asset || (asset.kind !== 'image' && asset.kind !== 'file') || asset.storageProvider === 'external') {
626
+ return null;
627
+ }
628
+ const objectStorageClient = deps.getObjectStorageClient();
629
+ let buffer = null;
630
+ try {
631
+ if (asset.storageProvider === 'r2' && objectStorageClient) {
632
+ const object = await objectStorageClient.getObject(normalizedPath);
633
+ buffer = object?.body || null;
634
+ }
635
+ else {
636
+ const root = path.resolve(basePath);
637
+ const absolutePath = path.resolve(root, normalizedPath);
638
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`))
639
+ return null;
640
+ if (!fs.existsSync(absolutePath))
641
+ return null;
642
+ buffer = fs.readFileSync(absolutePath);
643
+ }
644
+ }
645
+ catch (error) {
646
+ deps.recordSyncDiagnostic(workspaceId, 'pull', 'Failed to load targeted binary asset payload.', {
647
+ source: 'assets.snapshot.loadPayload',
648
+ path: normalizedPath,
649
+ assetId: asset.assetId,
650
+ error: String(error?.message || error || '')
651
+ });
652
+ return null;
653
+ }
654
+ if (!buffer)
655
+ return null;
656
+ const { primaryLink, effectiveUpdatedAt } = resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId);
657
+ return {
658
+ path: normalizedPath,
659
+ updatedAt: effectiveUpdatedAt,
660
+ contentBase64: buffer.toString('base64'),
661
+ assetId: asset.assetId,
662
+ kind: asset.kind,
663
+ mimeType: asset.mimeType || 'application/octet-stream',
664
+ referenceNumber: asset.referenceNumber,
665
+ taskId: primaryLink?.taskId || null,
666
+ logicalName: asset.logicalName || null,
667
+ caption: primaryLink?.displayName || null,
668
+ originalFilename: asset.originalFilename || path.basename(normalizedPath),
669
+ linkRole: primaryLink?.role || (asset.kind === 'image' ? 'image' : 'attachment')
670
+ };
671
+ }
672
+ export function listWorkspaceSyncBinaryAssetDeletesSnapshot(deps, workspaceId = 'default') {
673
+ if (!deps.workspaceAssetStore)
674
+ return [];
675
+ const entries = deps.workspaceAssetStore.listAllByWorkspace(workspaceId, {
676
+ includeDeleted: true
677
+ })
678
+ .filter((asset) => ((asset.kind === 'image' || asset.kind === 'file')
679
+ && asset.storageProvider !== 'external'
680
+ && typeof asset.deletedAt === 'string'
681
+ && asset.deletedAt.trim().length > 0))
682
+ .map((asset) => {
683
+ const normalizedPath = deps.normalizeTaskDataRelativePath(asset.storageKey);
684
+ if (!normalizedPath)
685
+ return null;
686
+ return {
687
+ path: normalizedPath,
688
+ deletedAt: String(asset.deletedAt || '').trim(),
689
+ assetId: asset.assetId
690
+ };
691
+ });
692
+ return entries.filter((entry) => Boolean(entry && entry.path && entry.deletedAt));
693
+ }
694
+ export async function writeWorkspaceSyncDocument(deps, basePath, doc, workspaceId = 'default', options) {
695
+ const normalizedPath = deps.normalizeWorkspaceSyncDocumentPath(doc.path);
696
+ if (!normalizedPath)
697
+ return;
698
+ const root = path.resolve(basePath);
699
+ const targetPath = path.resolve(root, normalizedPath);
700
+ if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
701
+ return;
702
+ const objectStorageClient = deps.getObjectStorageClient();
703
+ const content = String(doc.content || '');
704
+ if (objectStorageClient) {
705
+ try {
706
+ await objectStorageClient.putObject(normalizedPath, Buffer.from(content, 'utf8'), 'text/markdown');
707
+ }
708
+ catch (error) {
709
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to write document to object storage.', {
710
+ source: 'documents.write.putObject',
711
+ path: normalizedPath,
712
+ error: String(error?.message || error || '')
713
+ });
714
+ return;
715
+ }
716
+ }
717
+ else {
718
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
719
+ fs.writeFileSync(targetPath, content, 'utf8');
720
+ }
721
+ const updatedAt = String(doc.updatedAt || '').trim();
722
+ const watermarkMs = Date.parse(updatedAt);
723
+ if (!objectStorageClient && Number.isFinite(watermarkMs) && watermarkMs > 0) {
724
+ const when = new Date(watermarkMs);
725
+ fs.utimesSync(targetPath, when, when);
726
+ }
727
+ try {
728
+ const contentHash = createHash('sha256').update(content).digest('hex');
729
+ deps.core.upsertDocumentWatermark(workspaceId, normalizedPath, contentHash, updatedAt || new Date().toISOString());
730
+ if (deps.workspaceAssetStore) {
731
+ const existing = deps.workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
732
+ const effectiveUpdatedAt = updatedAt || new Date().toISOString();
733
+ const incomingReferenceNumber = Number.isFinite(Number(doc.referenceNumber))
734
+ ? Math.max(1, Math.floor(Number(doc.referenceNumber)))
735
+ : null;
736
+ const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true
737
+ || Boolean(existing?.referenceNumber && incomingReferenceNumber && existing.referenceNumber !== incomingReferenceNumber);
738
+ const originalFilename = String(doc.originalFilename || existing?.originalFilename || path.basename(normalizedPath) || 'document.md').trim() || 'document.md';
739
+ const asset = deps.workspaceAssetStore.upsertAsset({
740
+ assetId: String(doc.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`),
741
+ workspaceId,
742
+ kind: 'document',
743
+ logicalName: typeof doc.logicalName === 'string' && doc.logicalName.trim().length > 0
744
+ ? doc.logicalName.trim()
745
+ : existing?.logicalName || null,
746
+ originalFilename,
747
+ mimeType: existing?.mimeType || 'text/markdown',
748
+ storageProvider: objectStorageClient ? 'r2' : 'local',
749
+ storageKey: normalizedPath,
750
+ contentSha256: contentHash,
751
+ sizeBytes: Buffer.byteLength(content, 'utf8'),
752
+ documentId: String(doc.documentId || existing?.documentId || `doc-${randomUUID().replace(/-/g, '')}`),
753
+ referenceNumber: incomingReferenceNumber ?? existing?.referenceNumber ?? null,
754
+ version: Number.isFinite(Number(doc.version))
755
+ ? Math.max(1, Math.floor(Number(doc.version)))
756
+ : Math.max(1, Number(existing?.version || 0) + 1),
757
+ isLatest: true,
758
+ scanStatus: existing?.scanStatus || 'clean',
759
+ scanEngine: existing?.scanEngine || null,
760
+ scanDetails: existing?.scanDetails || null,
761
+ createdByUserId: existing?.createdByUserId || null,
762
+ createdAt: existing?.createdAt || effectiveUpdatedAt,
763
+ updatedAt: effectiveUpdatedAt,
764
+ deletedAt: null,
765
+ purgeAfter: null,
766
+ allowReferenceNumberReassignment
767
+ });
768
+ const taskId = String(doc.taskId || '').trim();
769
+ reconcileWorkspaceSyncLinks(deps, {
770
+ workspaceId,
771
+ assetId: asset.assetId,
772
+ taskId,
773
+ displayName: typeof doc.caption === 'string' && doc.caption.trim().length > 0 ? doc.caption.trim() : null,
774
+ role: doc.linkRole === 'reference' ? 'reference' : 'attachment',
775
+ updatedAt: effectiveUpdatedAt
776
+ });
777
+ }
778
+ }
779
+ catch (error) {
780
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to persist document watermark after write.', {
781
+ source: 'documents.write.upsertWatermark',
782
+ path: normalizedPath,
783
+ error: String(error?.message || error || '')
784
+ });
785
+ }
786
+ deps.workspaceDocumentSnapshotCache.get(workspaceId)?.delete(normalizedPath);
787
+ }
788
+ export async function writeWorkspaceSyncBinaryAsset(deps, basePath, asset, workspaceId = 'default', options) {
789
+ const normalizedPath = deps.normalizeTaskDataRelativePath(asset.path);
790
+ if (!normalizedPath)
791
+ return;
792
+ const root = path.resolve(basePath);
793
+ const targetPath = path.resolve(root, normalizedPath);
794
+ if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
795
+ return;
796
+ const objectStorageClient = deps.getObjectStorageClient();
797
+ const buffer = Buffer.from(String(asset.contentBase64 || ''), 'base64');
798
+ if (objectStorageClient) {
799
+ try {
800
+ await objectStorageClient.putObject(normalizedPath, buffer, String(asset.mimeType || 'application/octet-stream').trim() || 'application/octet-stream');
801
+ }
802
+ catch (error) {
803
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to write binary asset to object storage.', {
804
+ source: 'assets.write.putObject',
805
+ path: normalizedPath,
806
+ error: String(error?.message || error || '')
807
+ });
808
+ return;
809
+ }
810
+ }
811
+ else {
812
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
813
+ fs.writeFileSync(targetPath, buffer);
814
+ }
815
+ const updatedAt = String(asset.updatedAt || '').trim();
816
+ const watermarkMs = Date.parse(updatedAt);
817
+ if (!objectStorageClient && Number.isFinite(watermarkMs) && watermarkMs > 0) {
818
+ const when = new Date(watermarkMs);
819
+ fs.utimesSync(targetPath, when, when);
820
+ }
821
+ if (!deps.workspaceAssetStore)
822
+ return;
823
+ try {
824
+ const existing = deps.workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
825
+ const effectiveUpdatedAt = updatedAt || new Date().toISOString();
826
+ const incomingReferenceNumber = Number.isFinite(Number(asset.referenceNumber))
827
+ ? Math.max(1, Math.floor(Number(asset.referenceNumber)))
828
+ : null;
829
+ const allowReferenceNumberReassignment = options?.allowReferenceNumberReassignment === true
830
+ || Boolean(existing?.referenceNumber && incomingReferenceNumber && existing.referenceNumber !== incomingReferenceNumber);
831
+ const assetRecord = deps.workspaceAssetStore.upsertAsset({
832
+ assetId: String(asset.assetId || existing?.assetId || `asset-${randomUUID().replace(/-/g, '')}`),
833
+ workspaceId,
834
+ kind: asset.kind === 'image' ? 'image' : 'file',
835
+ logicalName: typeof asset.logicalName === 'string' && asset.logicalName.trim().length > 0
836
+ ? asset.logicalName.trim()
837
+ : existing?.logicalName || null,
838
+ originalFilename: String(asset.originalFilename || existing?.originalFilename || path.basename(normalizedPath) || 'asset').trim() || 'asset',
839
+ mimeType: String(asset.mimeType || existing?.mimeType || 'application/octet-stream').trim() || 'application/octet-stream',
840
+ storageProvider: objectStorageClient ? 'r2' : 'local',
841
+ storageKey: normalizedPath,
842
+ contentSha256: createHash('sha256').update(buffer).digest('hex'),
843
+ sizeBytes: buffer.length,
844
+ documentId: null,
845
+ referenceNumber: incomingReferenceNumber ?? existing?.referenceNumber ?? null,
846
+ version: null,
847
+ isLatest: true,
848
+ scanStatus: existing?.scanStatus || 'clean',
849
+ scanEngine: existing?.scanEngine || null,
850
+ scanDetails: existing?.scanDetails || null,
851
+ createdByUserId: existing?.createdByUserId || null,
852
+ createdAt: existing?.createdAt || effectiveUpdatedAt,
853
+ updatedAt: effectiveUpdatedAt,
854
+ deletedAt: null,
855
+ purgeAfter: null,
856
+ allowReferenceNumberReassignment
857
+ });
858
+ const taskId = String(asset.taskId || '').trim();
859
+ reconcileWorkspaceSyncLinks(deps, {
860
+ workspaceId,
861
+ assetId: assetRecord.assetId,
862
+ taskId,
863
+ displayName: typeof asset.caption === 'string' && asset.caption.trim().length > 0 ? asset.caption.trim() : null,
864
+ role: asset.linkRole === 'reference' ? 'reference' : asset.kind === 'image' ? 'image' : 'attachment',
865
+ updatedAt: effectiveUpdatedAt
866
+ });
867
+ }
868
+ catch (error) {
869
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to persist binary asset metadata after write.', {
870
+ source: 'assets.write.upsertAsset',
871
+ path: normalizedPath,
872
+ assetId: typeof asset.assetId === 'string' ? asset.assetId : null,
873
+ kind: asset.kind,
874
+ error: String(error?.message || error || '')
875
+ });
876
+ }
877
+ }
878
+ export function deleteWorkspaceSyncBinaryAsset(deps, basePath, assetPath, workspaceId = 'default') {
879
+ const normalizedPath = deps.normalizeTaskDataRelativePath(assetPath);
880
+ if (!normalizedPath)
881
+ return;
882
+ const root = path.resolve(basePath);
883
+ const targetPath = path.resolve(root, normalizedPath);
884
+ const objectStorageClient = deps.getObjectStorageClient();
885
+ try {
886
+ if (objectStorageClient) {
887
+ void objectStorageClient.deleteObject(normalizedPath);
888
+ }
889
+ else if (targetPath === root || targetPath.startsWith(`${root}${path.sep}`)) {
890
+ if (fs.existsSync(targetPath))
891
+ fs.unlinkSync(targetPath);
892
+ }
893
+ }
894
+ catch (error) {
895
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete workspace sync binary asset.', {
896
+ source: 'assets.delete.file',
897
+ path: normalizedPath,
898
+ error: String(error?.message || error || '')
899
+ });
900
+ }
901
+ if (deps.workspaceAssetStore) {
902
+ const existing = deps.workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
903
+ if (existing) {
904
+ const deletedAt = new Date().toISOString();
905
+ deps.workspaceAssetStore.markDeleted(existing.assetId, workspaceId, deletedAt);
906
+ if (existing.kind === 'image') {
907
+ deps.core.deleteAnnotatedAttachmentSessionsForImage({
908
+ workspaceId,
909
+ imageAssetId: existing.assetId,
910
+ deletedAt,
911
+ });
912
+ }
913
+ const links = deps.workspaceAssetStore.listLinksForAsset(existing.assetId, workspaceId, { includeDeleted: true });
914
+ for (const link of links) {
915
+ deps.workspaceAssetStore.deleteLink({
916
+ workspaceId,
917
+ assetId: existing.assetId,
918
+ taskId: link.taskId,
919
+ role: link.role
920
+ });
921
+ }
922
+ }
923
+ }
924
+ }
925
+ export function deleteWorkspaceSyncDocument(deps, basePath, docPath, workspaceId = 'default') {
926
+ const normalizedPath = deps.normalizeWorkspaceSyncDocumentPath(docPath);
927
+ if (!normalizedPath)
928
+ return;
929
+ const root = path.resolve(basePath);
930
+ const targetPath = path.resolve(root, normalizedPath);
931
+ if (targetPath !== root && !targetPath.startsWith(`${root}${path.sep}`))
932
+ return;
933
+ const objectStorageClient = deps.getObjectStorageClient();
934
+ try {
935
+ if (objectStorageClient) {
936
+ void objectStorageClient.deleteObject(normalizedPath);
937
+ }
938
+ else if (fs.existsSync(targetPath)) {
939
+ fs.unlinkSync(targetPath);
940
+ }
941
+ }
942
+ catch (error) {
943
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete workspace sync document.', {
944
+ source: 'documents.delete.file',
945
+ path: normalizedPath,
946
+ error: String(error?.message || error || '')
947
+ });
948
+ }
949
+ try {
950
+ deps.core.deleteDocumentWatermark(workspaceId, normalizedPath);
951
+ if (deps.workspaceAssetStore) {
952
+ const existing = deps.workspaceAssetStore.getByStorageKey(normalizedPath, workspaceId);
953
+ if (existing) {
954
+ const deletedAt = new Date().toISOString();
955
+ deps.workspaceAssetStore.markDeleted(existing.assetId, workspaceId, deletedAt);
956
+ if (existing.kind === 'image') {
957
+ deps.core.deleteAnnotatedAttachmentSessionsForImage({
958
+ workspaceId,
959
+ imageAssetId: existing.assetId,
960
+ deletedAt,
961
+ });
962
+ }
963
+ const links = deps.workspaceAssetStore.listLinksForAsset(existing.assetId, workspaceId, { includeDeleted: true });
964
+ for (const link of links) {
965
+ deps.workspaceAssetStore.deleteLink({
966
+ workspaceId,
967
+ assetId: existing.assetId,
968
+ taskId: link.taskId,
969
+ role: link.role
970
+ });
971
+ }
972
+ }
973
+ }
974
+ }
975
+ catch (error) {
976
+ deps.recordSyncDiagnostic(workspaceId, 'apply', 'Failed to delete document watermark.', {
977
+ source: 'documents.delete.watermark',
978
+ path: normalizedPath,
979
+ error: String(error?.message || error || '')
980
+ });
981
+ }
982
+ deps.workspaceDocumentSnapshotCache.get(workspaceId)?.delete(normalizedPath);
983
+ }
984
+ export function createContentSyncState(deps) {
985
+ return {
986
+ resolveCanonicalAssetSyncLinkState: (asset, workspaceId) => resolveCanonicalAssetSyncLinkState(deps, asset, workspaceId),
987
+ getCanonicalSyncDocumentMetadata: (relativePath, workspaceId) => getCanonicalSyncDocumentMetadata(deps, relativePath, workspaceId),
988
+ hasDocumentSyncMetadataDrift: (workspaceId, doc, options) => hasDocumentSyncMetadataDrift(deps, workspaceId, doc, options),
989
+ hasBinaryAssetSyncMetadataDrift: (workspaceId, asset, options) => hasBinaryAssetSyncMetadataDrift(deps, workspaceId, asset, options),
990
+ reconcileWorkspaceSyncLinks: (input) => reconcileWorkspaceSyncLinks(deps, input),
991
+ listWorkspaceSyncDocumentDeletesSnapshot: (workspaceId = 'default') => listWorkspaceSyncDocumentDeletesSnapshot(deps, workspaceId),
992
+ listWorkspaceSyncDocumentIndex: (basePath, workspaceId = 'default') => listWorkspaceSyncDocumentIndex(deps, basePath, workspaceId),
993
+ listWorkspaceSyncDocumentsSnapshot: (basePath, workspaceId = 'default') => listWorkspaceSyncDocumentsSnapshot(deps, basePath, workspaceId),
994
+ loadWorkspaceSyncDocumentPayload: (basePath, workspaceId, relativePath) => loadWorkspaceSyncDocumentPayload(deps, basePath, workspaceId, relativePath),
995
+ listWorkspaceSyncBinaryAssetIndex: (workspaceId = 'default') => listWorkspaceSyncBinaryAssetIndex(deps, workspaceId),
996
+ listWorkspaceSyncBinaryAssetsSnapshot: (basePath, workspaceId = 'default') => listWorkspaceSyncBinaryAssetsSnapshot(deps, basePath, workspaceId),
997
+ loadWorkspaceSyncBinaryAssetPayload: (basePath, workspaceId, assetPath) => loadWorkspaceSyncBinaryAssetPayload(deps, basePath, workspaceId, assetPath),
998
+ listWorkspaceSyncBinaryAssetDeletesSnapshot: (workspaceId = 'default') => listWorkspaceSyncBinaryAssetDeletesSnapshot(deps, workspaceId),
999
+ writeWorkspaceSyncDocument: (basePath, doc, workspaceId = 'default', options) => writeWorkspaceSyncDocument(deps, basePath, doc, workspaceId, options),
1000
+ writeWorkspaceSyncBinaryAsset: (basePath, asset, workspaceId = 'default', options) => writeWorkspaceSyncBinaryAsset(deps, basePath, asset, workspaceId, options),
1001
+ deleteWorkspaceSyncBinaryAsset: (basePath, assetPath, workspaceId = 'default') => deleteWorkspaceSyncBinaryAsset(deps, basePath, assetPath, workspaceId),
1002
+ deleteWorkspaceSyncDocument: (basePath, docPath, workspaceId = 'default') => deleteWorkspaceSyncDocument(deps, basePath, docPath, workspaceId),
1003
+ };
1004
+ }