@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.
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +5 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +11 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
- package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
- package/dist/modules/kb-fs/file-change-notifier.js +6 -8
- package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +4 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
- package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
- package/dist/modules/onboarding/starter-pack.service.js +1 -1
- package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
- package/dist/modules/version-control/git/git.service.d.ts +18 -0
- package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
- package/dist/modules/version-control/git/git.service.js +53 -2
- package/dist/modules/version-control/git/git.service.js.map +1 -1
- package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
- package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
- package/dist/modules/version-control/version-control-hooks.js +17 -0
- package/dist/modules/version-control/version-control-hooks.js.map +1 -1
- package/dist/modules/version-control/version-control.service.d.ts +0 -15
- package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
- package/dist/modules/version-control/version-control.service.js +4 -13
- package/dist/modules/version-control/version-control.service.js.map +1 -1
- package/dist/shared/fire-and-forget.d.ts +14 -0
- package/dist/shared/fire-and-forget.d.ts.map +1 -0
- package/dist/shared/fire-and-forget.js +23 -0
- package/dist/shared/fire-and-forget.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/capturing-logger.ts +36 -0
- package/src/__tests__/real-access.ts +16 -0
- package/src/__tests__/real-auth.ts +13 -0
- package/src/__tests__/real-readers.ts +27 -0
- package/src/__tests__/real-version-control.ts +22 -0
- package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
- package/src/core/create-core-services.ts +4 -2
- package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
- package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
- package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
- package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
- package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
- package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
- package/src/modules/database/__tests__/connection.test.ts +30 -0
- package/src/modules/database/connection.ts +12 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
- package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
- package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
- package/src/modules/kb-fs/file-change-notifier.ts +55 -32
- package/src/modules/kb-fs/locking-filesystem.ts +4 -18
- package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
- package/src/modules/onboarding/starter-pack.service.ts +2 -4
- package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
- package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
- package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
- package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
- package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
- package/src/modules/version-control/git/git.service.ts +55 -2
- package/src/modules/version-control/version-control-hooks.ts +41 -2
- package/src/modules/version-control/version-control.service.ts +4 -12
- package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
- 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 {
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
22
|
-
|
|
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(
|
|
25
|
+
expect(a).toHaveBeenCalledWith(shaped);
|
|
25
26
|
expect(b).toHaveBeenCalledTimes(1);
|
|
26
|
-
expect(b).toHaveBeenCalledWith(
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
* the
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|