@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.
Files changed (29) hide show
  1. package/dist/modules/workflow/git/git.service.d.ts +14 -0
  2. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  3. package/dist/modules/workflow/git/git.service.js +59 -2
  4. package/dist/modules/workflow/git/git.service.js.map +1 -1
  5. package/dist/modules/workflow/pending-commits.service.d.ts +17 -0
  6. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  7. package/dist/modules/workflow/pending-commits.service.js +33 -0
  8. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  9. package/dist/modules/workflow/workflow.errors.d.ts +26 -0
  10. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -1
  11. package/dist/modules/workflow/workflow.errors.js +38 -0
  12. package/dist/modules/workflow/workflow.errors.js.map +1 -1
  13. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  14. package/dist/modules/workflow/workflow.routes.js +5 -2
  15. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  16. package/dist/modules/workflow/workflow.service.d.ts +28 -1
  17. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  18. package/dist/modules/workflow/workflow.service.js +81 -6
  19. package/dist/modules/workflow/workflow.service.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -0
  22. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +44 -0
  23. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +111 -2
  24. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +306 -232
  25. package/src/modules/workflow/git/git.service.ts +62 -1
  26. package/src/modules/workflow/pending-commits.service.ts +39 -0
  27. package/src/modules/workflow/workflow.errors.ts +41 -0
  28. package/src/modules/workflow/workflow.routes.ts +5 -2
  29. 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 when commitFile reports nothing dirty (double-enqueue, identical bytes)', async () => {
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
+ });