@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
|
@@ -49,7 +49,6 @@ import {
|
|
|
49
49
|
} from '@mastra/core/workspace';
|
|
50
50
|
import type { AuthUser, Change, IVersionControlService } from '@bevel-software/platform-shared';
|
|
51
51
|
import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
|
|
52
|
-
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
53
52
|
import { isAbsence } from '../../shared/fs.contract.js';
|
|
54
53
|
import { DestinationTakenError, lstatOrNull, renameNoReplace, takenError } from '../../shared/rename-no-replace.js';
|
|
55
54
|
import { linkedAncestor } from '../../shared/no-link-below.js';
|
|
@@ -167,12 +166,6 @@ export interface LockingFilesystemContext {
|
|
|
167
166
|
* when the destination is a path the validator's `appliesTo` claims.
|
|
168
167
|
*/
|
|
169
168
|
validateWrite?: WriteValidator;
|
|
170
|
-
/**
|
|
171
|
-
* Post-commit hook for the BATCH `writeFiles` path (which commits via
|
|
172
|
-
* `commitChanges` and so skips `runPendingCommit`'s emit). Single-file ops
|
|
173
|
-
* emit through the queue instead, so they don't need this. Optional.
|
|
174
|
-
*/
|
|
175
|
-
fileChanges?: FileChangeNotifier;
|
|
176
169
|
/**
|
|
177
170
|
* Optional creator read-grant planner (see `modules/access/creator-access`).
|
|
178
171
|
* When present, a write/mkdir that starts a NEW FOLDER directly under one
|
|
@@ -601,7 +594,6 @@ export class LockingFilesystem extends GitGuardedFilesystem {
|
|
|
601
594
|
].sort();
|
|
602
595
|
const change = await versionControl.commitChanges(workspaceId, user, summary, scope);
|
|
603
596
|
await release('untouched');
|
|
604
|
-
if (change) this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: scope, byUser: user });
|
|
605
597
|
return change;
|
|
606
598
|
} catch (err) {
|
|
607
599
|
if (err instanceof PushNeedsAgentResolutionError) {
|
|
@@ -1027,16 +1019,10 @@ export class LockingFilesystem extends GitGuardedFilesystem {
|
|
|
1027
1019
|
await releaseAll((p) =>
|
|
1028
1020
|
committed.has(p) || failedSeedDiscards.has(p) ? 'discard' : 'untouched',
|
|
1029
1021
|
);
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
// tree) changed nothing, so it emits nothing.
|
|
1035
|
-
if (change) {
|
|
1036
|
-
// `touched` = caller paths + any creator-grant seeds that landed in the
|
|
1037
|
-
// same commit — the exact set this batch may have touched.
|
|
1038
|
-
this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: touched, byUser: user });
|
|
1039
|
-
}
|
|
1022
|
+
// The commit, if one landed, was reported by the git layer as it landed
|
|
1023
|
+
// (`VersionControlHooks.onCommitLanded`) with the paths it committed —
|
|
1024
|
+
// `touched` plus any creator-grant seed in the same commit. Nothing to
|
|
1025
|
+
// announce here.
|
|
1040
1026
|
return change;
|
|
1041
1027
|
}
|
|
1042
1028
|
|
|
@@ -4,8 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { DEFAULT_KB_LAYOUT, type AuthUser, type IVersionControlService, type KbLayout } from '@bevel-software/platform-shared';
|
|
6
6
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
7
|
-
import {
|
|
8
|
-
import { WorkspaceMutex } from '../../kb-fs/
|
|
7
|
+
import { realDisk } from '../../../__tests__/real-disk.js';
|
|
8
|
+
import { WorkspaceMutex } from '../../kb-fs/index.js';
|
|
9
9
|
import { KbPluginSource } from '../../plugins/index.js';
|
|
10
10
|
import { CLAIM_TTL_MS, StarterPackError, StarterPackService, commitSubjectOf, summaryOf } from '../starter-pack.service.js';
|
|
11
11
|
|
|
@@ -92,8 +92,8 @@ function harness({
|
|
|
92
92
|
const svc = new StarterPackService({
|
|
93
93
|
packsDir,
|
|
94
94
|
kb,
|
|
95
|
-
disk:
|
|
96
|
-
pluginSource: new KbPluginSource(
|
|
95
|
+
disk: realDisk(),
|
|
96
|
+
pluginSource: new KbPluginSource(realDisk(), kb),
|
|
97
97
|
pluginLocks,
|
|
98
98
|
workspaceService: {
|
|
99
99
|
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-main' })),
|
|
@@ -66,7 +66,7 @@ import { PushNeedsAgentResolutionError, VersionControlDomainError } from '../../
|
|
|
66
66
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
67
67
|
import type { IAdminAccessService } from '../admin/index.js';
|
|
68
68
|
import type { IAccessControl } from '../access/index.js';
|
|
69
|
-
import type {
|
|
69
|
+
import type { WorkspaceMutex } from '../kb-fs/index.js';
|
|
70
70
|
import { LockingFilesystem } from '../kb-fs/index.js';
|
|
71
71
|
import type { IFsProbe, ITreeWalker } from '../../shared/fs.contract.js';
|
|
72
72
|
import {
|
|
@@ -146,8 +146,6 @@ export interface StarterPackServiceDeps {
|
|
|
146
146
|
pluginLocks: PluginIdentityLocks;
|
|
147
147
|
/** The SSE bus: `fs-tree-changed` sends every open tree on the branch to fetch again. */
|
|
148
148
|
events?: { emit(event: { kind: 'fs-tree-changed'; workspaceId: string; branch: string }): void };
|
|
149
|
-
/** The post-commit hook catalogs refresh on — the plugin's skills appear without a restart. */
|
|
150
|
-
fileChanges?: FileChangeNotifier;
|
|
151
149
|
/**
|
|
152
150
|
* One choice at a time on this replica: the second of two quick clicks
|
|
153
151
|
* finds the first one's answer recorded. Across replicas the recorded
|
|
@@ -434,7 +432,7 @@ export class StarterPackService implements IStarterPackService, FirstRunStarterS
|
|
|
434
432
|
const writes = await this.plan(user, pack, files, path.join(basePath, kb.kbDirName));
|
|
435
433
|
const fsys = new LockingFilesystem(
|
|
436
434
|
{ basePath, contained: true },
|
|
437
|
-
{ versionControl, workspaceId: ws.id, branch, user, kbDirName: kb.kbDirName
|
|
435
|
+
{ versionControl, workspaceId: ws.id, branch, user, kbDirName: kb.kbDirName },
|
|
438
436
|
);
|
|
439
437
|
let written: string[] = [];
|
|
440
438
|
try {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import type { TreeWalkOptions, WalkListener, WalkResult } from '../../../shared/fs.contract.js';
|
|
2
|
+
import { realDisk } from '../../../__tests__/real-disk.js';
|
|
4
3
|
import fs from 'node:fs/promises';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
import os from 'node:os';
|
|
7
6
|
import { DEFAULT_BRANCH, pluginDisplayNameOf, type AuthUser } from '@bevel-software/platform-shared';
|
|
8
7
|
|
|
9
|
-
import type { WorkspaceService } from '../../workspace/
|
|
10
|
-
import {
|
|
8
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
9
|
+
import { realAccessControl } from '../../../__tests__/real-access.js';
|
|
11
10
|
import { PushNeedsAgentResolutionError } from '../../../shared/domain-errors.js';
|
|
12
11
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
13
12
|
import { KbPluginSource } from '../discovery/kb-plugin-source.js';
|
|
@@ -16,22 +15,22 @@ import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
|
16
15
|
|
|
17
16
|
// The one walk of the checkout, with a switch that makes it report a hole —
|
|
18
17
|
// the way a folder it cannot list would — to every listener on it. The
|
|
19
|
-
// walk is injected, so the switch is a disk that lies, not a module mock
|
|
18
|
+
// walk is injected, so the switch is a disk that lies, not a module mock:
|
|
19
|
+
// the real disk the shared fixture hands out, its walk wrapped.
|
|
20
20
|
const walkMock = { holeInTheWalk: false };
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
): Promise<WalkResult> {
|
|
27
|
-
const result = await super.walkKb(root, listeners, options);
|
|
21
|
+
function holedDisk(): ReturnType<typeof realDisk> {
|
|
22
|
+
const disk = realDisk();
|
|
23
|
+
const walkKb = disk.walkKb.bind(disk);
|
|
24
|
+
disk.walkKb = async (root, listeners, options) => {
|
|
25
|
+
const result = await walkKb(root, listeners, options);
|
|
28
26
|
if (walkMock.holeInTheWalk) {
|
|
29
27
|
const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
30
28
|
for (const l of listeners) await l.onHole?.('KnowledgeBase/Notes', err);
|
|
31
29
|
result.holes.push('KnowledgeBase/Notes');
|
|
32
30
|
}
|
|
33
31
|
return result;
|
|
34
|
-
}
|
|
32
|
+
};
|
|
33
|
+
return disk;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
/**
|
|
@@ -58,15 +57,19 @@ describe('PluginRenameService', () => {
|
|
|
58
57
|
let repo: string;
|
|
59
58
|
let commits: { summary: string; paths: string[] }[];
|
|
60
59
|
let failNextCommit: Error | null;
|
|
60
|
+
/** What each workspace-relative path last read as when it was committed — the driver's picture of the tree. */
|
|
61
|
+
let committed: Map<string, string>;
|
|
61
62
|
let failWriteMatching: string | null;
|
|
62
63
|
let invalidated: number;
|
|
63
|
-
let access:
|
|
64
|
+
let access: ReturnType<typeof realAccessControl>;
|
|
64
65
|
let svc: PluginRenameService;
|
|
65
66
|
|
|
67
|
+
// A seed is a committed file: what origin has before the rename runs.
|
|
66
68
|
const write = async (rel: string, text: string) => {
|
|
67
69
|
const abs = path.join(repo, rel);
|
|
68
70
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
69
71
|
await fs.writeFile(abs, text);
|
|
72
|
+
committed.set(`${KB_DIR}/${rel}`, text);
|
|
70
73
|
};
|
|
71
74
|
const read = (rel: string) => fs.readFile(path.join(repo, rel), 'utf-8');
|
|
72
75
|
const manifest = async () => JSON.parse(await read('Plugins/GTM/plugin.json'));
|
|
@@ -77,6 +80,7 @@ describe('PluginRenameService', () => {
|
|
|
77
80
|
commits = [];
|
|
78
81
|
failNextCommit = null;
|
|
79
82
|
failWriteMatching = null;
|
|
83
|
+
committed = new Map();
|
|
80
84
|
walkMock.holeInTheWalk = false;
|
|
81
85
|
invalidated = 0;
|
|
82
86
|
const workspaceService = {
|
|
@@ -101,7 +105,19 @@ describe('PluginRenameService', () => {
|
|
|
101
105
|
failNextCommit = null;
|
|
102
106
|
throw err;
|
|
103
107
|
}
|
|
108
|
+
// Git's rule: a tree whose files all read as they were last committed
|
|
109
|
+
// has nothing to commit, and the service is answered null.
|
|
110
|
+
let changed = false;
|
|
111
|
+
for (const p of paths) {
|
|
112
|
+
const now = await fs.readFile(path.join(root, wsId, p), 'utf-8');
|
|
113
|
+
if (now !== committed.get(p)) {
|
|
114
|
+
changed = true;
|
|
115
|
+
committed.set(p, now);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!changed) return null;
|
|
104
119
|
commits.push({ summary, paths });
|
|
120
|
+
return { sha: 'c0ffee' } as never;
|
|
105
121
|
},
|
|
106
122
|
};
|
|
107
123
|
|
|
@@ -114,8 +130,8 @@ describe('PluginRenameService', () => {
|
|
|
114
130
|
await write('Skills/Eng/deploy/access.md', DEPLOY_RULES);
|
|
115
131
|
await write('Skills/Eng/deploy/SKILL.md', '---\ndescription: Ship it.\n---\n');
|
|
116
132
|
|
|
117
|
-
access =
|
|
118
|
-
const disk =
|
|
133
|
+
access = realAccessControl(workspaceService, KB_DIR, realDisk());
|
|
134
|
+
const disk = holedDisk();
|
|
119
135
|
svc = new PluginRenameService(workspaceService, driver, access, new KbPluginSource(disk, testKbContext({ kbDirName: KB_DIR })), disk, testKbContext({ kbDirName: KB_DIR }), undefined, () => {
|
|
120
136
|
invalidated += 1;
|
|
121
137
|
});
|
|
@@ -125,7 +141,7 @@ describe('PluginRenameService', () => {
|
|
|
125
141
|
it('refuses to claim a name against a listing with a hole in it — an unreadable folder may hold that very plugin', async () => {
|
|
126
142
|
// Discovery as the walker reports it when a folder exists but could not
|
|
127
143
|
// be read: the plugins it did see, plus the hole.
|
|
128
|
-
const real = new KbPluginSource(
|
|
144
|
+
const real = new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR }));
|
|
129
145
|
const holed = {
|
|
130
146
|
dialect: 'kb',
|
|
131
147
|
discover: async (root: string) => ({ ...(await real.discover(root)), unreadable: ['Plugins/Hidden'] }),
|
|
@@ -135,7 +151,7 @@ describe('PluginRenameService', () => {
|
|
|
135
151
|
{ commitChanges: async () => { throw new Error('must not commit'); } },
|
|
136
152
|
access,
|
|
137
153
|
holed,
|
|
138
|
-
|
|
154
|
+
realDisk(),
|
|
139
155
|
testKbContext({ kbDirName: KB_DIR }),
|
|
140
156
|
);
|
|
141
157
|
await expect(svcOverHole.rename(manager, 'gtm', { name: 'go-to-market' })).rejects.toMatchObject({
|
|
@@ -156,7 +172,7 @@ describe('PluginRenameService', () => {
|
|
|
156
172
|
});
|
|
157
173
|
|
|
158
174
|
it('tells a NON-manager nothing about the tree — a holed discovery is still just "unknown plugin" to them', async () => {
|
|
159
|
-
const real = new KbPluginSource(
|
|
175
|
+
const real = new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR }));
|
|
160
176
|
const holed = {
|
|
161
177
|
dialect: 'kb',
|
|
162
178
|
discover: async (root: string) => ({ ...(await real.discover(root)), unreadable: ['Plugins/Hidden'] }),
|
|
@@ -166,7 +182,7 @@ describe('PluginRenameService', () => {
|
|
|
166
182
|
{ commitChanges: async () => { throw new Error('must not commit'); } },
|
|
167
183
|
access,
|
|
168
184
|
holed,
|
|
169
|
-
|
|
185
|
+
realDisk(),
|
|
170
186
|
testKbContext({ kbDirName: KB_DIR }),
|
|
171
187
|
);
|
|
172
188
|
const refusal = await svcOverHole.rename(member, 'gtm', { name: 'go-to-market' }).catch((e: unknown) => e);
|
|
@@ -270,7 +286,7 @@ describe('PluginRenameService', () => {
|
|
|
270
286
|
expect(await read('Skills/Eng/deploy/access.md')).toBe(DEPLOY_RULES);
|
|
271
287
|
// The three answers that must never diverge: the rename's, the
|
|
272
288
|
// discovery source's, and the shared reader's over the file itself.
|
|
273
|
-
const { plugins } = await new KbPluginSource(
|
|
289
|
+
const { plugins } = await new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB_DIR })).discover(repo);
|
|
274
290
|
const found = plugins.find((p) => p.folder === 'Plugins/GTM')!;
|
|
275
291
|
expect([found.name, found.displayName]).toEqual([result.name, result.displayName]);
|
|
276
292
|
expect(pluginDisplayNameOf(JSON.parse(await read('Plugins/GTM/plugin.json')))).toBe(result.displayName);
|
|
@@ -413,6 +429,15 @@ describe('PluginRenameService', () => {
|
|
|
413
429
|
expect(await access.canRead(wsId, member.email, 'Skills/Eng/deploy/SKILL.md')).toBe(true);
|
|
414
430
|
});
|
|
415
431
|
|
|
432
|
+
it('changes no bytes when the manifest already reads as the rename writes it — a clean tree, nothing to commit', async () => {
|
|
433
|
+
// The manifest already reads exactly as the rename will write it.
|
|
434
|
+
const asWritten = `${JSON.stringify({ name: 'gtm', version: '1.0.0', displayName: 'GTM' }, null, 2)}\n`;
|
|
435
|
+
await write('Plugins/GTM/plugin.json', asWritten);
|
|
436
|
+
await svc.rename(manager, 'gtm', { displayName: 'GTM' });
|
|
437
|
+
expect(await read('Plugins/GTM/plugin.json')).toBe(asWritten);
|
|
438
|
+
expect(commits).toEqual([]);
|
|
439
|
+
});
|
|
440
|
+
|
|
416
441
|
it('will not rename a plugin read from an external format — that repository owns its name', async () => {
|
|
417
442
|
await write('Plugins/Ext/plugin.bundle.json', '{"name":"ext"}');
|
|
418
443
|
await write('Plugins/Ext/access.md', GTM_RULES);
|
|
@@ -5,16 +5,16 @@ import path from 'node:path';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import express from 'express';
|
|
7
7
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
8
|
-
import {
|
|
8
|
+
import { realDisk } from '../../../__tests__/real-disk.js';
|
|
9
9
|
import { KbPluginSource } from '../discovery/kb-plugin-source.js';
|
|
10
10
|
|
|
11
11
|
import { DEFAULT_BRANCH, joinBranchFor } from '@bevel-software/platform-shared';
|
|
12
12
|
import type { ChangeRequest, IVersionControlService } from '@bevel-software/platform-shared';
|
|
13
13
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
14
|
-
import type { WorkspaceService } from '../../workspace/
|
|
15
|
-
import type { IAccessControl } from '../../access/
|
|
16
|
-
import type { ISkillService, SkillSummary } from '../../skills/
|
|
17
|
-
import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/
|
|
14
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
15
|
+
import type { IAccessControl } from '../../access/index.js';
|
|
16
|
+
import type { ISkillService, SkillSummary } from '../../skills/index.js';
|
|
17
|
+
import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/index.js';
|
|
18
18
|
import { PluginIndexService } from '../plugins.service.js';
|
|
19
19
|
import { createPluginCreationRoutes, createPluginsRoutes } from '../plugins.routes.js';
|
|
20
20
|
import type { JoinRequestsService } from '../join-requests.service.js';
|
|
@@ -162,7 +162,7 @@ async function makeHarness(opts: HarnessOpts = {}) {
|
|
|
162
162
|
skillService,
|
|
163
163
|
toolService,
|
|
164
164
|
testKbContext({ kbDirName: KB }),
|
|
165
|
-
new KbPluginSource(
|
|
165
|
+
new KbPluginSource(realDisk(), testKbContext({ kbDirName: KB })),
|
|
166
166
|
);
|
|
167
167
|
|
|
168
168
|
const email = opts.email === undefined ? ALI : opts.email;
|
|
@@ -11,8 +11,8 @@ import type {
|
|
|
11
11
|
import type { GitService } from '../git/git.service.js';
|
|
12
12
|
import type { PullRequestService } from '../git/pull-request.service.js';
|
|
13
13
|
import type { IChangeRequestReviewService } from '../change-request-review/change-request-review.interface.js';
|
|
14
|
-
import type { WorkspaceService } from '../../workspace/
|
|
15
|
-
import type { IAccessControl } from '../../access/
|
|
14
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
15
|
+
import type { IAccessControl } from '../../access/index.js';
|
|
16
16
|
import { FileLockService } from '../file-lock.service.js';
|
|
17
17
|
import { PendingCommitsService, type PendingCommit } from '../pending-commits.service.js';
|
|
18
18
|
import { PendingCommitsWorker } from '../pending-commits.worker.js';
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '../../../shared/domain-errors.js';
|
|
29
29
|
import type { VersionControlEventBus } from '../event-bus.js';
|
|
30
30
|
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
31
|
-
import type { Database } from '../../database/
|
|
31
|
+
import type { Database } from '../../database/index.js';
|
|
32
32
|
import { openChangeGate } from '../../../__tests__/open-change-gate.js';
|
|
33
33
|
import { ReadOnBranchDeniedError } from '../read-on-branch.js';
|
|
34
34
|
import { hashEmail } from '../../../shared/email-identity.js';
|
|
@@ -1801,7 +1801,7 @@ describe('VersionControlService.openChangeRequest — tells the change-request l
|
|
|
1801
1801
|
const svc = new VersionControlService(
|
|
1802
1802
|
db, git, prs, makeChangeRequestReview(), workspaces, access,
|
|
1803
1803
|
makeFileLockService(), makePendingCommits(), testKbContext(), openChangeGate(),
|
|
1804
|
-
{ emit: vi.fn() } as unknown as VersionControlEventBus, undefined,
|
|
1804
|
+
{ emit: vi.fn() } as unknown as VersionControlEventBus, undefined, hooks, accounts,
|
|
1805
1805
|
);
|
|
1806
1806
|
return { svc, run, prs, access, accounts, values };
|
|
1807
1807
|
}
|
|
@@ -4,14 +4,14 @@ import type { AuthUser, Change } from '@bevel-software/platform-shared';
|
|
|
4
4
|
import type { GitService } from '../git/git.service.js';
|
|
5
5
|
import type { PullRequestService } from '../git/pull-request.service.js';
|
|
6
6
|
import type { IChangeRequestReviewService } from '../change-request-review/change-request-review.interface.js';
|
|
7
|
-
import type { WorkspaceService } from '../../workspace/
|
|
8
|
-
import type { IAccessControl } from '../../access/
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/index.js';
|
|
9
9
|
import { FileLockService } from '../file-lock.service.js';
|
|
10
10
|
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
11
11
|
import { VersionControlEventBus } from '../event-bus.js';
|
|
12
12
|
import { VersionControlService, syncConflictMessage } from '../version-control.service.js';
|
|
13
13
|
import { openChangeGate } from '../../../__tests__/open-change-gate.js';
|
|
14
|
-
import type { Database } from '../../database/
|
|
14
|
+
import type { Database } from '../../database/index.js';
|
|
15
15
|
import {
|
|
16
16
|
PullRebaseConflictError,
|
|
17
17
|
PullRebaseConflictError,
|
|
@@ -87,24 +87,39 @@ function makePending(): PendingCommitsService {
|
|
|
87
87
|
} as unknown as PendingCommitsService;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
interface GitBehavior {
|
|
91
91
|
commitFile: Change | null;
|
|
92
92
|
pushBehavior: 'ok' | 'nff' | 'auth-fail';
|
|
93
93
|
pullBehavior: 'ok' | 'fail' | 'conflict';
|
|
94
94
|
pushAfterPullBehavior: 'ok' | 'fail';
|
|
95
95
|
hasUnpushedCommits: boolean;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The git stub, whose behaviour a test changes in place (`behave`) — the service keeps the one instance it was given. */
|
|
99
|
+
type GitStub = GitService & { behave(next: Partial<GitBehavior>): void };
|
|
100
|
+
|
|
101
|
+
function makeGit(overrides: Partial<GitBehavior> = {}): GitStub {
|
|
102
|
+
const b: GitBehavior = {
|
|
103
|
+
commitFile: CHANGE,
|
|
104
|
+
pushBehavior: 'ok',
|
|
105
|
+
pullBehavior: 'ok',
|
|
106
|
+
pushAfterPullBehavior: 'ok',
|
|
107
|
+
hasUnpushedCommits: false,
|
|
108
|
+
...overrides,
|
|
109
|
+
};
|
|
101
110
|
let pushCalls = 0;
|
|
111
|
+
const behave = (next: Partial<GitBehavior>): void => {
|
|
112
|
+
Object.assign(b, next);
|
|
113
|
+
pushCalls = 0;
|
|
114
|
+
};
|
|
102
115
|
return {
|
|
103
|
-
|
|
116
|
+
behave,
|
|
117
|
+
commitFile: vi.fn().mockImplementation(async () => b.commitFile),
|
|
104
118
|
discardPath: vi.fn().mockResolvedValue(undefined),
|
|
105
|
-
hasUnpushedCommits: vi.fn().
|
|
119
|
+
hasUnpushedCommits: vi.fn().mockImplementation(async () => b.hasUnpushedCommits),
|
|
106
120
|
push: vi.fn().mockImplementation(async () => {
|
|
107
121
|
pushCalls++;
|
|
122
|
+
const { pushBehavior, pushAfterPullBehavior } = b;
|
|
108
123
|
const phase = pushCalls === 1 ? pushBehavior : pushAfterPullBehavior;
|
|
109
124
|
if (phase === 'nff') {
|
|
110
125
|
throw new Error(
|
|
@@ -122,11 +137,11 @@ function makeGit(overrides: Partial<{
|
|
|
122
137
|
}
|
|
123
138
|
}),
|
|
124
139
|
pull: vi.fn().mockImplementation(async () => {
|
|
125
|
-
if (pullBehavior === 'fail') throw new Error('git pull failed: merge conflict');
|
|
126
|
-
if (pullBehavior === 'conflict') throw new PullRebaseConflictError('feat/x', ['foo.md'], 'CONFLICT (content)');
|
|
140
|
+
if (b.pullBehavior === 'fail') throw new Error('git pull failed: merge conflict');
|
|
141
|
+
if (b.pullBehavior === 'conflict') throw new PullRebaseConflictError('feat/x', ['foo.md'], 'CONFLICT (content)');
|
|
127
142
|
return { treeChanged: true };
|
|
128
143
|
}),
|
|
129
|
-
} as unknown as
|
|
144
|
+
} as unknown as GitStub;
|
|
130
145
|
}
|
|
131
146
|
|
|
132
147
|
function makeFacade(
|
|
@@ -642,18 +657,15 @@ describe('VersionControlService — git-sync visibility events', () => {
|
|
|
642
657
|
});
|
|
643
658
|
|
|
644
659
|
it('emits git-sync-recovered once when a push finally lands, then goes quiet again', async () => {
|
|
645
|
-
const
|
|
646
|
-
const svc = makeFacade(
|
|
660
|
+
const git = makeGit({ pushBehavior: 'auth-fail' });
|
|
661
|
+
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
647
662
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
|
|
648
663
|
expect(kinds()).toEqual(['git-sync-failed']);
|
|
649
664
|
|
|
650
665
|
// Same service instance — the failing/healthy state is per-workspace and
|
|
651
|
-
// lives on it, so recovery has to be observed through the same object
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
Object.assign(svc as unknown as Record<string, unknown>, {
|
|
655
|
-
git: (recovered as unknown as { git: GitService }).git,
|
|
656
|
-
});
|
|
666
|
+
// lives on it, so recovery has to be observed through the same object;
|
|
667
|
+
// the remote comes back by the stub's behaviour changing.
|
|
668
|
+
git.behave({ pushBehavior: 'ok' });
|
|
657
669
|
|
|
658
670
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
659
671
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
@@ -672,15 +684,11 @@ describe('VersionControlService — git-sync visibility events', () => {
|
|
|
672
684
|
// callers hand the encoded form (user%2Ffeat). The tracker must treat
|
|
673
685
|
// them as one workspace, or a failure recorded by one caller can never
|
|
674
686
|
// be cleared by the other's success and the banner sticks forever.
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
makeFileLocks(USER.id),
|
|
678
|
-
makePending(),
|
|
679
|
-
events,
|
|
680
|
-
);
|
|
687
|
+
const git = makeGit({ pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' });
|
|
688
|
+
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
681
689
|
await svc.runPendingCommit('user%2Ffeat', 'user/feat', 'foo.md', USER).catch(() => undefined);
|
|
682
690
|
|
|
683
|
-
|
|
691
|
+
git.behave({ pushBehavior: 'ok', pushAfterPullBehavior: 'ok' });
|
|
684
692
|
await svc.runPendingCommit('user/feat', 'user/feat', 'foo.md', USER);
|
|
685
693
|
|
|
686
694
|
const sync = emitSpy.mock.calls
|
|
@@ -694,18 +702,20 @@ describe('VersionControlService — git-sync visibility events', () => {
|
|
|
694
702
|
});
|
|
695
703
|
|
|
696
704
|
it('tracks workspaces independently — one recovering neither clears nor suppresses another', async () => {
|
|
697
|
-
const
|
|
698
|
-
const
|
|
705
|
+
const failing = { pushBehavior: 'auth-fail', pushAfterPullBehavior: 'fail' } as const;
|
|
706
|
+
const git = makeGit(failing);
|
|
707
|
+
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
699
708
|
|
|
700
709
|
// Both workspaces broken.
|
|
701
710
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER).catch(() => undefined);
|
|
711
|
+
git.behave(failing);
|
|
702
712
|
await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
|
|
703
713
|
|
|
704
714
|
// ws-1 recovers; ws-2 is still broken and must (a) not be cleared by
|
|
705
715
|
// ws-1's recovery and (b) still announce its own next failure.
|
|
706
|
-
|
|
716
|
+
git.behave({ pushBehavior: 'ok', pushAfterPullBehavior: 'ok' });
|
|
707
717
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
708
|
-
|
|
718
|
+
git.behave(failing);
|
|
709
719
|
await svc.runPendingCommit('ws-2', 'feat/y', 'bar.md', USER).catch(() => undefined);
|
|
710
720
|
|
|
711
721
|
expect(
|