@bevel-software/platform-core-backend 0.3.2 → 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 +14 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +59 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +17 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +33 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.errors.d.ts +26 -0
- package/dist/modules/workflow/workflow.errors.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.errors.js +38 -0
- package/dist/modules/workflow/workflow.errors.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +5 -2
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +28 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +81 -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 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +44 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +111 -2
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +306 -232
- package/src/modules/workflow/git/git.service.ts +62 -1
- package/src/modules/workflow/pending-commits.service.ts +39 -0
- package/src/modules/workflow/workflow.errors.ts +41 -0
- package/src/modules/workflow/workflow.routes.ts +5 -2
- package/src/modules/workflow/workflow.service.ts +89 -5
|
@@ -0,0 +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
|
+
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', () => {
|
|
@@ -11,6 +11,7 @@ import { WorkflowEventBus } from '../event-bus.js';
|
|
|
11
11
|
import { WorkflowService } from '../workflow.service.js';
|
|
12
12
|
import type { Database } from '../../database/connection.js';
|
|
13
13
|
import {
|
|
14
|
+
PullRebaseConflictError,
|
|
14
15
|
PushNeedsAgentResolutionError,
|
|
15
16
|
WorkflowValidationError,
|
|
16
17
|
} from '../workflow.errors.js';
|
|
@@ -66,6 +67,7 @@ function makeFileLocks(holderUserId: string): FileLockService {
|
|
|
66
67
|
function makePending(): PendingCommitsService {
|
|
67
68
|
return {
|
|
68
69
|
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
enqueueIfAbsent: vi.fn().mockResolvedValue(true),
|
|
69
71
|
claimNext: vi.fn().mockResolvedValue(null),
|
|
70
72
|
markSucceeded: vi.fn().mockResolvedValue(undefined),
|
|
71
73
|
markTransientFailure: vi.fn().mockResolvedValue(undefined),
|
|
@@ -82,6 +84,7 @@ function makeGit(overrides: Partial<{
|
|
|
82
84
|
pushBehavior: 'ok' | 'nff' | 'auth-fail';
|
|
83
85
|
pullBehavior: 'ok' | 'fail';
|
|
84
86
|
pushAfterPullBehavior: 'ok' | 'fail';
|
|
87
|
+
hasUnpushedCommits: boolean;
|
|
85
88
|
}> = {}): GitService {
|
|
86
89
|
const commitFileResult = overrides.commitFile === undefined ? CHANGE : overrides.commitFile;
|
|
87
90
|
const pushBehavior = overrides.pushBehavior ?? 'ok';
|
|
@@ -90,6 +93,7 @@ function makeGit(overrides: Partial<{
|
|
|
90
93
|
let pushCalls = 0;
|
|
91
94
|
return {
|
|
92
95
|
commitFile: vi.fn().mockResolvedValue(commitFileResult),
|
|
96
|
+
hasUnpushedCommits: vi.fn().mockResolvedValue(overrides.hasUnpushedCommits ?? false),
|
|
93
97
|
push: vi.fn().mockImplementation(async () => {
|
|
94
98
|
pushCalls++;
|
|
95
99
|
const phase = pushCalls === 1 ? pushBehavior : pushAfterPullBehavior;
|
|
@@ -265,10 +269,10 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
265
269
|
});
|
|
266
270
|
});
|
|
267
271
|
|
|
268
|
-
it('skips push + file-changed
|
|
272
|
+
it('skips push + file-changed on a no-op commit with nothing unpushed (double-enqueue, identical bytes)', async () => {
|
|
269
273
|
// The worker treats a no-op commit as success and drops the row.
|
|
270
274
|
// No push, no SSE event — there's no new sha for clients to see.
|
|
271
|
-
const git = makeGit({ commitFile: null });
|
|
275
|
+
const git = makeGit({ commitFile: null, hasUnpushedCommits: false });
|
|
272
276
|
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
273
277
|
|
|
274
278
|
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
@@ -278,6 +282,41 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
278
282
|
expect(emitSpy).not.toHaveBeenCalled();
|
|
279
283
|
});
|
|
280
284
|
|
|
285
|
+
it('STILL pushes on a no-op commit when the branch is ahead of origin (stranded autosave commit)', async () => {
|
|
286
|
+
// Regression: the autosave path commits locally with a best-effort
|
|
287
|
+
// push. When that push fails, the eventual queue row finds a CLEAN
|
|
288
|
+
// tree — and the old early-return treated it as success, dropping the
|
|
289
|
+
// row without ever pushing. The local commit stayed stranded forever
|
|
290
|
+
// and the recovery ladder never started.
|
|
291
|
+
const git = makeGit({ commitFile: null, hasUnpushedCommits: true });
|
|
292
|
+
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
293
|
+
|
|
294
|
+
await svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER);
|
|
295
|
+
|
|
296
|
+
expect(git.push).toHaveBeenCalledTimes(1);
|
|
297
|
+
// No new commit → still no file-changed (there's no new sha).
|
|
298
|
+
expect(emitSpy).not.toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('no-op commit + unpushed + conflicting divergence throws so the worker ladder takes over', async () => {
|
|
302
|
+
// The stranded-commit case where origin ALSO moved with a conflicting
|
|
303
|
+
// change (the production stuck-workspace state): push rejects non-FF,
|
|
304
|
+
// the cooperative pull conflicts — the typed error must reach the
|
|
305
|
+
// worker so its retry → recovery-agent ladder runs.
|
|
306
|
+
const git = makeGit({
|
|
307
|
+
commitFile: null,
|
|
308
|
+
hasUnpushedCommits: true,
|
|
309
|
+
pushBehavior: 'nff',
|
|
310
|
+
pullBehavior: 'fail',
|
|
311
|
+
});
|
|
312
|
+
const svc = makeFacade(git, makeFileLocks(USER.id), makePending(), events);
|
|
313
|
+
|
|
314
|
+
await expect(svc.runPendingCommit('ws-1', 'feat/x', 'foo.md', USER)).rejects.toBeInstanceOf(
|
|
315
|
+
PushNeedsAgentResolutionError,
|
|
316
|
+
);
|
|
317
|
+
expect(emitSpy).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
281
320
|
it('recovers from non-fast-forward push via pull --rebase + retry, emits file-changed exactly once', async () => {
|
|
282
321
|
// Teammate pushed to the same branch between our commit and our
|
|
283
322
|
// push. The cooperative path pulls + retries — this is the case
|
|
@@ -338,3 +377,73 @@ describe('WorkflowService.runPendingCommit — worker entry point', () => {
|
|
|
338
377
|
expect(emitSpy).not.toHaveBeenCalled();
|
|
339
378
|
});
|
|
340
379
|
});
|
|
380
|
+
|
|
381
|
+
describe('WorkflowService.updateFromRemote — pull-conflict recovery dispatch', () => {
|
|
382
|
+
const CONFLICT = new PullRebaseConflictError(
|
|
383
|
+
'main',
|
|
384
|
+
['PR-Overviews/PR-7.html', 'other.md'],
|
|
385
|
+
'git rebase failed: could not apply 0d7b463',
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
function makeConflictingGit(err: unknown): GitService {
|
|
389
|
+
return {
|
|
390
|
+
pull: vi.fn().mockRejectedValue(err),
|
|
391
|
+
} as unknown as GitService;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
it('queues ONE recovery row (first conflicted path, triggering user) and rethrows', async () => {
|
|
395
|
+
const pending = makePending();
|
|
396
|
+
const svc = makeFacade(
|
|
397
|
+
makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await expect(svc.updateFromRemote('main', USER)).rejects.toBe(CONFLICT);
|
|
401
|
+
|
|
402
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledTimes(1);
|
|
403
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledWith({
|
|
404
|
+
workspaceId: 'main',
|
|
405
|
+
branch: 'main',
|
|
406
|
+
path: 'PR-Overviews/PR-7.html',
|
|
407
|
+
authorEmail: USER.email,
|
|
408
|
+
authorName: USER.name,
|
|
409
|
+
});
|
|
410
|
+
// `enqueue` (counter-resetting refresh) must NOT be used here — repeated
|
|
411
|
+
// sync attempts would starve the worker's ladder.
|
|
412
|
+
expect(pending.enqueue).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('falls back to the recovery-bot identity when no user is available', async () => {
|
|
416
|
+
const pending = makePending();
|
|
417
|
+
const svc = makeFacade(
|
|
418
|
+
makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await expect(svc.updateFromRemote('main')).rejects.toBe(CONFLICT);
|
|
422
|
+
|
|
423
|
+
const input = (pending.enqueueIfAbsent as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
424
|
+
expect(input.authorEmail).toContain('recovery-bot@');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('does NOT queue recovery for a non-conflict pull failure (transient fetch error)', async () => {
|
|
428
|
+
const pending = makePending();
|
|
429
|
+
const transient = new Error('git fetch failed: could not resolve host');
|
|
430
|
+
const svc = makeFacade(
|
|
431
|
+
makeConflictingGit(transient), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
await expect(svc.updateFromRemote('main', USER)).rejects.toBe(transient);
|
|
435
|
+
expect(pending.enqueueIfAbsent).not.toHaveBeenCalled();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('a queue hiccup does not mask the conflict error', async () => {
|
|
439
|
+
const pending = makePending();
|
|
440
|
+
(pending.enqueueIfAbsent as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
441
|
+
new Error('db down'),
|
|
442
|
+
);
|
|
443
|
+
const svc = makeFacade(
|
|
444
|
+
makeConflictingGit(CONFLICT), makeFileLocks(USER.id), pending, new WorkflowEventBus(),
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
await expect(svc.updateFromRemote('main', USER)).rejects.toBe(CONFLICT);
|
|
448
|
+
});
|
|
449
|
+
});
|