@bevel-software/platform-core-backend 0.3.2 → 0.4.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/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 +47 -1
- 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 +25 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +75 -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 +72 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +111 -2
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +74 -0
- package/src/modules/workflow/git/git.service.ts +50 -0
- 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 +83 -5
|
@@ -0,0 +1,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
|
+
function makeFakeDb(existingCount: number) {
|
|
19
|
+
const inserted: unknown[] = [];
|
|
20
|
+
const selectChain: any = {};
|
|
21
|
+
selectChain.from = vi.fn(() => selectChain);
|
|
22
|
+
selectChain.where = vi.fn(() => selectChain);
|
|
23
|
+
selectChain.then = (onF: any, onR: any) =>
|
|
24
|
+
Promise.resolve([{ count: existingCount }]).then(onF, onR);
|
|
25
|
+
const insertChain: any = {};
|
|
26
|
+
insertChain.values = vi.fn((v: unknown) => {
|
|
27
|
+
inserted.push(v);
|
|
28
|
+
return insertChain;
|
|
29
|
+
});
|
|
30
|
+
insertChain.then = (onF: any, onR: any) => Promise.resolve(undefined).then(onF, onR);
|
|
31
|
+
const db = {
|
|
32
|
+
select: vi.fn(() => selectChain),
|
|
33
|
+
insert: vi.fn(() => insertChain),
|
|
34
|
+
} as unknown as Database;
|
|
35
|
+
return { db, inserted };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const INPUT = {
|
|
39
|
+
workspaceId: 'razvan.radulescu/feature',
|
|
40
|
+
branch: 'razvan.radulescu/feature',
|
|
41
|
+
path: 'PR-Overviews/PR-7.html',
|
|
42
|
+
authorEmail: 'Alice@Example.com',
|
|
43
|
+
authorName: 'Alice',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('PendingCommitsService.enqueueIfAbsent', () => {
|
|
47
|
+
it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
|
|
48
|
+
const { db, inserted } = makeFakeDb(0);
|
|
49
|
+
const svc = new PendingCommitsService(db);
|
|
50
|
+
|
|
51
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
|
|
52
|
+
|
|
53
|
+
expect(inserted).toHaveLength(1);
|
|
54
|
+
expect(inserted[0]).toMatchObject({
|
|
55
|
+
// Same canonicalization as `enqueue` — the worker claims per the
|
|
56
|
+
// ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
|
|
57
|
+
workspaceId: 'razvan.radulescu%2Ffeature',
|
|
58
|
+
branch: 'razvan.radulescu/feature',
|
|
59
|
+
path: 'PR-Overviews/PR-7.html',
|
|
60
|
+
authorEmail: 'alice@example.com',
|
|
61
|
+
authorName: 'Alice',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
|
|
66
|
+
const { db, inserted } = makeFakeDb(1);
|
|
67
|
+
const svc = new PendingCommitsService(db);
|
|
68
|
+
|
|
69
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
|
|
70
|
+
expect(inserted).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { GitService } from '../git.service.js';
|
|
6
|
+
import { PullRebaseConflictError } from '../../workflow.errors.js';
|
|
6
7
|
import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
|
|
7
8
|
|
|
8
9
|
/** See git.service.createBranch.test.ts — same prod-mirroring layout. */
|
|
@@ -229,4 +230,77 @@ describe('GitService.pull', () => {
|
|
|
229
230
|
const dash = await fs.readFile(path.join(repo, 'dash.html'), 'utf8');
|
|
230
231
|
expect(dash).toBe('v1\n');
|
|
231
232
|
});
|
|
233
|
+
|
|
234
|
+
// The production stuck-workspace state: a local commit and an origin commit
|
|
235
|
+
// both rewrote the same file, so replaying the local commit conflicts. The
|
|
236
|
+
// pull must (a) abort the rebase so the clone isn't left in a detached
|
|
237
|
+
// apply state, (b) surface a TYPED error naming the contested paths so the
|
|
238
|
+
// workflow layer can queue background recovery, and (c) leave the local
|
|
239
|
+
// commit intact (it's someone's saved content).
|
|
240
|
+
it('throws PullRebaseConflictError with the conflicted paths and aborts the rebase cleanly', async () => {
|
|
241
|
+
const { upstream, repo } = await seedWorkspace(root, workspaceId);
|
|
242
|
+
|
|
243
|
+
// Shared base version of the contested file.
|
|
244
|
+
await fs.writeFile(path.join(repo, 'PR-7.html'), 'base\n');
|
|
245
|
+
await runGit(repo, ['add', '.']);
|
|
246
|
+
await runGit(repo, ['commit', '-m', 'add overview']);
|
|
247
|
+
await runGit(repo, ['push', 'origin', 'target-company-state']);
|
|
248
|
+
|
|
249
|
+
// Origin rewrites it one way...
|
|
250
|
+
const pusher = path.join(root, '.conflict-pusher');
|
|
251
|
+
await runGit(root, ['clone', upstream, pusher]);
|
|
252
|
+
await fs.writeFile(path.join(pusher, 'PR-7.html'), 'origin version\n');
|
|
253
|
+
await runGit(pusher, ['add', '.']);
|
|
254
|
+
await runGit(pusher, ['commit', '-m', 'origin rewrite']);
|
|
255
|
+
await runGit(pusher, ['push', 'origin', 'target-company-state']);
|
|
256
|
+
|
|
257
|
+
// ...and the local branch rewrites it another way (the stranded commit).
|
|
258
|
+
await fs.writeFile(path.join(repo, 'PR-7.html'), 'local version\n');
|
|
259
|
+
await runGit(repo, ['add', '.']);
|
|
260
|
+
await runGit(repo, ['commit', '-m', 'local rewrite']);
|
|
261
|
+
const localHead = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
262
|
+
|
|
263
|
+
const svc = new GitService(
|
|
264
|
+
stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
|
|
265
|
+
stubWorkflowHooks(),
|
|
266
|
+
'knowledge-base',
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const err = await svc.pull(workspaceId).then(() => null).catch((e: unknown) => e);
|
|
270
|
+
expect(err).toBeInstanceOf(PullRebaseConflictError);
|
|
271
|
+
expect((err as PullRebaseConflictError).branch).toBe('target-company-state');
|
|
272
|
+
expect((err as PullRebaseConflictError).conflictedPaths).toEqual(['PR-7.html']);
|
|
273
|
+
|
|
274
|
+
// The rebase was aborted: no in-progress rebase state, clean tree, and
|
|
275
|
+
// the local commit is still HEAD with its version of the file on disk.
|
|
276
|
+
await expect(
|
|
277
|
+
fs.access(path.join(repo, '.git', 'rebase-merge')),
|
|
278
|
+
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
279
|
+
expect(await gitOut(repo, ['status', '--porcelain'])).toBe('');
|
|
280
|
+
expect(await gitOut(repo, ['rev-parse', 'HEAD'])).toBe(localHead);
|
|
281
|
+
// \r tolerated: Windows checkouts with autocrlf rewrite the file on the
|
|
282
|
+
// abort's checkout — content identity is what matters here.
|
|
283
|
+
const onDisk = await fs.readFile(path.join(repo, 'PR-7.html'), 'utf8');
|
|
284
|
+
expect(onDisk.replace(/\r/g, '')).toBe('local version\n');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// hasUnpushedCommits backs the worker's no-op arm (see runPendingCommit).
|
|
288
|
+
it('hasUnpushedCommits: false when in sync, true once a local commit lands', async () => {
|
|
289
|
+
const { repo } = await seedWorkspace(root, workspaceId);
|
|
290
|
+
const svc = new GitService(
|
|
291
|
+
stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
|
|
292
|
+
stubWorkflowHooks(),
|
|
293
|
+
'knowledge-base',
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
|
|
297
|
+
|
|
298
|
+
await fs.writeFile(path.join(repo, 'new.txt'), 'x\n');
|
|
299
|
+
await runGit(repo, ['add', '.']);
|
|
300
|
+
await runGit(repo, ['commit', '-m', 'unpushed']);
|
|
301
|
+
await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(true);
|
|
302
|
+
|
|
303
|
+
await runGit(repo, ['push', 'origin', 'target-company-state']);
|
|
304
|
+
await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
|
|
305
|
+
});
|
|
232
306
|
});
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
BranchAuthorshipError,
|
|
30
30
|
WorkflowValidationError,
|
|
31
31
|
ProtectedBranchError,
|
|
32
|
+
PullRebaseConflictError,
|
|
32
33
|
} from '../workflow.errors.js';
|
|
33
34
|
|
|
34
35
|
const execFileAsync = promisify(execFile);
|
|
@@ -1387,16 +1388,65 @@ export class GitService implements IGitService {
|
|
|
1387
1388
|
const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
|
|
1388
1389
|
await this.git(cwd, ['rebase', '--autostash', remoteRef]);
|
|
1389
1390
|
} catch (err) {
|
|
1391
|
+
// Capture the contested paths BEFORE the abort wipes the rebase
|
|
1392
|
+
// state — `--diff-filter=U` lists exactly the files whose replay
|
|
1393
|
+
// conflicted. Best-effort: an empty list just means this wasn't a
|
|
1394
|
+
// content conflict (or the probe itself failed) and the raw error
|
|
1395
|
+
// is surfaced unchanged.
|
|
1396
|
+
let conflictedPaths: string[] = [];
|
|
1397
|
+
try {
|
|
1398
|
+
const { stdout } = await this.git(cwd, ['diff', '--name-only', '--diff-filter=U']);
|
|
1399
|
+
conflictedPaths = stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1400
|
+
} catch {
|
|
1401
|
+
// fall through with an empty list
|
|
1402
|
+
}
|
|
1390
1403
|
// A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
|
|
1391
1404
|
// which makes every subsequent git command behave unexpectedly. Return the
|
|
1392
1405
|
// working tree to a clean state before surfacing the original error.
|
|
1393
1406
|
await this.git(cwd, ['rebase', '--abort']).catch(() => undefined);
|
|
1407
|
+
if (conflictedPaths.length > 0) {
|
|
1408
|
+
// Typed so the workflow layer can queue background recovery — this
|
|
1409
|
+
// divergence never resolves on its own (every retry pull hits the
|
|
1410
|
+
// same conflict) and the unpushed local commits are someone's saved
|
|
1411
|
+
// content stuck violating save=share.
|
|
1412
|
+
throw new PullRebaseConflictError(
|
|
1413
|
+
branch,
|
|
1414
|
+
conflictedPaths,
|
|
1415
|
+
err instanceof Error ? err.message : String(err),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1394
1418
|
throw err;
|
|
1395
1419
|
}
|
|
1396
1420
|
this.accessControl?.invalidate(workspaceId);
|
|
1397
1421
|
});
|
|
1398
1422
|
}
|
|
1399
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* Does the checked-out branch carry commits its remote-tracking ref
|
|
1426
|
+
* doesn't reach? Purely local (`rev-list` against
|
|
1427
|
+
* `refs/remotes/origin/<branch>` — no network), so a stale tracking ref
|
|
1428
|
+
* can only over-report: commits that were in fact already pushed make
|
|
1429
|
+
* this return true, and the follow-up push is a harmless no-op. A
|
|
1430
|
+
* missing tracking ref (branch never pushed) counts as unpushed — that
|
|
1431
|
+
* IS unshared work.
|
|
1432
|
+
*
|
|
1433
|
+
* Used by the pending-commits worker's no-op arm: a clean tree does NOT
|
|
1434
|
+
* mean "nothing to share" when a prior best-effort push (the autosave
|
|
1435
|
+
* path) failed and left the committed change stranded locally.
|
|
1436
|
+
*/
|
|
1437
|
+
async hasUnpushedCommits(workspaceId: string): Promise<boolean> {
|
|
1438
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1439
|
+
const branch = await this.currentBranch(cwd);
|
|
1440
|
+
try {
|
|
1441
|
+
const { stdout } = await this.git(cwd, [
|
|
1442
|
+
'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
|
|
1443
|
+
]);
|
|
1444
|
+
return Number(stdout.trim()) > 0;
|
|
1445
|
+
} catch {
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1400
1450
|
/**
|
|
1401
1451
|
* Hard-reset the workspace's checked-out branch to `origin/<branch>`, fetching
|
|
1402
1452
|
* first. This is the break-glass primitive behind roles.yaml recovery: it
|
|
@@ -216,6 +216,45 @@ export class PendingCommitsService {
|
|
|
216
216
|
});
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Enqueue ONLY when no row — in ANY status — exists for
|
|
221
|
+
* `(workspaceId, branch, path)`. The pull-conflict recovery dispatch uses
|
|
222
|
+
* this instead of `enqueue` because that path can fire repeatedly (every
|
|
223
|
+
* branch focus / merge retries the pull) and `enqueue`'s refresh
|
|
224
|
+
* semantics would reset the existing row's retry counters each time —
|
|
225
|
+
* starving the worker's ladder so the recovery agent never spawns. A
|
|
226
|
+
* `needs_attention` row also blocks re-entry on purpose: the ladder
|
|
227
|
+
* already escalated that divergence to a human; spawning more agents on
|
|
228
|
+
* it would loop.
|
|
229
|
+
*
|
|
230
|
+
* Returns whether a row was inserted. Race window between the existence
|
|
231
|
+
* check and the insert is harmless — the schema allows duplicate rows and
|
|
232
|
+
* the worker's idempotent commit pass collapses them (same reasoning as
|
|
233
|
+
* `startupReconcile`'s bypass of the mutex).
|
|
234
|
+
*/
|
|
235
|
+
async enqueueIfAbsent(input: EnqueueInput): Promise<boolean> {
|
|
236
|
+
const workspaceId = canonicalWorkspaceId(input.workspaceId);
|
|
237
|
+
const rows = await this.db
|
|
238
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
239
|
+
.from(pendingCommits)
|
|
240
|
+
.where(
|
|
241
|
+
and(
|
|
242
|
+
eq(pendingCommits.workspaceId, workspaceId),
|
|
243
|
+
eq(pendingCommits.branch, input.branch),
|
|
244
|
+
eq(pendingCommits.path, input.path),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
if ((rows[0]?.count ?? 0) > 0) return false;
|
|
248
|
+
await this.db.insert(pendingCommits).values({
|
|
249
|
+
workspaceId,
|
|
250
|
+
branch: input.branch,
|
|
251
|
+
path: input.path,
|
|
252
|
+
authorEmail: input.authorEmail.trim().toLowerCase(),
|
|
253
|
+
authorName: input.authorName,
|
|
254
|
+
});
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
219
258
|
/**
|
|
220
259
|
* Atomically claim the next ready row for `workspaceId`. "Ready" means:
|
|
221
260
|
*
|
|
@@ -120,6 +120,47 @@ export class PushNeedsAgentResolutionError extends WorkflowDomainError {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Refreshing a workspace from origin (`GitService.pull`) hit a rebase
|
|
125
|
+
* conflict: the workspace carries local commits origin doesn't have, origin
|
|
126
|
+
* moved ahead with changes that touch the same files, and replaying the
|
|
127
|
+
* local commits conflicts. The rebase has already been aborted — the
|
|
128
|
+
* workspace is back in its pre-pull state (diverged, clean tree).
|
|
129
|
+
*
|
|
130
|
+
* This state does NOT resolve itself: every subsequent pull hits the same
|
|
131
|
+
* conflict, and the local commits (someone's saved content, under
|
|
132
|
+
* save=share) stay unshared until the divergence is reconciled. The service
|
|
133
|
+
* layer therefore reacts by queueing a background recovery run (see
|
|
134
|
+
* `WorkflowService.updateFromRemote`); this error tells the caller what
|
|
135
|
+
* happened and which paths are contested.
|
|
136
|
+
*
|
|
137
|
+
* 409 like the other conflict errors — the operation needs the divergence
|
|
138
|
+
* cleared before it can succeed. `detail` (the raw git failure, already
|
|
139
|
+
* credential-redacted by `GitService.git`) is kept OFF the payload; it's for
|
|
140
|
+
* server logs and the recovery pipeline, not the client.
|
|
141
|
+
*/
|
|
142
|
+
export class PullRebaseConflictError extends WorkflowDomainError {
|
|
143
|
+
readonly kind = 'pull-rebase-conflict' as const;
|
|
144
|
+
constructor(
|
|
145
|
+
readonly branch: string,
|
|
146
|
+
readonly conflictedPaths: string[],
|
|
147
|
+
readonly detail: string,
|
|
148
|
+
) {
|
|
149
|
+
const list = conflictedPaths.join(', ');
|
|
150
|
+
super(
|
|
151
|
+
`Updating "${branch}" from origin hit conflicts in ${conflictedPaths.length} file(s): ${list}. ` +
|
|
152
|
+
`Automatic background recovery has been queued.`,
|
|
153
|
+
409,
|
|
154
|
+
{
|
|
155
|
+
kind: 'pull-rebase-conflict',
|
|
156
|
+
branch,
|
|
157
|
+
conflictedPaths,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
this.name = 'PullRebaseConflictError';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
/**
|
|
124
165
|
* Generic 400 for workflow-input validation (malformed branch names, missing
|
|
125
166
|
* fields, etc.). Carries an optional payload so callers can attach typed
|
|
@@ -192,9 +192,12 @@ export function createWorkflowRoutes(
|
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
router.post('/workspace/:id/workflow/update-from-remote', async (req, res) => {
|
|
195
|
-
|
|
195
|
+
// The user is threaded through so a pull-conflict recovery row is
|
|
196
|
+
// attributed to whoever triggered the sync (see updateFromRemote).
|
|
197
|
+
const user = await requireUser(req, res);
|
|
198
|
+
if (!user) return;
|
|
196
199
|
try {
|
|
197
|
-
await workflow.updateFromRemote(req.params.id);
|
|
200
|
+
await workflow.updateFromRemote(req.params.id, user);
|
|
198
201
|
res.json({ status: 'updated' });
|
|
199
202
|
} catch (err) {
|
|
200
203
|
const { status, body } = toHttpError(err);
|
|
@@ -60,9 +60,11 @@ import {
|
|
|
60
60
|
ChangeRequestConflictsError,
|
|
61
61
|
DuplicateChangeRequestError,
|
|
62
62
|
RolesYamlPreservationError,
|
|
63
|
+
PullRebaseConflictError,
|
|
63
64
|
PushNeedsAgentResolutionError,
|
|
64
65
|
WorkflowValidationError,
|
|
65
66
|
} from './workflow.errors.js';
|
|
67
|
+
import { RECOVERY_BOT_EMAIL, RECOVERY_BOT_NAME } from './recovery-bot.js';
|
|
66
68
|
import { AccessDeniedError } from '../access/access-errors.js';
|
|
67
69
|
|
|
68
70
|
const execFileAsync = promisify(execFile);
|
|
@@ -299,8 +301,64 @@ export class WorkflowService implements IWorkflowService {
|
|
|
299
301
|
return this.git.fetch(workspaceId);
|
|
300
302
|
}
|
|
301
303
|
|
|
302
|
-
updateFromRemote(workspaceId: string): Promise<void> {
|
|
303
|
-
|
|
304
|
+
async updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void> {
|
|
305
|
+
try {
|
|
306
|
+
await this.git.pull(workspaceId);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err instanceof PullRebaseConflictError) {
|
|
309
|
+
await this.queuePullConflictRecovery(workspaceId, err, user);
|
|
310
|
+
}
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A pull hit a rebase conflict — the workspace carries local commits that
|
|
317
|
+
* conflict with origin, a state that never resolves on its own (every
|
|
318
|
+
* retry pull re-hits it, and the local commits are saved-but-unshared
|
|
319
|
+
* content violating save=share). Queue ONE pending-commits row for it so
|
|
320
|
+
* the worker's existing retry → recovery-agent → escalate ladder takes
|
|
321
|
+
* over in the background.
|
|
322
|
+
*
|
|
323
|
+
* `enqueueIfAbsent` (not `enqueue`): sync attempts can fire repeatedly
|
|
324
|
+
* (every branch focus / merge), and a fresh `enqueue` would reset the
|
|
325
|
+
* existing row's retry counters each time — starving the ladder so the
|
|
326
|
+
* recovery agent never spawns. It also refuses to resurrect a
|
|
327
|
+
* `needs_attention` row: once the ladder has escalated to a human, more
|
|
328
|
+
* agent runs on the same divergence are just a loop.
|
|
329
|
+
*
|
|
330
|
+
* One row, first conflicted path as the representative: the recovery
|
|
331
|
+
* agent's job is "bring the BRANCH into a pushable state", so one ladder
|
|
332
|
+
* per divergence — not one per conflicted file, which would multiply
|
|
333
|
+
* agent runs for a single underlying conflict.
|
|
334
|
+
*
|
|
335
|
+
* Best-effort by design — the caller is already surfacing the conflict
|
|
336
|
+
* error; a queue hiccup must not mask it.
|
|
337
|
+
*/
|
|
338
|
+
private async queuePullConflictRecovery(
|
|
339
|
+
workspaceId: string,
|
|
340
|
+
err: PullRebaseConflictError,
|
|
341
|
+
user?: AuthUser,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
try {
|
|
344
|
+
const queued = await this.pendingCommits.enqueueIfAbsent({
|
|
345
|
+
workspaceId,
|
|
346
|
+
branch: err.branch,
|
|
347
|
+
path: err.conflictedPaths[0],
|
|
348
|
+
authorEmail: user?.email ?? RECOVERY_BOT_EMAIL,
|
|
349
|
+
authorName: user?.name ?? RECOVERY_BOT_NAME,
|
|
350
|
+
});
|
|
351
|
+
if (queued) {
|
|
352
|
+
console.warn(
|
|
353
|
+
`[workflow] pull conflict on ws=${workspaceId} branch=${err.branch} (${err.conflictedPaths.join(', ')}) — queued background recovery`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
} catch (queueErr) {
|
|
357
|
+
console.error(
|
|
358
|
+
`[workflow] failed to queue pull-conflict recovery for ws=${workspaceId}:`,
|
|
359
|
+
queueErr instanceof Error ? queueErr.message : queueErr,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
304
362
|
}
|
|
305
363
|
|
|
306
364
|
resetToRemote(workspaceId: string, branch: string): Promise<void> {
|
|
@@ -680,9 +738,21 @@ export class WorkflowService implements IWorkflowService {
|
|
|
680
738
|
): Promise<void> {
|
|
681
739
|
const change = await this.git.commitFile(workspaceId, user, targetPath);
|
|
682
740
|
if (!change) {
|
|
683
|
-
// No-op commit
|
|
684
|
-
// or a save of bytes identical to HEAD
|
|
685
|
-
//
|
|
741
|
+
// No-op commit — the path was already clean. Usually a double-enqueue
|
|
742
|
+
// or a save of bytes identical to HEAD, BUT a clean tree does NOT
|
|
743
|
+
// prove there's nothing to share: the autosave path
|
|
744
|
+
// (`commitFileWhileLocked`) commits locally with a best-effort push,
|
|
745
|
+
// and when that push fails the commit stays stranded on the local
|
|
746
|
+
// branch with the tree clean. Treating that as success dropped the
|
|
747
|
+
// row without ever starting the retry → recovery-agent ladder —
|
|
748
|
+
// which is exactly how a diverged workspace ends up stuck forever.
|
|
749
|
+
// So: if the branch is ahead of its remote-tracking ref, push (with
|
|
750
|
+
// the same cooperative recovery, so a conflicting divergence throws
|
|
751
|
+
// and the worker's ladder takes over). Nothing to emit either way —
|
|
752
|
+
// there's no new sha.
|
|
753
|
+
if (await this.git.hasUnpushedCommits(workspaceId)) {
|
|
754
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
755
|
+
}
|
|
686
756
|
return;
|
|
687
757
|
}
|
|
688
758
|
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
@@ -1352,6 +1422,14 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1352
1422
|
const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1353
1423
|
await this.git.pull(targetWorkspace.id);
|
|
1354
1424
|
} catch (err) {
|
|
1425
|
+
// Still best-effort for the merge response (the merge already landed
|
|
1426
|
+
// on origin) — but a rebase CONFLICT here means the target workspace
|
|
1427
|
+
// is stranded (local commits vs origin, never self-heals), so queue
|
|
1428
|
+
// the background recovery ladder before shrugging.
|
|
1429
|
+
if (err instanceof PullRebaseConflictError) {
|
|
1430
|
+
const targetWorkspaceId = encodeURIComponent(baseBranch);
|
|
1431
|
+
await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
|
|
1432
|
+
}
|
|
1355
1433
|
console.warn(
|
|
1356
1434
|
`[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
|
|
1357
1435
|
err,
|