@bevel-software/platform-core-backend 0.36.1 → 0.37.0

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 (71) hide show
  1. package/dist/core/create-core-services.d.ts.map +1 -1
  2. package/dist/core/create-core-services.js +5 -2
  3. package/dist/core/create-core-services.js.map +1 -1
  4. package/dist/modules/database/connection.d.ts.map +1 -1
  5. package/dist/modules/database/connection.js +11 -0
  6. package/dist/modules/database/connection.js.map +1 -1
  7. package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
  8. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  9. package/dist/modules/kb-fs/file-change-notifier.js +6 -8
  10. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
  12. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  13. package/dist/modules/kb-fs/locking-filesystem.js +4 -12
  14. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  15. package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
  16. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
  17. package/dist/modules/onboarding/starter-pack.service.js +1 -1
  18. package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
  19. package/dist/modules/version-control/git/git.service.d.ts +18 -0
  20. package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
  21. package/dist/modules/version-control/git/git.service.js +53 -2
  22. package/dist/modules/version-control/git/git.service.js.map +1 -1
  23. package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
  24. package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
  25. package/dist/modules/version-control/version-control-hooks.js +17 -0
  26. package/dist/modules/version-control/version-control-hooks.js.map +1 -1
  27. package/dist/modules/version-control/version-control.service.d.ts +0 -15
  28. package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
  29. package/dist/modules/version-control/version-control.service.js +4 -13
  30. package/dist/modules/version-control/version-control.service.js.map +1 -1
  31. package/dist/shared/fire-and-forget.d.ts +14 -0
  32. package/dist/shared/fire-and-forget.d.ts.map +1 -0
  33. package/dist/shared/fire-and-forget.js +23 -0
  34. package/dist/shared/fire-and-forget.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/__tests__/capturing-logger.ts +36 -0
  37. package/src/__tests__/real-access.ts +16 -0
  38. package/src/__tests__/real-auth.ts +13 -0
  39. package/src/__tests__/real-readers.ts +27 -0
  40. package/src/__tests__/real-version-control.ts +22 -0
  41. package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
  42. package/src/core/create-core-services.ts +4 -2
  43. package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
  44. package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
  45. package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
  46. package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
  47. package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
  48. package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
  49. package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
  50. package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
  51. package/src/modules/database/__tests__/connection.test.ts +30 -0
  52. package/src/modules/database/connection.ts +12 -0
  53. package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
  54. package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
  55. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
  56. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
  57. package/src/modules/kb-fs/file-change-notifier.ts +55 -32
  58. package/src/modules/kb-fs/locking-filesystem.ts +4 -18
  59. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
  60. package/src/modules/onboarding/starter-pack.service.ts +2 -4
  61. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
  62. package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
  63. package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
  64. package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
  65. package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
  66. package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
  67. package/src/modules/version-control/git/git.service.ts +55 -2
  68. package/src/modules/version-control/version-control-hooks.ts +41 -2
  69. package/src/modules/version-control/version-control.service.ts +4 -12
  70. package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
  71. package/src/shared/fire-and-forget.ts +26 -0
@@ -10,6 +10,8 @@ import {
10
10
  getDb,
11
11
  type Database,
12
12
  } from '../connection.js';
13
+ import { setLogger } from '../../../shared/logging.js';
14
+ import { capturingLogger } from '../../../__tests__/capturing-logger.js';
13
15
 
