@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
@@ -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
- await this.git(cwd, ['rebase', '--abort']).catch(() => undefined);
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
- if (!(await requireUser(req, res))) return;
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
- return this.git.pull(workspaceId);
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 (path was already clean — typical for a double-enqueue
684
- // or a save of bytes identical to HEAD). Nothing to push; nothing to
685
- // emit. The worker treats this as success and drops the row.
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,