@bevel-software/platform-core-backend 0.4.0 → 0.4.1
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/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +13 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +5 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +12 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/package.json +2 -2
- package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +44 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +306 -306
- package/src/modules/workflow/git/git.service.ts +13 -2
- package/src/modules/workflow/workflow.service.ts +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bevel-software/platform-core-backend",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"pg": "^8.20.0",
|
|
46
46
|
"yaml": "^2.9.0",
|
|
47
47
|
"zod": "^3.24.0",
|
|
48
|
-
"@bevel-software/platform-shared": "0.4.
|
|
48
|
+
"@bevel-software/platform-shared": "0.4.1"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/adm-zip": "^0.5.8",
|
|
@@ -1,72 +1,82 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
3
|
-
import type { Database } from '../../database/connection.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
|
|
7
|
-
* insert only when NO row exists for the (workspace, branch, path) in ANY
|
|
8
|
-
* status. A plain `enqueue` would refresh an existing pending row and reset
|
|
9
|
-
* its retry counters — and the dispatch path fires on every sync attempt,
|
|
10
|
-
* so those resets would starve the worker's ladder and the recovery agent
|
|
11
|
-
* would never spawn. A `needs_attention` row must also block re-entry (the
|
|
12
|
-
* ladder already escalated to a human; more agent runs are just a loop).
|
|
13
|
-
*
|
|
14
|
-
* Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
|
|
15
|
-
* behavior (claim fencing etc.) is covered elsewhere; this pins the
|
|
16
|
-
* check-then-insert decision logic.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
3
|
+
import type { Database } from '../../database/connection.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
|
|
7
|
+
* insert only when NO row exists for the (workspace, branch, path) in ANY
|
|
8
|
+
* status. A plain `enqueue` would refresh an existing pending row and reset
|
|
9
|
+
* its retry counters — and the dispatch path fires on every sync attempt,
|
|
10
|
+
* so those resets would starve the worker's ladder and the recovery agent
|
|
11
|
+
* would never spawn. A `needs_attention` row must also block re-entry (the
|
|
12
|
+
* ladder already escalated to a human; more agent runs are just a loop).
|
|
13
|
+
*
|
|
14
|
+
* Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
|
|
15
|
+
* behavior (claim fencing etc.) is covered elsewhere; this pins the
|
|
16
|
+
* check-then-insert decision logic.
|
|
17
|
+
*/
|
|
18
|
+
interface SelectChain extends PromiseLike<unknown> {
|
|
19
|
+
from: (...args: unknown[]) => SelectChain;
|
|
20
|
+
where: (...args: unknown[]) => SelectChain;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InsertChain extends PromiseLike<unknown> {
|
|
24
|
+
values: (...args: unknown[]) => InsertChain;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeFakeDb(existingCount: number) {
|
|
28
|
+
const inserted: unknown[] = [];
|
|
29
|
+
const selectChain: SelectChain = {
|
|
30
|
+
from: vi.fn(() => selectChain),
|
|
31
|
+
where: vi.fn(() => selectChain),
|
|
32
|
+
then: (onF, onR) => Promise.resolve([{ count: existingCount }]).then(onF, onR),
|
|
33
|
+
};
|
|
34
|
+
const insertChain: InsertChain = {
|
|
35
|
+
values: vi.fn((v: unknown) => {
|
|
36
|
+
inserted.push(v);
|
|
37
|
+
return insertChain;
|
|
38
|
+
}),
|
|
39
|
+
then: (onF, onR) => Promise.resolve(undefined).then(onF, onR),
|
|
40
|
+
};
|
|
41
|
+
const db = {
|
|
42
|
+
select: vi.fn(() => selectChain),
|
|
43
|
+
insert: vi.fn(() => insertChain),
|
|
44
|
+
} as unknown as Database;
|
|
45
|
+
return { db, inserted };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const INPUT = {
|
|
49
|
+
workspaceId: 'razvan.radulescu/feature',
|
|
50
|
+
branch: 'razvan.radulescu/feature',
|
|
51
|
+
path: 'PR-Overviews/PR-7.html',
|
|
52
|
+
authorEmail: 'Alice@Example.com',
|
|
53
|
+
authorName: 'Alice',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('PendingCommitsService.enqueueIfAbsent', () => {
|
|
57
|
+
it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
|
|
58
|
+
const { db, inserted } = makeFakeDb(0);
|
|
59
|
+
const svc = new PendingCommitsService(db);
|
|
60
|
+
|
|
61
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
|
|
62
|
+
|
|
63
|
+
expect(inserted).toHaveLength(1);
|
|
64
|
+
expect(inserted[0]).toMatchObject({
|
|
65
|
+
// Same canonicalization as `enqueue` — the worker claims per the
|
|
66
|
+
// ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
|
|
67
|
+
workspaceId: 'razvan.radulescu%2Ffeature',
|
|
68
|
+
branch: 'razvan.radulescu/feature',
|
|
69
|
+
path: 'PR-Overviews/PR-7.html',
|
|
70
|
+
authorEmail: 'alice@example.com',
|
|
71
|
+
authorName: 'Alice',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
|
|
76
|
+
const { db, inserted } = makeFakeDb(1);
|
|
77
|
+
const svc = new PendingCommitsService(db);
|
|
78
|
+
|
|
79
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
|
|
80
|
+
expect(inserted).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -12,6 +12,7 @@ import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
|
12
12
|
import { FileLockService } from '../file-lock.service.js';
|
|
13
13
|
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
14
14
|
import { WorkflowService } from '../workflow.service.js';
|
|
15
|
+
import { PullRebaseConflictError } from '../workflow.errors.js';
|
|
15
16
|
import type { Database } from '../../database/connection.js';
|
|
16
17
|
|
|
17
18
|
// The facade tests never exercise `openChangeRequest` (the only method that
|
|
@@ -50,6 +51,7 @@ function makePendingCommits(): PendingCommitsService {
|
|
|
50
51
|
// behavior is covered by `pending-commits.service.test.ts`.
|
|
51
52
|
return {
|
|
52
53
|
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
enqueueIfAbsent: vi.fn().mockResolvedValue(true),
|
|
53
55
|
claimNext: vi.fn().mockResolvedValue(null),
|
|
54
56
|
markSucceeded: vi.fn().mockResolvedValue(undefined),
|
|
55
57
|
markTransientFailure: vi.fn().mockResolvedValue(undefined),
|
|
@@ -327,6 +329,48 @@ describe('WorkflowService — change request delegation + cache invalidation', (
|
|
|
327
329
|
);
|
|
328
330
|
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(4);
|
|
329
331
|
});
|
|
332
|
+
|
|
333
|
+
it('mergeChangeRequest still merges when the post-merge pull conflicts, and queues recovery on the TARGET workspace', async () => {
|
|
334
|
+
const prs = makePrs();
|
|
335
|
+
const reviewWorkflow = makeReviewWorkflow();
|
|
336
|
+
const mergeResult = { prNumber: 4, sha: 'abc', mergedAt: 't' };
|
|
337
|
+
(reviewWorkflow.mergePr as ReturnType<typeof vi.fn>).mockResolvedValue(mergeResult);
|
|
338
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({ branch: 'alice/feat', base: 'main' });
|
|
339
|
+
|
|
340
|
+
const git = makeGit();
|
|
341
|
+
const conflict = new PullRebaseConflictError(
|
|
342
|
+
'main',
|
|
343
|
+
// Deliberately unsorted — the representative row must be the smallest.
|
|
344
|
+
['b-second.md', 'a-first.md'],
|
|
345
|
+
'git rebase failed: could not apply deadbee',
|
|
346
|
+
);
|
|
347
|
+
(git.pull as ReturnType<typeof vi.fn>).mockRejectedValue(conflict);
|
|
348
|
+
const workspaceService = makeWorkspaceService();
|
|
349
|
+
// Return an id that ISN'T derivable from the branch name, pinning that
|
|
350
|
+
// the dispatch uses the id `getOrCreateForBranch` returned rather than
|
|
351
|
+
// re-deriving it.
|
|
352
|
+
(workspaceService.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
353
|
+
id: 'resolved-target-ws',
|
|
354
|
+
});
|
|
355
|
+
const pending = makePendingCommits();
|
|
356
|
+
const svc = new WorkflowService(makeDb(), git, prs, reviewWorkflow, workspaceService, makeAccessControl(), makeFileLockService(), pending, 'knowledge-base');
|
|
357
|
+
|
|
358
|
+
const outcome = await svc.mergeChangeRequest(
|
|
359
|
+
4, makeUser(), 'sha', [], 'open', 'PR title', 'main', 'w1', { bypass: true },
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// The merge already landed on origin — a stuck target workspace must not
|
|
363
|
+
// fail the response.
|
|
364
|
+
expect(outcome).toEqual({ kind: 'merged', result: mergeResult });
|
|
365
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledTimes(1);
|
|
366
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledWith({
|
|
367
|
+
workspaceId: 'resolved-target-ws',
|
|
368
|
+
branch: 'main',
|
|
369
|
+
path: 'a-first.md',
|
|
370
|
+
authorEmail: 'alice@example.com',
|
|
371
|
+
authorName: 'Alice',
|
|
372
|
+
});
|
|
373
|
+
});
|
|
330
374
|
});
|
|
331
375
|
|
|
332
376
|
describe('WorkflowService — file lock delegation', () => {
|