14
16
  /**
15
17
  * Pools here are never connected: `pg.Pool` opens nothing until a query is
@@ -27,6 +29,34 @@ afterEach(async () => {
27
29
  await Promise.all(opened.splice(0).map((db) => closeDb(db)));
28
30
  });
29
31
 
32
+ describe('an idle connection the server closes', () => {
33
+ it('is logged, with the error, and let go — the pool has a listener, so the event cannot end the process', () => {
34
+ // A capturing logger stands in for the process's: the warning is the
35
+ // behaviour under test, not a side effect to tolerate.
36
+ const { logger: capturing, lines } = capturingLogger();
37
+ const previous = setLogger(capturing);
38
+ try {
39
+ const db = createDb(URL);
40
+ opened.push(db);
41
+ // `pg.Pool` emits an idle client's failure on itself; without a listener
42
+ // Node throws the event. The terminated-backend error is what a server
43
+ // restart or a forced drop sends to every idle client.
44
+ const terminated = Object.assign(new Error('terminating connection due to administrator command'), { code: '57P01' });
45
+ expect(db.$client.listenerCount('error')).toBe(1);
46
+ expect(() => db.$client.emit('error', terminated)).not.toThrow();
47
+ expect(lines).toEqual([
48
+ {
49
+ level: 'warn',
50
+ message: 'an idle database connection was closed by the server; the next query opens a new one:',
51
+ fields: { module: 'database', err: terminated },
52
+ },
53
+ ]);
54
+ } finally {
55
+ setLogger(previous);
56
+ }
57
+ });
58
+ });
59
+
30
60
  describe('schema per knowledge base', () => {
31
61
  it('searches the schema it was built for, and leaves the default schema to the server', () => {
32
62
  const acme = createDb(URL, { schema: 'acme' });
@@ -2,6 +2,9 @@ import { sql } from 'drizzle-orm';
2
2
  import { drizzle } from 'drizzle-orm/node-postgres';
3
3
  import pg from 'pg';
4
4
  import * as schema from './schema.js';
5
+ import { logger } from '../../shared/logging.js';
6
+
7
+ const log = logger('database');
5
8
  import { PII_CIPHERTEXT_PREFIX, PiiParam, derivePiiKeys, type PiiKeys } from '../../shared/column-crypto.js';
6
9
 
7
10
  /**
@@ -189,6 +192,15 @@ export function createDb(databaseUrl: string, opts: DbOptions = {}) {
189
192
  // operator's own `search_path` stays in force there.
190
193
  ...(dbSchema === DEFAULT_DB_SCHEMA ? {} : { options: `-c search_path=${dbSchema}` }),
191
194
  });
195
+ // An IDLE client's failure — its backend terminated by the server (a
196
+ // restart, a `drop database … with (force)`, an administrator's
197
+ // `pg_terminate_backend`), a dropped network — is emitted on the pool, and
198
+ // an 'error' event nobody listens to ends the process. Logged and let go:
199
+ // the pool has already discarded that client, and the next query opens a
200
+ // fresh one. A query in flight is not this path; it fails to its caller.
201
+ pool.on('error', (err) => {
202
+ log.warn('an idle database connection was closed by the server; the next query opens a new one:', { err });
203
+ });
192
204
  const db = drizzle(pool, { schema });
193
205
  schemas.set(db, dbSchema);
194
206
  if (keys) piiKeys.set(db, { keys, of: opts.piiKey! });
@@ -1,10 +1,9 @@
1
1
  import jwt from 'jsonwebtoken';
2
2
  import { createHmac } from 'node:crypto';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
- import { createFileReaderRegistry } from '../../workspace/file-readers/file-reader.registry.js';
5
- import type { DocExtractOutcome, DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
6
- import { makeRolesYamlWriteValidator, RolesYamlInvalidError } from '../../access-model/roles-yaml-guard.js';
7
- import { ACCOUNT_DEACTIVATED_MESSAGE } from '../../auth/account-admission.js';
4
+ import { realFileReaders } from '../../../__tests__/real-readers.js';
5
+ import { makeRolesYamlWriteValidator } from '../../access-model/index.js';
6
+ import { ACCOUNT_DEACTIVATED_MESSAGE } from '../../auth/index.js';
8
7
  import { testKbContext, TEST_BRANCH_MODEL } from '../../../__tests__/kb-context.js';
9
8
  import { EmbedService, type EmbedConfig } from '../embed.service.js';
10
9
  import {
@@ -54,19 +53,6 @@ interface Opts {
54
53
  resolveNodeId?: ((id: string) => Promise<string | null>) | null;
55
54
  }
56
55
 
57
- /**
58
- * The extraction service the reader registry takes, typed against its
59
- * contract: no test here reads a document-format file, and one that did
60
- * would get this typed refusal, not a silent empty text.
61
- */
62
- function noDocExtract(): DocExtractService {
63
- const extract = async (): Promise<DocExtractOutcome> => ({
64
- ok: false,
65
- message: 'this suite reads no document-format file',
66
- });
67
- return { extract } satisfies Pick<DocExtractService, 'extract'> as DocExtractService;
68
- }
69
-
70
56
  function build(opts: Opts = {}) {
71
57
  const files: Record<string, string | Buffer> = opts.files ?? { [WS]: PAGE };
72
58
  const workspaceService = {
@@ -138,7 +124,7 @@ function build(opts: Opts = {}) {
138
124
  versionControlService as never,
139
125
  gitService as never,
140
126
  accountLinks as never,
141
- createFileReaderRegistry(noDocExtract()),
127
+ realFileReaders(),
142
128
  // The real gate: the property under test is that an embed write cannot
143
129
  // put on disk what the app's editor would refuse.
144
130
  makeRolesYamlWriteValidator(KB),
@@ -791,7 +777,7 @@ describe('EmbedService: the roles.yaml write gate', () => {
791
777
  it('refuses to save a roles.yaml that would not parse, writing nothing and taking no lock', async () => {
792
778
  const { service, workspaceService, versionControlService } = build({ files: { [ROLES]: VALID } });
793
779
  const { token } = await service.mintForUser({ userId: USER.id, reference: 'roles.yaml' });
794
- await expect(service.save(token, 'roles: [oops')).rejects.toThrow(RolesYamlInvalidError);
780
+ await expect(service.save(token, 'roles: [oops')).rejects.toMatchObject({ status: 422, payload: { kind: 'roles-yaml-invalid' } });
795
781
  expect(workspaceService.writeFile).not.toHaveBeenCalled();
796
782
  expect(versionControlService.acquireLock).not.toHaveBeenCalled();
797
783
  expect(versionControlService.releaseLock).not.toHaveBeenCalled();
@@ -803,7 +789,7 @@ describe('EmbedService: the roles.yaml write gate', () => {
803
789
  files: { [ROLES]: VALID },
804
790
  });
805
791
  const { token } = await service.mintForUser({ userId: USER.id, reference: 'roles.yaml' });
806
- await expect(service.propose(token, 'roles: [oops')).rejects.toThrow(RolesYamlInvalidError);
792
+ await expect(service.propose(token, 'roles: [oops')).rejects.toMatchObject({ status: 422, payload: { kind: 'roles-yaml-invalid' } });
807
793
  expect(versionControlService.createBranch).not.toHaveBeenCalled();
808
794
  expect(workspaceService.writeFile).not.toHaveBeenCalled();
809
795
  expect(versionControlService.commitChanges).not.toHaveBeenCalled();
@@ -18,12 +18,13 @@ describe('FileChangeNotifier', () => {
18
18
  const b = vi.fn();
19
19
  notifier.onFilesChanged(a);
20
20
  notifier.onFilesChanged(b);
21
- const c = change(['x/Tools/a.tool', 'x/Knowledge/B.md']);
22
- notifier.emit(c);
21
+ notifier.emit(change(['x/Tools/a.tool', 'x/Knowledge/B.md']));
22
+ // Both hear the one change, shaped: paths sorted.
23
+ const shaped = change(['x/Knowledge/B.md', 'x/Tools/a.tool']);
23
24
  expect(a).toHaveBeenCalledTimes(1);
24
- expect(a).toHaveBeenCalledWith(c);
25
+ expect(a).toHaveBeenCalledWith(shaped);
25
26
  expect(b).toHaveBeenCalledTimes(1);
26
- expect(b).toHaveBeenCalledWith(c);
27
+ expect(b).toHaveBeenCalledWith(shaped);
27
28
  });
28
29
 
29
30
  it('a throwing listener never blocks the others (commit isolation)', () => {
@@ -38,6 +39,16 @@ describe('FileChangeNotifier', () => {
38
39
  expect(after).toHaveBeenCalledOnce();
39
40
  });
40
41
 
42
+ it('a listener whose promise rejects is isolated too — the rejection is caught, the others heard', async () => {
43
+ const notifier = new FileChangeNotifier();
44
+ const after = vi.fn();
45
+ notifier.onFilesChanged(async () => Promise.reject(new Error('boom, later')));
46
+ notifier.onFilesChanged(after);
47
+ expect(() => notifier.emit(change(['x/A.md']))).not.toThrow();
48
+ await new Promise<void>((resolve) => setImmediate(resolve));
49
+ expect(after).toHaveBeenCalledOnce();
50
+ });
51
+
41
52
  it('an empty path list emits nothing; unsubscribe stops delivery', () => {
42
53
  const notifier = new FileChangeNotifier();
43
54
  const listener = vi.fn();
@@ -48,4 +59,12 @@ describe('FileChangeNotifier', () => {
48
59
  notifier.emit(change(['x/A.md']));
49
60
  expect(listener).not.toHaveBeenCalled();
50
61
  });
62
+
63
+ it('hands every listener the paths sorted and unique: the shape of the contract is made here, once', () => {
64
+ const notifier = new FileChangeNotifier();
65
+ const heard: FilesChange[] = [];
66
+ notifier.onFilesChanged((c) => heard.push(c));
67
+ notifier.emit(change(['x/B.md', 'x/A.md', 'x/B.md']));
68
+ expect(heard).toEqual([change(['x/A.md', 'x/B.md'])]);
69
+ });
51
70
  });
@@ -6,11 +6,7 @@ import type { AuthUser, IVersionControlService } from '@bevel-software/platform-
6
6
  import { LocalFilesystem } from '@mastra/core/workspace';
7
7
  import { LockingFilesystem } from '../locking-filesystem.js';
8
8
  import { PushNeedsAgentResolutionError, VersionControlValidationError } from '../../../shared/domain-errors.js';
9
- import {
10
- makeAgentRolesYamlWriteValidator,
11
- RolesYamlInvalidError,
12
- RolesYamlNewRoleError,
13
- } from '../../access-model/roles-yaml-guard.js';
9
+ import { makeAgentRolesYamlWriteValidator } from '../../access-model/index.js';
14
10
 
15
11
  /** The clone folder at the workspace root: every path the filesystem mutates lives under it. */
16
12
  const KB = 'knowledge-base';
@@ -213,7 +209,7 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
213
209
  });
214
210
  });
215
211
 
216
- describe('LockingFilesystem.writeFiles — batch with deletes + one batched change event', () => {
212
+ describe('LockingFilesystem.writeFiles — batch with deletes', () => {
217
213
  let root: string;
218
214
 
219
215
  beforeEach(async () => {
@@ -224,12 +220,11 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
224
220
  await fs.rm(root, { recursive: true, force: true });
225
221
  });
226
222
 
227
- it('locks writes AND deletes, applies both, emits ONE batched onFilesChanged', async () => {
223
+ it('locks writes AND deletes, applies both, returns the commit', async () => {
228
224
  await fs.mkdir(path.join(root, 'knowledge-base/Tools'), { recursive: true });
229
225
  await fs.writeFile(path.join(root, 'knowledge-base/Tools/old.tool'), '{}');
230
226
 
231
227
  const versionControl = makeVersionControl();
232
- const emitted: unknown[] = [];
233
228
  const fsLayer = new LockingFilesystem(
234
229
  { basePath: root, contained: true },
235
230
  {
@@ -237,7 +232,6 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
237
232
  workspaceId: 'ws-feat',
238
233
  branch: 'feat',
239
234
  user: USER, kbDirName: KB,
240
- fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
241
235
  },
242
236
  );
243
237
 
@@ -253,23 +247,14 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
253
247
  expect(await fs.readFile(path.join(root, 'knowledge-base/Tools/new.tool'), 'utf-8')).toContain('http');
254
248
  await expect(fs.access(path.join(root, 'knowledge-base/Tools/old.tool'))).rejects.toThrow();
255
249
  expect(change).toEqual({});
256
- // ONE event carrying the whole batch (sorted), attributed to the user.
257
- expect(emitted).toHaveLength(1);
258
- expect(emitted[0]).toMatchObject({
259
- workspaceId: 'ws-feat',
260
- branch: 'feat',
261
- paths: ['knowledge-base/Tools/new.tool', 'knowledge-base/Tools/old.tool'],
262
- byUser: USER,
263
- });
264
250
  });
265
251
 
266
- it('a failing commit releases every lock WITHOUT committing and emits nothing', async () => {
252
+ it('a failing commit releases every lock WITHOUT committing', async () => {
267
253
  await fs.mkdir(path.join(root, 'knowledge-base/Tools'), { recursive: true });
268
254
  await fs.writeFile(path.join(root, 'knowledge-base/Tools/old.tool'), '{}');
269
255
 
270
256
  const versionControl = makeVersionControl();
271
257
  (versionControl.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('push exploded'));
272
- const emitted: unknown[] = [];
273
258
  const fsLayer = new LockingFilesystem(
274
259
  { basePath: root, contained: true },
275
260
  {
@@ -277,7 +262,6 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
277
262
  workspaceId: 'ws-feat',
278
263
  branch: 'feat',
279
264
  user: USER, kbDirName: KB,
280
- fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
281
265
  },
282
266
  );
283
267
 
@@ -292,11 +276,10 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
292
276
  // Every acquired path goes through the NO-COMMIT release — which is what
293
277
  // discards the uncommitted bytes (write reverted, delete restored) in the
294
278
  // real VersionControlService via git.discardPath. The committing release must
295
- // never run, and no change event fires for a failed batch.
279
+ // never run.
296
280
  expect(versionControl.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/new.tool', USER);
297
281
  expect(versionControl.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/old.tool', USER);
298
282
  expect(versionControl.releaseLock).not.toHaveBeenCalled();
299
- expect(emitted).toHaveLength(0);
300
283
  });
301
284
 
302
285
  it('a POST-commit push failure releases WITH commit-on-release (arms the worker retry), never the discard path', async () => {
@@ -311,7 +294,6 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
311
294
  (versionControl.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
312
295
  new PushNeedsAgentResolutionError('feat', 'knowledge-base/A.md', 'non-fast-forward', 'rebase failed'),
313
296
  );
314
- const emitted: unknown[] = [];
315
297
  const fsLayer = new LockingFilesystem(
316
298
  { basePath: root, contained: true },
317
299
  {
@@ -319,7 +301,6 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
319
301
  workspaceId: 'ws-feat',
320
302
  branch: 'feat',
321
303
  user: USER, kbDirName: KB,
322
- fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
323
304
  },
324
305
  );
325
306
 
@@ -329,25 +310,6 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
329
310
 
330
311
  expect(versionControl.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/A.md', USER);
331
312
  expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
332
- expect(emitted).toHaveLength(0);
333
- });
334
-
335
- it('emits nothing on a no-op commit (clean tree)', async () => {
336
- const versionControl = makeVersionControl();
337
- (versionControl.commitChanges as ReturnType<typeof vi.fn>).mockResolvedValue(null);
338
- const emitted: unknown[] = [];
339
- const fsLayer = new LockingFilesystem(
340
- { basePath: root, contained: true },
341
- {
342
- versionControl,
343
- workspaceId: 'ws-feat',
344
- branch: 'feat',
345
- user: USER, kbDirName: KB,
346
- fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
347
- },
348
- );
349
- await fsLayer.writeFiles([{ path: 'knowledge-base/A.md', content: 'same' }], 'noop');
350
- expect(emitted).toHaveLength(0);
351
313
  });
352
314
  });
353
315
 
@@ -714,7 +676,6 @@ describe('LockingFilesystem — creator read grants on creation', () => {
714
676
  wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
715
677
  apply: (current: string) => current, // grant already present → no-op
716
678
  });
717
- const emitted: { paths: string[] }[] = [];
718
679
  const fsLayer = new LockingFilesystem(
719
680
  { basePath: root, contained: true },
720
681
  {
@@ -723,7 +684,6 @@ describe('LockingFilesystem — creator read grants on creation', () => {
723
684
  branch: 'feat',
724
685
  user: USER, kbDirName: KB,
725
686
  creatorAccess,
726
- fileChanges: { emit: (c: { paths: string[] }) => emitted.push(c) } as never,
727
687
  },
728
688
  );
729
689
  await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
@@ -734,7 +694,6 @@ describe('LockingFilesystem — creator read grants on creation', () => {
734
694
  // ...but the commit is scoped to the caller's path only.
735
695
  const commitPaths = (versionControl.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
736
696
  expect(commitPaths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
737
- expect(emitted[0].paths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
738
697
  // The seeded file is untouched.
739
698
  expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), 'utf-8')).toBe(
740
699
  seedContent,
@@ -1228,7 +1187,7 @@ describe('LockingFilesystem — the agent roles.yaml gate covers every op that l
1228
1187
  }
1229
1188
 
1230
1189
  async function expectRefused(op: Promise<unknown>, versionControl: IVersionControlService): Promise<void> {
1231
- await expect(op).rejects.toBeInstanceOf(RolesYamlNewRoleError);
1190
+ await expect(op).rejects.toMatchObject({ status: 422, payload: { kind: 'roles-yaml-new-role' } });
1232
1191
  expect(versionControl.acquireLock).not.toHaveBeenCalled();
1233
1192
  expect(await fs.readFile(path.join(root, ROLES), 'utf-8')).toBe(CURRENT);
1234
1193
  }
@@ -1291,7 +1250,7 @@ describe('LockingFilesystem — the agent roles.yaml gate covers every op that l
1291
1250
  }
1292
1251
 
1293
1252
  async function expectRefusedUnderLock(op: Promise<unknown>, versionControl: IVersionControlService, locks: number): Promise<void> {
1294
- await expect(op).rejects.toBeInstanceOf(RolesYamlNewRoleError);
1253
+ await expect(op).rejects.toMatchObject({ status: 422, payload: { kind: 'roles-yaml-new-role' } });
1295
1254
  expect(versionControl.releaseLockUntouched).toHaveBeenCalledTimes(locks);
1296
1255
  expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
1297
1256
  expect(versionControl.releaseLock).not.toHaveBeenCalled();
@@ -1325,7 +1284,7 @@ describe('LockingFilesystem — the agent roles.yaml gate covers every op that l
1325
1284
  // change is validity: the same line appended to the rewritten file breaks it.
1326
1285
  const rewritten = 'roles:\n Admin:\n - a@x.eu';
1327
1286
  const versionControl = racingVersionControl(() => fs.writeFile(path.join(root, ROLES), rewritten));
1328
- await expect(layer(versionControl).appendFile(ROLES, ' - b@x.eu\n')).rejects.toBeInstanceOf(RolesYamlInvalidError);
1287
+ await expect(layer(versionControl).appendFile(ROLES, ' - b@x.eu\n')).rejects.toMatchObject({ status: 422, payload: { kind: 'roles-yaml-invalid' } });
1329
1288
  expect(versionControl.releaseLockUntouched).toHaveBeenCalledTimes(1);
1330
1289
  expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
1331
1290
  expect(await fs.readFile(path.join(root, ROLES), 'utf-8')).toBe(rewritten);
@@ -4,11 +4,11 @@ import { promisify } from 'node:util';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
- import type { AuthUser, IVersionControlService } from '@bevel-software/platform-shared';
7
+ import type { AuthUser, FilesChange, IVersionControlService } from '@bevel-software/platform-shared';
8
+ import type { VersionControlHooks } from '../../version-control/index.js';
8
9
  import { testKbContext } from '../../../__tests__/kb-context.js';
9
- import { GitService } from '../../version-control/git/git.service.js';
10
- import { VersionControlHooks } from '../../version-control/version-control-hooks.js';
11
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
10
+ import { realGitService, realVersionControlHooks } from '../../../__tests__/real-version-control.js';
11
+ import type { WorkspaceService } from '../../workspace/index.js';
12
12
  import { LockingFilesystem, MoveLockedError, MoveRacedError, MoveUndoError } from '../locking-filesystem.js';
13
13
  import { PushNeedsAgentResolutionError } from '../../../shared/domain-errors.js';
14
14
 
@@ -59,13 +59,14 @@ beforeEach(async () => {
59
59
  await git(repo, ['add', '-A']);
60
60
  await git(repo, ['commit', '-m', 'seed']);
61
61
 
62
- const svc = new GitService(
62
+ landed = [];
63
+ hooks = realVersionControlHooks();
64
+ hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
65
+ // What the git layer reports landing — the one place every commit is announced from.
66
+ hooks.onCommitLanded((change) => landed.push(change));
67
+ const svc = realGitService(
63
68
  { getWorkspacePath: async () => root } as unknown as WorkspaceService,
64
- (() => {
65
- const hooks = new VersionControlHooks();
66
- hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
67
- return hooks;
68
- })(),
69
+ hooks,
69
70
  testKbContext({ kbDirName: KB }),
70
71
  );
71
72
  lockedPaths = new Set();
@@ -103,6 +104,11 @@ const edits = () => [
103
104
  { path: `${KB}/Index.md`, lockAt: `${KB}/Index.md`, content: '[one](Topics/Deep/A/One.md)\n' },
104
105
  ];
105
106
 
107
+ /** Every commit the git layer landed, as the hook heard it (paths as git named them; the notifier is what sorts). */
108
+ let landed: FilesChange[] = [];
109
+ let hooks: VersionControlHooks;
110
+ const heard = () => landed.map((c) => ({ ...c, paths: [...c.paths].sort() }));
111
+
106
112
  describe('LockingFilesystem.moveWithEdits', () => {
107
113
  it('lands the folder move and every edit as ONE commit that git reads as renames', async () => {
108
114
  const before = (await git(repo, ['rev-list', '--count', 'HEAD'])).trim();
@@ -123,6 +129,40 @@ describe('LockingFilesystem.moveWithEdits', () => {
123
129
  expect(await read('Topics/Deep/A/One.md')).toContain('../../../NodeTypes/Task.md');
124
130
  // The binary moved byte for byte.
125
131
  expect([...(await fs.readFile(path.join(repo, 'Topics/Deep/A/pic.png')))]).toEqual([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3]);
132
+ // The git layer reported the ONE commit, with every path it named at both
133
+ // ends — the one place any commit is announced from.
134
+ expect(heard()).toEqual([
135
+ {
136
+ workspaceId: WS,
137
+ branch: 'feature-test',
138
+ byUser: USER,
139
+ paths: [
140
+ `${KB}/Index.md`,
141
+ `${KB}/Projects/A/One.md`,
142
+ `${KB}/Projects/A/Two.md`,
143
+ `${KB}/Projects/A/pic.png`,
144
+ `${KB}/Topics/Deep/A/One.md`,
145
+ `${KB}/Topics/Deep/A/Two.md`,
146
+ `${KB}/Topics/Deep/A/pic.png`,
147
+ ],
148
+ },
149
+ ]);
150
+ });
151
+
152
+ it('a reactor that throws cannot unwind the landed move: the hook runs isolated, the commit, the files and the rename stay', async () => {
153
+ hooks.onCommitLanded(() => {
154
+ throw new Error('reactor exploded');
155
+ });
156
+ const before = (await git(repo, ['rev-list', '--count', 'HEAD'])).trim();
157
+ await fsLayer().moveWithEdits(`${KB}/Projects/A`, `${KB}/Topics/Deep/A`, edits(), 'Move A');
158
+ expect(landed).toHaveLength(1);
159
+ expect((await git(repo, ['rev-list', '--count', 'HEAD'])).trim()).toBe(String(Number(before) + 1));
160
+ expect((await git(repo, ['status', '--porcelain'])).trim()).toBe('');
161
+ expect(await read('Index.md')).toBe('[one](Topics/Deep/A/One.md)\n');
162
+ expect(await read('Topics/Deep/A/One.md')).toContain('../../../NodeTypes/Task.md');
163
+ await expect(fs.access(path.join(repo, 'Projects/A'))).rejects.toThrow();
164
+ // Every lock had already been given back untouched; nothing was discarded.
165
+ expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
126
166
  });
127
167
 
128
168
  it('a single file moved with edits is one commit with a rename', async () => {
@@ -152,6 +192,7 @@ describe('LockingFilesystem.moveWithEdits', () => {
152
192
  expect(released(versionControl.releaseLockUntouched)).toEqual([`${KB}/Index.md`, `${KB}/Projects/A`, `${KB}/Projects/A/One.md`]);
153
193
  expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
154
194
  expect(versionControl.releaseLock).not.toHaveBeenCalled();
195
+ expect(landed).toEqual([]);
155
196
  expect(await read('Index.md')).toBe('[one](Projects/A/One.md)\n');
156
197
  }, 20_000);
157
198
 
@@ -176,6 +217,7 @@ describe('LockingFilesystem.moveWithEdits', () => {
176
217
  expect(await read('Index.md')).toBe('[one](Projects/A/One.md)\n');
177
218
  expect(versionControl.releaseLockNoCommit).not.toHaveBeenCalled();
178
219
  expect(released(versionControl.releaseLockUntouched).length).toBeGreaterThan(0);
220
+ expect(landed).toEqual([]);
179
221
  });
180
222
 
181
223
  it('a move that fails and cannot be undone releases every lock with the discard, and says both', async () => {
@@ -195,6 +237,8 @@ describe('LockingFilesystem.moveWithEdits', () => {
195
237
  expect(discarded).toContain(`${KB}/Projects/A`);
196
238
  expect(discarded).toContain(`${KB}/Topics/Deep/A/One.md`);
197
239
  expect(discarded).toContain(`${KB}/Index.md`);
240
+ // A failed commit announces nothing.
241
+ expect(landed).toEqual([]);
198
242
  });
199
243
 
200
244
  it('a commit that landed but could not be pushed enqueues the committed paths only; the folder locks go back untouched', async () => {
@@ -223,6 +267,25 @@ describe('LockingFilesystem.moveWithEdits', () => {
223
267
  expect(enqueued).not.toContain(`${KB}/Projects/A`);
224
268
  expect(enqueued).not.toContain(`${KB}/Topics/Deep/A`);
225
269
  expect(untouched).toEqual([`${KB}/Projects/A`, `${KB}/Topics/Deep/A`]);
270
+ // The commit landed, so the git layer reported it — once, with the
271
+ // committed scope; the worker's later push makes no new commit and
272
+ // reports nothing more.
273
+ expect(heard()).toEqual([
274
+ {
275
+ workspaceId: WS,
276
+ branch: 'feature-test',
277
+ byUser: USER,
278
+ paths: [
279
+ `${KB}/Index.md`,
280
+ `${KB}/Projects/A/One.md`,
281
+ `${KB}/Projects/A/Two.md`,
282
+ `${KB}/Projects/A/pic.png`,
283
+ `${KB}/Topics/Deep/A/One.md`,
284
+ `${KB}/Topics/Deep/A/Two.md`,
285
+ `${KB}/Topics/Deep/A/pic.png`,
286
+ ],
287
+ },
288
+ ]);
226
289
  });
227
290
 
228
291
  it('locks every file of a moved folder at its old and its new path', async () => {
@@ -1,38 +1,63 @@
1
1
  /**
2
- * One in-process notification fired AFTER a set of file changes is committed +
3
- * pushed (post-lock-release), so backend services can react to KB writes without
4
- * the per-file TTL wait. Distinct from the SSE `VersionControlEventBus`, which fans
5
- * scoped events to browser sessions; this is an unscoped, server-side domain hook.
2
+ * One in-process notification per commit the git service lands — a single-file
3
+ * save, an autosave checkpoint, a batch (`LockingFilesystem.writeFiles`, a
4
+ * bulk upload, the admin roster commits, a plugin rename), a merge, a
5
+ * `commit_change` — fired when the commit lands: pushed, or awaiting its push
6
+ * (the later push makes no new commit and is not announced again). Backend
7
+ * services react to KB writes here without the per-file TTL wait. Distinct
8
+ * from the SSE `VersionControlEventBus`, which fans scoped events to browser
9
+ * sessions; this is an unscoped, server-side domain hook.
6
10
  *
7
- * BATCHED by design: a single-file save emits `paths: [p]`, a batch write
8
- * (`LockingFilesystem.writeFiles`, e.g. a bulk node upload) emits ONE event with
9
- * every path it committed. Subscribers that do expensive per-event work (the
10
- * id-repair rebuilds the id index) handle a whole batch off one rebuild instead
11
- * of N back-to-back ones.
11
+ * ONE SOURCE. The git service reports every commit it lands through
12
+ * `VersionControlHooks.onCommitLanded`, and the composition root hands that to
13
+ * this notifier once; nothing else emits here, and a test pins both. A commit
14
+ * made some other way (the startup runner's seed of a fresh knowledge base,
15
+ * before any catalog exists) is the one commit this does not see. A commit
16
+ * that ARRIVES by a pull was landed — and announced — by the process that made
17
+ * it; a pull of the default branch also announces the tree change on the SSE
18
+ * bus (`fs-tree-changed`), which the catalogs listen to, and a pull rebases
19
+ * this process's own commits without re-announcing them (their content is
20
+ * unchanged).
12
21
  *
13
- * Emitted from the two post-commit chokepoints (`VersionControlService.runPendingCommit`
14
- * for single-file writes, `LockingFilesystem.writeFiles` for batches).
22
+ * BATCHED by design: a single-file save emits `paths: [p]`, a batch emits ONE
23
+ * event with every path it committed, a merge every path it brought in — so a
24
+ * subscriber that does expensive per-event work (the id-repair rebuilds the id
25
+ * index) handles a whole batch off one rebuild instead of N back-to-back ones.
26
+ * Paths are workspace-relative, sorted and unique: `emit` shapes them.
15
27
  *
16
- * Every listener is invoked inside try/catch — a throwing subscriber must never
17
- * fail the commit that triggered it (mirrors the SSE bus's per-subscriber
18
- * isolation). Firing is fire-and-forget; a listener that needs to write files
19
- * (id-repair) does so on its own, after the locks have already been released.
28
+ * TIMING. The announcement is made when the commit lands, inside the git
29
+ * layer's reservation. A listener that only reads (the catalogs) is
30
+ * unaffected by locks. A listener that WRITES an announced path acquires its
31
+ * lock like any writer, and two cases differ: a batch's committer (the
32
+ * lock-aware filesystem, the admin commit driver) releases its locks once the
33
+ * commit's push has either gone through or been handed off to the recovery
34
+ * worker (the push is then still owed, and the lock no longer guards the
35
+ * path) — the lock-aware filesystem's acquire retries (three, two seconds
36
+ * apart) cover the usual push, and a push that outlasts them leaves the writer
37
+ * to skip; an editor's autosave checkpoint is announced while the editor's
38
+ * own lock is held, for as long as the person keeps editing, so a write to
39
+ * that path cannot be made then. In both cases the path's next announcement —
40
+ * the editor's release commit when it carries new bytes, the next change to
41
+ * the file — is when such a writer catches up; a writer that skips must
42
+ * therefore treat "could not lock" as "not now", as the id-repair already
43
+ * does. Before this release a batch was announced only where its filesystem
44
+ * had been handed the notifier, and a checkpoint not at all.
45
+ *
46
+ * Every listener is invoked isolated (`callFireAndForget`): one that throws, or
47
+ * whose promise rejects, is logged and never fails the commit that triggered
48
+ * it (mirrors the SSE bus's per-subscriber isolation). Firing is
49
+ * fire-and-forget; nothing awaits a listener.
20
50
  */
21
- import type { AuthUser } from '@bevel-software/platform-shared';
51
+ import type { FilesChange } from '@bevel-software/platform-shared';
52
+ import { callFireAndForget, type FireAndForgetListener } from '../../shared/fire-and-forget.js';
22
53
  import { logger } from '../../shared/logging.js';
23
54
 
24
55
  const log = logger('file-change');
25
56
 
26
- export interface FilesChange {
27
- workspaceId: string;
28
- branch: string;
29
- /** Workspace-relative paths committed in this change (≥1; sorted for batches). */
30
- paths: string[];
31
- /** Who the change was committed as — a reacting write (id-repair) acts as the same user. */
32
- byUser: AuthUser;
33
- }
57
+ export type { FilesChange };
34
58
 
35
- export type FilesChangedListener = (change: FilesChange) => void;
59
+ /** Fire-and-forget: a listener may answer a promise, which nobody awaits; a rejection is logged like a throw. */
60
+ export type FilesChangedListener = FireAndForgetListener<FilesChange>;
36
61
 
37
62
  export class FileChangeNotifier {
38
63
  private readonly listeners = new Set<FilesChangedListener>();
@@ -43,15 +68,13 @@ export class FileChangeNotifier {
43
68
  return () => this.listeners.delete(listener);
44
69
  }
45
70
 
46
- /** Fan a change out to every listener; isolate + log each so one can't break the commit. */
71
+ /** Fan a change out to every listener — paths sorted and unique, nothing for an empty set; each isolated (a throw or a rejection is logged) so one can't break the commit. */
47
72
  emit(change: FilesChange): void {
48
- if (change.paths.length === 0) return;
73
+ const paths = [...new Set(change.paths)].sort();
74
+ if (paths.length === 0) return;
75
+ const shaped: FilesChange = { ...change, paths };
49
76
  for (const listener of this.listeners) {
50
- try {
51
- listener(change);
52
- } catch (err) {
53
- log.error('listener threw:', { err });
54
- }
77
+ callFireAndForget(listener, shaped, log, 'a file-change listener failed:');
55
78
  }
56
79
  }
57
80
  }