collabmd 0.1.19 → 0.1.21

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 (53) hide show
  1. package/README.md +13 -11
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  6. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  7. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  8. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  9. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  10. package/public/assets/js/excalidraw-editor.js +1 -1
  11. package/public/assets/js/main.js +102 -75
  12. package/public/assets/js/preview-render-worker.js +15 -15
  13. package/public/index.html +14 -4
  14. package/src/client/application/app-shell/git-feature.js +86 -19
  15. package/src/client/application/app-shell/ui-feature.js +4 -0
  16. package/src/client/application/app-shell-elements.js +1 -0
  17. package/src/client/bootstrap/collabmd-app-shell.js +16 -0
  18. package/src/client/domain/vault-utils.js +2 -2
  19. package/src/client/excalidraw-editor.js +2 -1
  20. package/src/client/infrastructure/editor-session.js +4 -0
  21. package/src/client/infrastructure/editor-view-adapter.js +245 -7
  22. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  23. package/src/client/presentation/backlinks-panel.js +157 -101
  24. package/src/client/presentation/comment-ui-controller.js +233 -10
  25. package/src/client/presentation/file-explorer-controller.js +15 -3
  26. package/src/client/presentation/file-explorer-view.js +152 -11
  27. package/src/client/presentation/git-panel-controller.js +73 -0
  28. package/src/client/presentation/outline-controller.js +25 -0
  29. package/src/client/styles/style.css +184 -9
  30. package/src/domain/wiki-link-resolver.js +16 -5
  31. package/src/domain/workspace-change.js +68 -0
  32. package/src/domain/workspace-room.js +3 -0
  33. package/src/server/create-app-server.js +41 -10
  34. package/src/server/domain/backlink-index.js +94 -1
  35. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  36. package/src/server/domain/collaboration/room-registry.js +64 -10
  37. package/src/server/infrastructure/git/errors.js +4 -1
  38. package/src/server/infrastructure/git/git-service.js +215 -1
  39. package/src/server/infrastructure/git/responses.js +12 -19
  40. package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
  41. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  42. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  43. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  44. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  45. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  46. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  47. package/src/server/infrastructure/http/http-response.js +65 -28
  48. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  49. package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
  50. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  51. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  52. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  53. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
@@ -6,6 +6,7 @@ import { parseNameStatusOutput } from './parsers.js';
6
6
  import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
7
7
  import { GitStatusService } from './status-service.js';
8
8
  import { GitUntrackedFileService } from './untracked-files.js';
9
+ import { PullBackupStore } from '../persistence/pull-backup-store.js';
9
10
 
