@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
|
@@ -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,76 @@ 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
|
+
let abortSucceeded = true;
|
|
1407
|
+
await this.git(cwd, ['rebase', '--abort']).catch(() => {
|
|
1408
|
+
abortSucceeded = false;
|
|
1409
|
+
});
|
|
1410
|
+
if (abortSucceeded && conflictedPaths.length > 0) {
|
|
1411
|
+
// Typed so the workflow layer can queue background recovery — this
|
|
1412
|
+
// divergence never resolves on its own (every retry pull hits the
|
|
1413
|
+
// same conflict) and the unpushed local commits are someone's saved
|
|
1414
|
+
// content stuck violating save=share.
|
|
1415
|
+
//
|
|
1416
|
+
// Gated on the abort succeeding: a failed abort means either no
|
|
1417
|
+
// rebase was ever active (the unmerged paths pre-date this pull —
|
|
1418
|
+
// some earlier operation left the index broken) or the clone is in
|
|
1419
|
+
// a state git itself can't unwind. Queueing recovery there would
|
|
1420
|
+
// point the worker's commitFile at a repo mid-conflict, risking a
|
|
1421
|
+
// commit full of conflict markers — surface the raw error instead
|
|
1422
|
+
// and leave diagnosis to a human.
|
|
1423
|
+
throw new PullRebaseConflictError(
|
|
1424
|
+
branch,
|
|
1425
|
+
conflictedPaths,
|
|
1426
|
+
err instanceof Error ? err.message : String(err),
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1394
1429
|
throw err;
|
|
1395
1430
|
}
|
|
1396
1431
|
this.accessControl?.invalidate(workspaceId);
|
|
1397
1432
|
});
|
|
1398
1433
|
}
|
|
1399
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* Does the checked-out branch carry commits its remote-tracking ref
|
|
1437
|
+
* doesn't reach? Purely local (`rev-list` against
|
|
1438
|
+
* `refs/remotes/origin/<branch>` — no network), so a stale tracking ref
|
|
1439
|
+
* can only over-report: commits that were in fact already pushed make
|
|
1440
|
+
* this return true, and the follow-up push is a harmless no-op. A
|
|
1441
|
+
* missing tracking ref (branch never pushed) counts as unpushed — that
|
|
1442
|
+
* IS unshared work.
|
|
1443
|
+
*
|
|
1444
|
+
* Used by the pending-commits worker's no-op arm: a clean tree does NOT
|
|
1445
|
+
* mean "nothing to share" when a prior best-effort push (the autosave
|
|
1446
|
+
* path) failed and left the committed change stranded locally.
|
|
1447
|
+
*/
|
|
1448
|
+
async hasUnpushedCommits(workspaceId: string): Promise<boolean> {
|
|
1449
|
+
const cwd = await this.repoDir(workspaceId);
|
|
1450
|
+
const branch = await this.currentBranch(cwd);
|
|
1451
|
+
try {
|
|
1452
|
+
const { stdout } = await this.git(cwd, [
|
|
1453
|
+
'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
|
|
1454
|
+
]);
|
|
1455
|
+
return Number(stdout.trim()) > 0;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return true;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1400
1461
|
/**
|
|
1401
1462
|
* Hard-reset the workspace's checked-out branch to `origin/<branch>`, fetching
|
|
1402
1463
|
* 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,67 @@ 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, smallest 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. Sorted so the choice is
|
|
334
|
+
* stable across retries regardless of how the caller ordered the paths —
|
|
335
|
+
* a shifting representative would slip past `enqueueIfAbsent`'s
|
|
336
|
+
* per-(workspace, branch, path) dedup and start a second ladder.
|
|
337
|
+
*
|
|
338
|
+
* Best-effort by design — the caller is already surfacing the conflict
|
|
339
|
+
* error; a queue hiccup must not mask it.
|
|
340
|
+
*/
|
|
341
|
+
private async queuePullConflictRecovery(
|
|
342
|
+
workspaceId: string,
|
|
343
|
+
err: PullRebaseConflictError,
|
|
344
|
+
user?: AuthUser,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
try {
|
|
347
|
+
const queued = await this.pendingCommits.enqueueIfAbsent({
|
|
348
|
+
workspaceId,
|
|
349
|
+
branch: err.branch,
|
|
350
|
+
path: [...err.conflictedPaths].sort()[0],
|
|
351
|
+
authorEmail: user?.email ?? RECOVERY_BOT_EMAIL,
|
|
352
|
+
authorName: user?.name ?? RECOVERY_BOT_NAME,
|
|
353
|
+
});
|
|
354
|
+
if (queued) {
|
|
355
|
+
console.warn(
|
|
356
|
+
`[workflow] pull conflict on ws=${workspaceId} branch=${err.branch} (${err.conflictedPaths.join(', ')}) — queued background recovery`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
} catch (queueErr) {
|
|
360
|
+
console.error(
|
|
361
|
+
`[workflow] failed to queue pull-conflict recovery for ws=${workspaceId}:`,
|
|
362
|
+
queueErr instanceof Error ? queueErr.message : queueErr,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
304
365
|
}
|
|
305
366
|
|
|
306
367
|
resetToRemote(workspaceId: string, branch: string): Promise<void> {
|
|
@@ -680,9 +741,21 @@ export class WorkflowService implements IWorkflowService {
|
|
|
680
741
|
): Promise<void> {
|
|
681
742
|
const change = await this.git.commitFile(workspaceId, user, targetPath);
|
|
682
743
|
if (!change) {
|
|
683
|
-
// No-op commit
|
|
684
|
-
// or a save of bytes identical to HEAD
|
|
685
|
-
//
|
|
744
|
+
// No-op commit — the path was already clean. Usually a double-enqueue
|
|
745
|
+
// or a save of bytes identical to HEAD, BUT a clean tree does NOT
|
|
746
|
+
// prove there's nothing to share: the autosave path
|
|
747
|
+
// (`commitFileWhileLocked`) commits locally with a best-effort push,
|
|
748
|
+
// and when that push fails the commit stays stranded on the local
|
|
749
|
+
// branch with the tree clean. Treating that as success dropped the
|
|
750
|
+
// row without ever starting the retry → recovery-agent ladder —
|
|
751
|
+
// which is exactly how a diverged workspace ends up stuck forever.
|
|
752
|
+
// So: if the branch is ahead of its remote-tracking ref, push (with
|
|
753
|
+
// the same cooperative recovery, so a conflicting divergence throws
|
|
754
|
+
// and the worker's ladder takes over). Nothing to emit either way —
|
|
755
|
+
// there's no new sha.
|
|
756
|
+
if (await this.git.hasUnpushedCommits(workspaceId)) {
|
|
757
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
758
|
+
}
|
|
686
759
|
return;
|
|
687
760
|
}
|
|
688
761
|
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
@@ -1348,10 +1421,21 @@ export class WorkflowService implements IWorkflowService {
|
|
|
1348
1421
|
// this a read right after a merge misses the just-merged change. Best-effort:
|
|
1349
1422
|
// the merge already succeeded on origin, so a pull hiccup must not fail the
|
|
1350
1423
|
// response (a later fetch/pull reconciles).
|
|
1424
|
+
let targetWorkspaceId: string | undefined;
|
|
1351
1425
|
try {
|
|
1352
1426
|
const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1427
|
+
targetWorkspaceId = targetWorkspace.id;
|
|
1353
1428
|
await this.git.pull(targetWorkspace.id);
|
|
1354
1429
|
} catch (err) {
|
|
1430
|
+
// Still best-effort for the merge response (the merge already landed
|
|
1431
|
+
// on origin) — but a rebase CONFLICT here means the target workspace
|
|
1432
|
+
// is stranded (local commits vs origin, never self-heals), so queue
|
|
1433
|
+
// the background recovery ladder before shrugging. A conflict can
|
|
1434
|
+
// only have come from the pull, so the workspace id is always set on
|
|
1435
|
+
// this arm — the guard just satisfies the narrowing.
|
|
1436
|
+
if (err instanceof PullRebaseConflictError && targetWorkspaceId) {
|
|
1437
|
+
await this.queuePullConflictRecovery(targetWorkspaceId, err, user);
|
|
1438
|
+
}
|
|
1355
1439
|
console.warn(
|
|
1356
1440
|
`[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
|
|
1357
1441
|
err,
|