10
11
  export class GitService {
11
12
  constructor({
@@ -36,6 +37,7 @@ export class GitService {
36
37
  statusService: this.statusService,
37
38
  untrackedFileService: this.untrackedFileService,
38
39
  });
40
+ this.pullBackupStore = new PullBackupStore({ vaultDir });
39
41
  }
40
42
 
41
43
  async isGitRepo() {
@@ -55,6 +57,14 @@ export class GitService {
55
57
  return this.statusService.getStatus(options);
56
58
  }
57
59
 
60
+ async listPullBackups() {
61
+ if (!(await this.isGitRepo())) {
62
+ return [];
63
+ }
64
+
65
+ return this.pullBackupStore.listBackups();
66
+ }
67
+
58
68
  async stageFile(path) {
59
69
  const normalizedPath = normalizeRelativeGitPath(path);
60
70
  await this.commandRunner.execGit(['add', '-A', '--', normalizedPath]);
@@ -131,7 +141,45 @@ export class GitService {
131
141
  }
132
142
 
133
143
  const beforeRef = await this.getHeadRef();
134
- const output = await this.commandRunner.execGit(['pull', '--ff-only']);
144
+ await this.fetchUpstream(status.branch.upstream);
145
+
146
+ const targetRef = await this.resolveRef(status.branch.upstream);
147
+ if (!targetRef) {
148
+ throw createGitRequestError(409, 'Unable to resolve upstream branch for pull');
149
+ }
150
+
151
+ if (!(await this.canFastForwardTo(targetRef))) {
152
+ throw createGitRequestError(
153
+ 409,
154
+ 'Cannot pull because local and remote commits have diverged; fast-forward only pull is not possible.',
155
+ 'pull_diverged_ff_only',
156
+ );
157
+ }
158
+
159
+ const dirtyEntries = this.collectDirtyEntries(status);
160
+ const upstreamWorkspaceChange = await this.createWorkspaceChangeFromRefs(beforeRef, targetRef);
161
+ const overlappingEntries = this.findOverlappingDirtyEntries({
162
+ dirtyEntries,
163
+ workspaceChange: upstreamWorkspaceChange,
164
+ });
165
+
166
+ let pullBackup = null;
167
+ if (overlappingEntries.length > 0) {
168
+ pullBackup = await this.backupAndClearOverlappingEntries({
169
+ beforeRef,
170
+ branchName: status.branch?.name ?? null,
171
+ entries: overlappingEntries,
172
+ targetRef,
173
+ });
174
+ }
175
+
176
+ let output;
177
+ try {
178
+ output = await this.commandRunner.execGit(['pull', '--ff-only', '--autostash']);
179
+ } catch (error) {
180
+ throw await this.classifyPullError(error);
181
+ }
182
+
135
183
  const afterRef = await this.getHeadRef();
136
184
  this.invalidateStatusCache();
137
185
  return {
@@ -139,6 +187,7 @@ export class GitService {
139
187
  beforeRef,
140
188
  ok: true,
141
189
  output: output.trim(),
190
+ pullBackup,
142
191
  workspaceChange: await this.createWorkspaceChangeFromRefs(beforeRef, afterRef),
143
192
  };
144
193
  }
@@ -202,6 +251,171 @@ export class GitService {
202
251
  }
203
252
  }
204
253
 
254
+ async resolveRef(ref) {
255
+ try {
256
+ const output = await this.commandRunner.execGit(['rev-parse', ref]);
257
+ return output.trim() || null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ async fetchUpstream(upstreamRef) {
264
+ const remoteName = String(upstreamRef ?? '').split('/')[0] || null;
265
+ await this.commandRunner.execGit(remoteName
266
+ ? ['fetch', '--prune', remoteName]
267
+ : ['fetch', '--prune']);
268
+ }
269
+
270
+ async canFastForwardTo(targetRef) {
271
+ const headRef = await this.getHeadRef();
272
+ if (!headRef || !targetRef || headRef === targetRef) {
273
+ return true;
274
+ }
275
+
276
+ try {
277
+ await this.commandRunner.execGit(['merge-base', '--is-ancestor', headRef, targetRef]);
278
+ return true;
279
+ } catch (error) {
280
+ if (error?.code === 1) {
281
+ return false;
282
+ }
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ collectDirtyEntries(status = {}) {
288
+ const entries = new Map();
289
+ for (const section of status.sections ?? []) {
290
+ for (const file of section.files ?? []) {
291
+ if (!file?.path) {
292
+ continue;
293
+ }
294
+
295
+ const existing = entries.get(file.path) ?? {
296
+ hasStagedChanges: false,
297
+ hasWorkingTreeChanges: false,
298
+ hasTrackedChanges: false,
299
+ isUntracked: false,
300
+ oldPath: null,
301
+ path: file.path,
302
+ touchPaths: new Set(),
303
+ };
304
+
305
+ existing.isUntracked = existing.isUntracked || file.scope === 'untracked';
306
+ existing.hasStagedChanges = existing.hasStagedChanges || file.scope === 'staged';
307
+ existing.hasWorkingTreeChanges = existing.hasWorkingTreeChanges || file.scope === 'working-tree';
308
+ existing.hasTrackedChanges = existing.hasTrackedChanges || file.scope === 'staged' || file.scope === 'working-tree';
309
+ if (!existing.oldPath && file.oldPath) {
310
+ existing.oldPath = file.oldPath;
311
+ }
312
+ existing.touchPaths.add(file.path);
313
+ if (file.oldPath) {
314
+ existing.touchPaths.add(file.oldPath);
315
+ }
316
+ entries.set(file.path, existing);
317
+ }
318
+ }
319
+
320
+ return Array.from(entries.values());
321
+ }
322
+
323
+ findOverlappingDirtyEntries({ dirtyEntries = [], workspaceChange = createEmptyWorkspaceChange() } = {}) {
324
+ const upstreamTouchedPaths = new Set([
325
+ ...(workspaceChange.changedPaths ?? []),
326
+ ...(workspaceChange.deletedPaths ?? []),
327
+ ...((workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry.oldPath, entry.newPath])),
328
+ ].filter(Boolean));
329
+
330
+ return dirtyEntries.filter((entry) => Array.from(entry.touchPaths).some((path) => upstreamTouchedPaths.has(path)));
331
+ }
332
+
333
+ async createPatchForEntry(entry, { cached = false } = {}) {
334
+ const pathspecs = Array.from(entry?.touchPaths ?? []).filter(Boolean);
335
+ if (pathspecs.length === 0) {
336
+ return null;
337
+ }
338
+
339
+ const output = await this.commandRunner.execGit([
340
+ 'diff',
341
+ '--binary',
342
+ '--find-renames',
343
+ ...(cached ? ['--cached'] : []),
344
+ '--',
345
+ ...pathspecs,
346
+ ]);
347
+
348
+ return output || null;
349
+ }
350
+
351
+ async backupAndClearOverlappingEntries({
352
+ beforeRef,
353
+ branchName = null,
354
+ entries = [],
355
+ targetRef = null,
356
+ } = {}) {
357
+ const backupEntries = await Promise.all(entries.map(async (entry) => ({
358
+ ...entry,
359
+ stagedPatchContent: entry.hasStagedChanges
360
+ ? await this.createPatchForEntry(entry, { cached: true })
361
+ : null,
362
+ worktreePatchContent: entry.hasWorkingTreeChanges
363
+ ? await this.createPatchForEntry(entry, { cached: false })
364
+ : null,
365
+ })));
366
+
367
+ const pullBackup = await this.pullBackupStore.createBackup({
368
+ branch: branchName,
369
+ entries: backupEntries,
370
+ headRef: beforeRef,
371
+ targetRef,
372
+ });
373
+
374
+ for (const entry of backupEntries) {
375
+ if (entry.isUntracked && !entry.hasTrackedChanges) {
376
+ await this.commandRunner.execGit(['clean', '-f', '--', entry.path]);
377
+ continue;
378
+ }
379
+
380
+ await this.restorePathToHead(entry.path);
381
+ }
382
+
383
+ return pullBackup;
384
+ }
385
+
386
+ async restorePathToHead(path) {
387
+ const normalizedPath = normalizeRelativeGitPath(path);
388
+ const sourceRef = 'HEAD';
389
+ const existsOnSource = await this.pathExistsAtRef(sourceRef, normalizedPath);
390
+
391
+ if (existsOnSource) {
392
+ await this.commandRunner.execGit(['restore', '--source', sourceRef, '--staged', '--worktree', '--', normalizedPath]);
393
+ return;
394
+ }
395
+
396
+ await this.commandRunner.execGit(['rm', '-f', '--ignore-unmatch', '--', normalizedPath]);
397
+ await this.commandRunner.execGit(['clean', '-f', '--', normalizedPath]);
398
+ }
399
+
400
+ async hasConflictedStatus() {
401
+ const status = await this.getStatus({ force: true });
402
+ return (status.sections ?? [])
403
+ .flatMap((section) => section.files ?? [])
404
+ .some((file) => file?.status === 'conflicted');
405
+ }
406
+
407
+ async classifyPullError(error) {
408
+ if (await this.hasConflictedStatus()) {
409
+ return createGitRequestError(
410
+ 409,
411
+ 'Pull applied remote updates, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.',
412
+ 'pull_conflicted_after_autostash',
413
+ );
414
+ }
415
+
416
+ return error;
417
+ }
418
+
205
419
  async createWorkspaceChangeFromRefs(beforeRef, afterRef) {
206
420
  if (!afterRef || beforeRef === afterRef) {
207
421
  return createEmptyWorkspaceChange();
@@ -1,3 +1,8 @@
1
+ import {
2
+ createEmptyWorkspaceChange as createSharedEmptyWorkspaceChange,
3
+ createWorkspaceChange as createSharedWorkspaceChange,
4
+ } from '../../../domain/workspace-change.js';
5
+
1
6
  const STATUS_MAP = {
2
7
  A: { code: 'A', label: 'added', status: 'added' },
3
8
  C: { code: 'C', label: 'copied', status: 'copied' },
@@ -43,12 +48,7 @@ export function createEmptyBranchStatus() {
43
48
  }
44
49
 
45
50
  export function createEmptyWorkspaceChange() {
46
- return {
47
- changedPaths: [],
48
- deletedPaths: [],
49
- refreshExplorer: true,
50
- renamedPaths: [],
51
- };
51
+ return createSharedEmptyWorkspaceChange();
52
52
  }
53
53
 
54
54
  export function createEmptyStatusResponse() {
@@ -92,17 +92,10 @@ export function createWorkspaceChange({
92
92
  refreshExplorer = true,
93
93
  renamedPaths = [],
94
94
  } = {}) {
95
- return {
96
- changedPaths: Array.from(new Set((changedPaths ?? []).filter(Boolean))),
97
- deletedPaths: Array.from(new Set((deletedPaths ?? []).filter(Boolean))),
98
- refreshExplorer: Boolean(refreshExplorer),
99
- renamedPaths: Array.from(new Map(
100
- (renamedPaths ?? [])
101
- .filter((entry) => entry?.oldPath && entry?.newPath && entry.oldPath !== entry.newPath)
102
- .map((entry) => [`${entry.oldPath}:${entry.newPath}`, {
103
- newPath: entry.newPath,
104
- oldPath: entry.oldPath,
105
- }]),
106
- ).values()),
107
- };
95
+ return createSharedWorkspaceChange({
96
+ changedPaths,
97
+ deletedPaths,
98
+ refreshExplorer,
99
+ renamedPaths,
100
+ });
108
101
  }
@@ -1,7 +1,7 @@
1
1
  import { getRequestErrorStatusCode } from './http-errors.js';
2
2
  import { jsonResponse } from './http-response.js';
3
3
  import { parseJsonBody } from './request-body.js';
4
- import { createEmptyWorkspaceChange } from '../git/responses.js';
4
+ import { createEmptyWorkspaceChange, hasWorkspaceMutation } from '../../../domain/workspace-change.js';
5
5
 
6
6
  async function parseRequiredBody(req, res, fieldName) {
7
7
  const body = await parseJsonBody(req);
@@ -16,7 +16,11 @@ async function parseRequiredBody(req, res, fieldName) {
16
16
  function handleGitError(req, res, error, logMessage, fallbackMessage) {
17
17
  const statusCode = getRequestErrorStatusCode(error);
18
18
  if (statusCode) {
19
- jsonResponse(req, res, statusCode, { error: error.message });
19
+ const payload = { error: error.message };
20
+ if (typeof error?.requestCode === 'string') {
21
+ payload.code = error.requestCode;
22
+ }
23
+ jsonResponse(req, res, statusCode, payload);
20
24
  return true;
21
25
  }
22
26
 
@@ -25,19 +29,16 @@ function handleGitError(req, res, error, logMessage, fallbackMessage) {
25
29
  return true;
26
30
  }
27
31
 
28
- function hasWorkspaceMutation(workspaceChange = {}) {
29
- return Boolean(
30
- (workspaceChange.changedPaths?.length ?? 0) > 0
31
- || (workspaceChange.deletedPaths?.length ?? 0) > 0
32
- || (workspaceChange.renamedPaths?.length ?? 0) > 0,
33
- );
32
+ function readRequestId(req) {
33
+ const value = String(req.headers['x-collabmd-request-id'] || '').trim();
34
+ return value ? value.slice(0, 120) : null;
34
35
  }
35
36
 
36
37
  async function applyWorkspaceMutationEffects({
37
- backlinkIndex,
38
+ action,
39
+ req,
38
40
  responsePayload,
39
- roomRegistry,
40
- vaultFileStore,
41
+ workspaceMutationCoordinator,
41
42
  }) {
42
43
  const workspaceChange = responsePayload?.workspaceChange ?? createEmptyWorkspaceChange();
43
44
  responsePayload.workspaceChange = workspaceChange;
@@ -46,17 +47,19 @@ async function applyWorkspaceMutationEffects({
46
47
  return responsePayload;
47
48
  }
48
49
 
49
- await vaultFileStore?.reconcileSidecars?.(workspaceChange);
50
- await backlinkIndex?.build?.();
51
- await roomRegistry?.reconcileWorkspaceChange?.(workspaceChange);
50
+ await workspaceMutationCoordinator?.apply?.({
51
+ action,
52
+ origin: 'git',
53
+ requestId: readRequestId(req),
54
+ sourceRef: responsePayload?.sourceRef ?? null,
55
+ workspaceChange,
56
+ });
52
57
  return responsePayload;
53
58
  }
54
59
 
55
60
  export function createGitApiCommandHandler({
56
- backlinkIndex = null,
57
61
  gitService,
58
- roomRegistry = null,
59
- vaultFileStore = null,
62
+ workspaceMutationCoordinator = null,
60
63
  }) {
61
64
  return async function handleGitApiCommand(req, res, requestUrl) {
62
65
  if (requestUrl.pathname === '/api/git/stage' && req.method === 'POST') {
@@ -67,10 +70,12 @@ export function createGitApiCommandHandler({
67
70
  }
68
71
 
69
72
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
70
- backlinkIndex,
71
- responsePayload: await gitService.stageFile(body.path),
72
- roomRegistry,
73
- vaultFileStore,
73
+ action: 'stage',
74
+ req,
75
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
76
+ () => gitService.stageFile(body.path),
77
+ ),
78
+ workspaceMutationCoordinator,
74
79
  }));
75
80
  } catch (error) {
76
81
  handleGitError(req, res, error, '[api] Failed to stage git file:', 'Failed to stage git file');
@@ -86,10 +91,12 @@ export function createGitApiCommandHandler({
86
91
  }
87
92
 
88
93
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
89
- backlinkIndex,
90
- responsePayload: await gitService.unstageFile(body.path),
91
- roomRegistry,
92
- vaultFileStore,
94
+ action: 'unstage',
95
+ req,
96
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
97
+ () => gitService.unstageFile(body.path),
98
+ ),
99
+ workspaceMutationCoordinator,
93
100
  }));
94
101
  } catch (error) {
95
102
  handleGitError(req, res, error, '[api] Failed to unstage git file:', 'Failed to unstage git file');
@@ -105,12 +112,14 @@ export function createGitApiCommandHandler({
105
112
  }
106
113
 
107
114
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
108
- backlinkIndex,
109
- responsePayload: await gitService.commitStaged({
110
- message: body.message,
111
- }),
112
- roomRegistry,
113
- vaultFileStore,
115
+ action: 'commit',
116
+ req,
117
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
118
+ () => gitService.commitStaged({
119
+ message: body.message,
120
+ }),
121
+ ),
122
+ workspaceMutationCoordinator,
114
123
  }));
115
124
  } catch (error) {
116
125
  handleGitError(req, res, error, '[api] Failed to commit staged changes:', 'Failed to commit staged changes');
@@ -121,10 +130,12 @@ export function createGitApiCommandHandler({
121
130
  if (requestUrl.pathname === '/api/git/push' && req.method === 'POST') {
122
131
  try {
123
132
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
124
- backlinkIndex,
125
- responsePayload: await gitService.pushBranch(),
126
- roomRegistry,
127
- vaultFileStore,
133
+ action: 'push',
134
+ req,
135
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
136
+ () => gitService.pushBranch(),
137
+ ),
138
+ workspaceMutationCoordinator,
128
139
  }));
129
140
  } catch (error) {
130
141
  handleGitError(req, res, error, '[api] Failed to push git branch:', 'Failed to push git branch');
@@ -135,10 +146,12 @@ export function createGitApiCommandHandler({
135
146
  if (requestUrl.pathname === '/api/git/pull' && req.method === 'POST') {
136
147
  try {
137
148
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
138
- backlinkIndex,
139
- responsePayload: await gitService.pullBranch(),
140
- roomRegistry,
141
- vaultFileStore,
149
+ action: 'pull',
150
+ req,
151
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
152
+ () => gitService.pullBranch(),
153
+ ),
154
+ workspaceMutationCoordinator,
142
155
  }));
143
156
  } catch (error) {
144
157
  handleGitError(req, res, error, '[api] Failed to pull git branch:', 'Failed to pull git branch');
@@ -154,10 +167,12 @@ export function createGitApiCommandHandler({
154
167
  }
155
168
 
156
169
  jsonResponse(req, res, 200, await applyWorkspaceMutationEffects({
157
- backlinkIndex,
158
- responsePayload: await gitService.resetFileToHead(body.path),
159
- roomRegistry,
160
- vaultFileStore,
170
+ action: 'reset-file',
171
+ req,
172
+ responsePayload: await workspaceMutationCoordinator.runManagedWorkspaceMutation(
173
+ () => gitService.resetFileToHead(body.path),
174
+ ),
175
+ workspaceMutationCoordinator,
161
176
  }));
162
177
  } catch (error) {
163
178
  handleGitError(req, res, error, '[api] Failed to reset git file:', 'Failed to reset git file');
@@ -7,6 +7,7 @@ export function createGitApiHandler({
7
7
  gitService = null,
8
8
  roomRegistry = null,
9
9
  vaultFileStore = null,
10
+ workspaceMutationCoordinator = null,
10
11
  }) {
11
12
  const handleGitApiQuery = createGitApiQueryHandler({ gitService });
12
13
  const handleGitApiCommand = createGitApiCommandHandler({
@@ -14,6 +15,7 @@ export function createGitApiHandler({
14
15
  gitService,
15
16
  roomRegistry,
16
17
  vaultFileStore,
18
+ workspaceMutationCoordinator,
17
19
  });
18
20
 
19
21
  return async function handleGitApi(req, res, requestUrl) {
@@ -21,7 +21,11 @@ function isTruthyParam(value) {
21
21
  function handleGitError(req, res, error, message, fallback) {
22
22
  const statusCode = getRequestErrorStatusCode(error);
23
23
  if (statusCode) {
24
- jsonResponse(req, res, statusCode, { error: error.message });
24
+ const payload = { error: error.message };
25
+ if (typeof error?.requestCode === 'string') {
26
+ payload.code = error.requestCode;
27
+ }
28
+ jsonResponse(req, res, statusCode, payload);
25
29
  return true;
26
30
  }
27
31
 
@@ -57,6 +61,17 @@ export function createGitApiQueryHandler({ gitService }) {
57
61
  return true;
58
62
  }
59
63
 
64
+ if (requestUrl.pathname === '/api/git/pull-backups' && req.method === 'GET') {
65
+ try {
66
+ jsonResponse(req, res, 200, {
67
+ backups: await gitService.listPullBackups(),
68
+ });
69
+ } catch (error) {
70
+ handleGitError(req, res, error, '[api] Failed to read pull backups:', 'Failed to read pull backups');
71
+ }
72
+ return true;
73
+ }
74
+
60
75
  return false;
61
76
  };
62
77
  }
@@ -42,6 +42,8 @@ export function createRequestHandler(
42
42
  roomRegistry = null,
43
43
  plantUmlRenderer = null,
44
44
  gitService = null,
45
+ workspaceMutationCoordinator = null,
46
+ fileSystemSyncService = null,
45
47
  ) {
46
48
  const handleEsmProxy = createEsmProxyHandler();
47
49
  const handleStaticRequest = createStaticHandler(config, authService);
@@ -51,12 +53,14 @@ export function createRequestHandler(
51
53
  gitService,
52
54
  roomRegistry,
53
55
  vaultFileStore,
56
+ workspaceMutationCoordinator,
54
57
  });
55
58
  const handleVaultApi = createVaultApiHandler({
56
59
  backlinkIndex,
57
60
  plantUmlRenderer,
58
61
  roomRegistry,
59
62
  vaultFileStore,
63
+ workspaceMutationCoordinator,
60
64
  });
61
65
 
62
66
  return async function handleRequest(req, res) {
@@ -103,8 +107,11 @@ export function createRequestHandler(
103
107
  }
104
108
 
105
109
  if (config.nodeEnv === 'test' && requestUrl.pathname === '/api/test/reset-state' && req.method === 'POST') {
110
+ await fileSystemSyncService?.resetForExternalStateChange?.();
106
111
  await roomRegistry?.reset?.();
107
112
  await backlinkIndex?.build?.();
113
+ await workspaceMutationCoordinator?.initialize?.();
114
+ await fileSystemSyncService?.resetForExternalStateChange?.();
108
115
  jsonResponse(req, res, 200, { ok: true });
109
116
  return;
110
117
  }