@bevel-software/platform-core-backend 0.10.0 → 0.11.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.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -42,6 +42,7 @@ import {
42
42
  type WriteOptions,
43
43
  } from '@mastra/core/workspace';
44
44
  import type { AuthUser, Change, IWorkflowService } from '@bevel-software/platform-shared';
45
+ import { PushNeedsAgentResolutionError } from './workflow.errors.js';
45
46
  import type { FileChangeNotifier } from './file-change-notifier.js';
46
47
  import type { CreationGrantPlan, ICreatorAccess } from '../access/creator-access.js';
47
48
 
@@ -167,6 +168,10 @@ export class LockingFilesystem extends LocalFilesystem {
167
168
  * `git commit`, then push), and release WITHOUT a per-file commit. Fail-closed:
168
169
  * if any lock can't be acquired, or a write/commit throws, nothing is committed
169
170
  * (the failure-path `releaseLockNoCommit` discards the uncommitted bytes).
171
+ * The ONE exception is `PushNeedsAgentResolutionError` — a POST-commit
172
+ * failure (the commit landed, the push didn't): locks release via
173
+ * `releaseLock` so the enqueued release commit arms the pending-commits
174
+ * worker's push retry, and the error still propagates to the caller.
170
175
  *
171
176
  * `writes[].path` is workspace-relative (like `writeFile`). We do the disk
172
177
  * write here; `commitChanges` only stages + commits what's on disk (the git
@@ -210,13 +215,50 @@ export class LockingFilesystem extends LocalFilesystem {
210
215
  // locked too (a delete mutates that path just like a write).
211
216
  const paths = [...new Set([...writes.map((w) => w.path), ...deletes])].sort();
212
217
  const acquired: string[] = [];
213
- const releaseAll = async (): Promise<void> => {
218
+ // Paths whose on-disk bytes THIS batch replaced (caller writes/deletes,
219
+ // recorded just before each disk op so a mid-write throw still counts).
220
+ // Deliberately NOT the seed paths: a seed write is an additive merge of
221
+ // the CURRENT bytes, and a failed one restores its pre-image itself —
222
+ // only a seed whose restore ALSO failed joins the discard set, via
223
+ // `failedSeedDiscards` (see the seed loop).
224
+ const dirtied = new Set<string>();
225
+ // Seed paths left holding known-partial bytes: the seed write threw
226
+ // mid-write AND the pre-image restore failed too. These must release
227
+ // with the discard so the corrupt bytes can't outlive the batch.
228
+ const failedSeedDiscards = new Set<string>();
229
+ // Release every acquired lock, each with the semantics its outcome
230
+ // earned. Three shapes (mirroring the three WorkflowService releases):
231
+ //
232
+ // 'discard' → releaseLockNoCommit: resets the path's working tree to
233
+ // HEAD. Only for bytes this batch itself owns — its committed paths
234
+ // (clean tree, the discard no-ops), its own failed writes, and a
235
+ // failed-restore seed (fail-closed: partial bytes must not land).
236
+ // 'enqueue' → releaseLock: enqueues a commit-on-release row. Only
237
+ // for the push-retry unwind, and only on the batch's OWN committed
238
+ // paths — the enqueued row is the vehicle that retries the push.
239
+ // 'untouched' → releaseLockUntouched: drops the lock row and nothing
240
+ // else. For every path the batch merely LOCKED (a creator seed that
241
+ // no-op'd or fully restored itself, a caller path never reached):
242
+ // such a path may hold a PRIOR save's dirty bytes whose commit is
243
+ // still queued — a discard would silently destroy that landed save,
244
+ // and an enqueue would re-attribute its queued row to THIS user and
245
+ // reset its retry ladder.
246
+ const releaseAll = async (
247
+ modeOf: (p: string) => 'discard' | 'enqueue' | 'untouched',
248
+ ): Promise<void> => {
214
249
  for (const p of acquired) {
215
250
  try {
216
- await workflow.releaseLockNoCommit(workspaceId, branch, p, user);
251
+ const mode = modeOf(p);
252
+ if (mode === 'discard') {
253
+ await workflow.releaseLockNoCommit(workspaceId, branch, p, user);
254
+ } else if (mode === 'enqueue') {
255
+ await workflow.releaseLock(workspaceId, branch, p, user);
256
+ } else {
257
+ await workflow.releaseLockUntouched(workspaceId, branch, p, user);
258
+ }
217
259
  } catch (releaseErr) {
218
260
  console.warn(
219
- `[locking-fs] releaseLockNoCommit failed for "${p}" during writeFiles:`,
261
+ `[locking-fs] lock release failed for "${p}" during writeFiles:`,
220
262
  releaseErr instanceof Error ? releaseErr.message : releaseErr,
221
263
  );
222
264
  }
@@ -237,7 +279,9 @@ export class LockingFilesystem extends LocalFilesystem {
237
279
  if (attempt < ACQUIRE_RETRY_ATTEMPTS - 1) await sleep(ACQUIRE_RETRY_DELAY_MS);
238
280
  }
239
281
  if (!ok) {
240
- await releaseAll();
282
+ // Nothing written yet — every held lock is a merely-locked path, so
283
+ // the unwind must leave disk and queue exactly as they are.
284
+ await releaseAll(() => 'untouched');
241
285
  throw new Error(
242
286
  `Skipped editing "${p}" — locked by ${holderName ?? 'another user'}. ` +
243
287
  `Continuing with other edits; try this one again later.`,
@@ -263,55 +307,173 @@ export class LockingFilesystem extends LocalFilesystem {
263
307
  }
264
308
  }
265
309
 
310
+ // The paths this batch actually TOUCHED — the caller's writes + deletes,
311
+ // plus each seed whose bytes really landed below. Kept separate from
312
+ // `acquired` (which only tracks held LOCKS): a seed lock is taken before
313
+ // we know whether the seed write happens, and scoping the commit to a
314
+ // merely-locked path would sweep in another save's still-queued dirty
315
+ // bytes on that path under this batch's author/summary.
316
+ const touched = [...paths];
317
+ // Seeds whose bytes LANDED, with the pre-image read under their lock:
318
+ // if the batch's commit then fails, these must be rolled back to that
319
+ // pre-image (not to HEAD — the pre-image may be a prior save's queued
320
+ // bytes) so an uncommitted grant can't sit on disk as if it were real.
321
+ const landedSeeds = new Map<string, { current: string; existedBefore: boolean }>();
266
322
  let change: Change | null;
267
323
  try {
268
324
  // Write/delete every file to disk inside the locks (workspace-relative;
269
325
  // LocalFilesystem resolves against basePath), then commit + push the whole
270
326
  // set as ONE change. `commitChanges` only stages + commits what's on disk.
271
- for (const w of writes) await super.writeFile(w.path, w.content);
272
- for (const p of deletes) await super.deleteFile(p);
327
+ for (const w of writes) {
328
+ dirtied.add(w.path);
329
+ await super.writeFile(w.path, w.content);
330
+ }
331
+ for (const p of deletes) {
332
+ dirtied.add(p);
333
+ await super.deleteFile(p);
334
+ }
273
335
  // Seeds merge into the CURRENT on-disk bytes (read under the lock) so a
274
336
  // concurrent creator's grant survives; per-seed failures are best-effort.
275
337
  for (const [p, apply] of seeds) {
276
- try {
277
- let current = '';
278
- const absolute = this.resolveAbsolutePath(p);
279
- if (absolute) {
280
- try {
281
- current = await fs.readFile(absolute, 'utf-8');
282
- } catch {
283
- // Not there yet — the normal case for a brand-new directory.
284
- }
338
+ let current = '';
339
+ let existedBefore = false;
340
+ const absolute = this.resolveAbsolutePath(p);
341
+ if (absolute) {
342
+ try {
343
+ current = await fs.readFile(absolute, 'utf-8');
344
+ existedBefore = true;
345
+ } catch {
346
+ // Not there yet — the normal case for a brand-new directory.
285
347
  }
286
- const next = apply(current);
287
- if (next !== current) await super.writeFile(p, next);
348
+ }
349
+ let next: string;
350
+ try {
351
+ next = apply(current);
352
+ } catch (err) {
353
+ // Plan failed before any bytes moved — the path is untouched.
354
+ console.warn(
355
+ `[locking-fs] creator access.md seed failed for "${p}":`,
356
+ err instanceof Error ? err.message : err,
357
+ );
358
+ continue;
359
+ }
360
+ if (next === current) continue;
361
+ try {
362
+ await super.writeFile(p, next);
363
+ touched.push(p);
364
+ landedSeeds.set(p, { current, existedBefore });
288
365
  } catch (err) {
366
+ // The write itself threw — it may have died MID-WRITE, leaving
367
+ // partial bytes on disk. Left alone, those bytes would ride the
368
+ // path's release (or the next save) as if they were real content.
369
+ // Restore the pre-image we read under this very lock: on success
370
+ // the path is byte-identical to before the batch and releases as
371
+ // merely-locked; only when even the restore fails does the path
372
+ // join the discard set (reset to HEAD on release — fail-closed,
373
+ // partial bytes must never land). Residual risk, documented: in
374
+ // that double-failure case a prior save's still-queued dirty bytes
375
+ // on this path are lost to the discard — accepted, because the
376
+ // alternative is committing known-corrupt bytes under their name.
289
377
  console.warn(
290
378
  `[locking-fs] creator access.md seed failed for "${p}":`,
291
379
  err instanceof Error ? err.message : err,
292
380
  );
381
+ let restored = false;
382
+ try {
383
+ if (existedBefore) {
384
+ await super.writeFile(p, current);
385
+ restored = true;
386
+ } else if (absolute) {
387
+ // Didn't exist before — remove whatever the failed write left.
388
+ await fs.rm(absolute, { force: true });
389
+ restored = true;
390
+ }
391
+ } catch {
392
+ // Fall through to the discard set.
393
+ }
394
+ if (!restored) {
395
+ failedSeedDiscards.add(p);
396
+ console.warn(
397
+ `[locking-fs] could not restore pre-seed bytes for "${p}" — releasing with discard`,
398
+ );
399
+ }
293
400
  }
294
401
  }
295
- change = await workflow.commitChanges(workspaceId, user, summary);
402
+ // Scope the commit to this batch's own paths (caller paths + landed
403
+ // seeds): on the shared per-branch workspace another save's bytes may be
404
+ // dirty with their commit still queued, and an unscoped commit would
405
+ // sweep them in under this batch's author/summary.
406
+ change = await workflow.commitChanges(workspaceId, user, summary, touched);
296
407
  if (seeds.size > 0) this.lockContext.creatorAccess?.noteAccessFileWritten(workspaceId);
297
408
  } catch (err) {
298
- // A write or the commit threw — nothing should land. releaseLockNoCommit
299
- // discards the uncommitted bytes for each acquired path.
300
- await releaseAll();
409
+ if (err instanceof PushNeedsAgentResolutionError) {
410
+ // POST-commit failure: the commit LANDED and only the push needs help
411
+ // (thrown "with the commit intact"). Routing this through the discard
412
+ // release would strand the landed commit with nothing to retry the
413
+ // push — the next identical write no-ops against the committed bytes
414
+ // and the change stays unpublished forever. Release the batch's OWN
415
+ // committed paths WITH commit-on-release instead (the same posture
416
+ // AdminLockedCommits takes): each enqueued release commit no-ops on
417
+ // the clean tree, and the pending-commits worker then notices the
418
+ // unpushed commits and re-runs the cooperative push ladder. Merely-
419
+ // locked paths still release untouched — arming the retry never
420
+ // justifies re-attributing someone else's queued row (see
421
+ // releaseAll), and `touched` is never empty here (the commit that
422
+ // just landed had to have paths in scope).
423
+ const committed = new Set(touched);
424
+ await releaseAll((p) =>
425
+ committed.has(p) ? 'enqueue' : failedSeedDiscards.has(p) ? 'discard' : 'untouched',
426
+ );
427
+ throw err;
428
+ }
429
+ // A write or the commit threw — nothing of THIS batch should land.
430
+ // Landed seed bytes are uncommitted now too: roll each back to the
431
+ // pre-image read under its lock (which may be a prior save's queued
432
+ // bytes — a discard to HEAD would destroy those), and only a seed
433
+ // whose rollback ALSO fails joins the discard set, same fail-closed
434
+ // posture as a failed seed write.
435
+ for (const [p, pre] of landedSeeds) {
436
+ try {
437
+ if (pre.existedBefore) {
438
+ await super.writeFile(p, pre.current);
439
+ } else {
440
+ const absolute = this.resolveAbsolutePath(p);
441
+ if (absolute) await fs.rm(absolute, { force: true });
442
+ }
443
+ } catch {
444
+ failedSeedDiscards.add(p);
445
+ console.warn(
446
+ `[locking-fs] could not roll back seed bytes for "${p}" after a failed batch — releasing with discard`,
447
+ );
448
+ }
449
+ }
450
+ // The batch's own dirtied paths (and any failed-restore seed) release
451
+ // no-commit (the discard reverts them). Merely-locked paths — rolled-
452
+ // back seeds included — release untouched (see releaseAll).
453
+ await releaseAll((p) =>
454
+ dirtied.has(p) || failedSeedDiscards.has(p) ? 'discard' : 'untouched',
455
+ );
301
456
  throw err;
302
457
  }
303
- // Commit already landed synchronously — release WITHOUT a per-file commit,
304
- // else `releaseLock` would enqueue a second (duplicate) commit per path.
305
- await releaseAll();
458
+ // Commit already landed synchronously — the `touched` paths are clean
459
+ // (nothing to discard, nothing to enqueue), so they release no-commit
460
+ // like the batch's other own-bytes paths; the discard no-ops on the
461
+ // clean tree, and a failed-restore seed's discard reverts its partial
462
+ // bytes. A seed lock whose write no-op'd (or fully restored itself)
463
+ // releases untouched: it was never this batch's to discard OR enqueue.
464
+ const committed = new Set(touched);
465
+ await releaseAll((p) =>
466
+ committed.has(p) || failedSeedDiscards.has(p) ? 'discard' : 'untouched',
467
+ );
306
468
  // This batch path commits via `commitChanges`, NOT the per-file queue, so it
307
469
  // skips `runPendingCommit`'s emit — fire the post-commit hook here with the
308
470
  // WHOLE batch as one event, so an expensive subscriber (id-repair rebuilds the
309
471
  // id index) reacts once per batch, not once per file. A no-op commit (clean
310
472
  // tree) changed nothing, so it emits nothing.
311
473
  if (change) {
312
- // `acquired` = caller paths + any creator-grant seeds that landed in the
474
+ // `touched` = caller paths + any creator-grant seeds that landed in the
313
475
  // same commit — the exact set this batch may have touched.
314
- this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: acquired, byUser: user });
476
+ this.lockContext.fileChanges?.emit({ workspaceId, branch, paths: touched, byUser: user });
315
477
  }
316
478
  return change;
317
479
  }
@@ -34,7 +34,7 @@
34
34
  * (status='needs_attention')
35
35
  */
36
36
 
37
- import { and, eq, isNull, or, sql } from 'drizzle-orm';
37
+ import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
38
38
  import type { Database } from '../database/connection.js';
39
39
  import { pendingCommits } from '../database/schema.js';
40
40
 
@@ -411,6 +411,32 @@ export class PendingCommitsService {
411
411
  return rows[0]?.count ?? 0;
412
412
  }
413
413
 
414
+ /**
415
+ * Is a LIVE row — `pending` or `running`, the statuses the worker will
416
+ * still drive — queued for `(workspaceId, branch, path)`? A
417
+ * `needs_attention` row deliberately does NOT count: its ladder has
418
+ * escalated to a human and nothing will retry it, so a caller asking
419
+ * "is my landed-but-unpushed commit going to be published?" must hear no.
420
+ * Backs `IWorkflowService.hasQueuedCommit` (the synced-groups committer's
421
+ * armed-retry proof) and the coordination-release discard guard.
422
+ */
423
+ async hasLiveRowFor(rawWorkspaceId: string, branch: string, path: string): Promise<boolean> {
424
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
425
+ const rows = await this.db
426
+ .select({ id: pendingCommits.id })
427
+ .from(pendingCommits)
428
+ .where(
429
+ and(
430
+ eq(pendingCommits.workspaceId, workspaceId),
431
+ eq(pendingCommits.branch, branch),
432
+ eq(pendingCommits.path, path),
433
+ inArray(pendingCommits.status, ['pending', 'running']),
434
+ ),
435
+ )
436
+ .limit(1);
437
+ return rows.length > 0;
438
+ }
439
+
414
440
  /**
415
441
  * Is there ANY row (regardless of status) for this workspace? Used by the
416
442
  * git status path to tell an EXPECTED dirty working tree (files saved to
@@ -166,7 +166,6 @@ function makeAccessControl(byPath: EligibilityByPath): IAccessControl {
166
166
  findEmailByHash: async () => null,
167
167
  kbPrincipals: async () => ({ plugins: [], people: [] }),
168
168
  validateRolesYaml: () => ({ ok: true }),
169
- referencesToRole: async () => [],
170
169
  };
171
170
  }
172
171
 
@@ -55,7 +55,6 @@ function makeAccessControl(adminEmails: string[] = []): IAccessControl {
55
55
  findEmailByHash: async () => null,
56
56
  kbPrincipals: async () => ({ plugins: [], people: [] }),
57
57
  validateRolesYaml: () => ({ ok: true }),
58
- referencesToRole: async () => [],
59
58
  };
60
59
  }
61
60
 
@@ -463,8 +463,9 @@ export class WorkflowService implements IWorkflowService {
463
463
  workspaceId: string,
464
464
  user: AuthUser,
465
465
  summary: string,
466
+ onlyPaths?: string[],
466
467
  ): Promise<Change | null> {
467
- const change = await this.git.commitChanges(workspaceId, user, summary);
468
+ const change = await this.git.commitChanges(workspaceId, user, summary, onlyPaths);
468
469
  // Same recovery as the per-file queue: a bare push here could strand the
469
470
  // just-made local commit (committed but never shared — breaking save=share)
470
471
  // on any non-fast-forward race. Pull-rebase + retry recovers the common
@@ -563,6 +564,7 @@ export class WorkflowService implements IWorkflowService {
563
564
  branch: string,
564
565
  targetPath: string,
565
566
  user: AuthUser,
567
+ opts?: { coordination?: boolean },
566
568
  ): Promise<AcquireLockResult> {
567
569
  // **Permission check at lock acquisition, not at commit time.** Under the
568
570
  // "disk is the source of truth" rule, once a write has landed on disk we
@@ -579,12 +581,22 @@ export class WorkflowService implements IWorkflowService {
579
581
  // boundary lives. Checking at HEAD (not at the working tree) so a user
580
582
  // can't grant themselves access by editing `roles.yaml` in the same
581
583
  // session.
582
- if (isProtectedBranch(branch)) {
584
+ //
585
+ // **Coordination acquires skip the gate** (see the interface doc): the
586
+ // caller wants only mutual exclusion with the path's writer — e.g. the
587
+ // roles admin holding machine-owned `synced-groups.yaml` steady across
588
+ // its IdP-mode recheck — and will never write the path. Gating those on
589
+ // write permission would make a pure serialization hold impossible for
590
+ // exactly the paths (machine-owned ones) that need it most. No write
591
+ // authority flows from the hold: the mode is persisted on the lock row
592
+ // and `commitFileWhileLocked` / `releaseLock` refuse to treat a
593
+ // coordination hold as write possession (see those methods).
594
+ if (isProtectedBranch(branch) && !opts?.coordination) {
583
595
  // `assertCanWriteAtPath` throws AccessDeniedError on denial, with the
584
596
  // eligible-writers payload so the frontend can render a useful refusal.
585
597
  await this.assertCanWriteAtPath(workspaceId, branch, user.email, targetPath);
586
598
  }
587
- const result = await this.fileLocks.acquire(workspaceId, branch, targetPath, user);
599
+ const result = await this.fileLocks.acquire(workspaceId, branch, targetPath, user, opts);
588
600
  if (result.acquired) {
589
601
  console.log(
590
602
  `[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → acquired`,
@@ -650,6 +662,17 @@ export class WorkflowService implements IWorkflowService {
650
662
  { kind: 'lock-not-held', branch, path: targetPath },
651
663
  );
652
664
  }
665
+ // A coordination hold is NOT write possession (see `acquireLock`): it was
666
+ // acquired past the write-authorization gate on the promise that nothing
667
+ // gets written under it. Treating it like an edit lock here would let the
668
+ // holder checkpoint bytes onto a path — machine-owned files included —
669
+ // whose write rule they never passed.
670
+ if (lock.mode === 'coordination') {
671
+ throw new WorkflowValidationError(
672
+ `Cannot commit "${targetPath}": the lock is a coordination hold, which grants no write authority.`,
673
+ { kind: 'coordination-hold', branch, path: targetPath },
674
+ );
675
+ }
653
676
  const change = await this.git.commitFile(workspaceId, user, targetPath, summary);
654
677
  // Push the autosave commit so other users on the branch see it
655
678
  // without waiting for the eventual lock release. Best-effort, same
@@ -758,6 +781,24 @@ export class WorkflowService implements IWorkflowService {
758
781
  { kind: 'lock-not-held', branch, path: targetPath },
759
782
  );
760
783
  }
784
+ // Releasing a coordination hold must NEVER enqueue a commit: the hold was
785
+ // granted past the write-authorization gate (see `acquireLock`) on the
786
+ // promise that nothing gets written under it, so a release commit would
787
+ // publish whatever bytes are on disk — an unauthorized route write, a
788
+ // prior save's still-queued work — under this caller's name. Refusing
789
+ // (rather than silently degrading) surfaces the caller bug loudly; the
790
+ // sanctioned releases for a coordination hold are `releaseLockNoCommit`
791
+ // and `releaseLockUntouched`. Worst case a refused caller strands the
792
+ // row until its TTL.
793
+ if (lock.mode === 'coordination') {
794
+ console.warn(
795
+ `[lock] RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → coordination hold (no commit may be enqueued)`,
796
+ );
797
+ throw new WorkflowValidationError(
798
+ `Cannot release lock on "${targetPath}" with a commit: it is a coordination hold. Use releaseLockNoCommit.`,
799
+ { kind: 'coordination-hold', branch, path: targetPath },
800
+ );
801
+ }
761
802
  // Order matters here:
762
803
  // 1. Drop the lock row. Other sessions can re-acquire immediately.
763
804
  // 2. Enqueue the commit. Worker will pick it up on next sweep.
@@ -940,20 +981,46 @@ export class WorkflowService implements IWorkflowService {
940
981
  // acquirer under the wrong author. The system's invariant is now:
941
982
  // every release leaves the working tree clean. If we're not
942
983
  // committing the bytes, we're throwing them away.
943
- try {
944
- await this.git.discardPath(workspaceId, targetPath);
945
- } catch (err) {
946
- // Best-effort: a discard failure is logged but doesn't block the
947
- // lock release. Worst case the working tree stays dirty for one
948
- // path until the next save on it cleans up.
949
- console.warn(
950
- `[workflow] discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`,
951
- err instanceof Error ? err.message : err,
952
- );
984
+ //
985
+ // One refinement for COORDINATION holds: the holder never legitimately
986
+ // wrote the path (the write paths refuse coordination possession), so
987
+ // anything dirty there is either bytes smuggled in around the workflow —
988
+ // which the discard rightly wipes — or a PRIOR save's work whose commit
989
+ // is still queued. Only the queue can tell those apart: when a live
990
+ // pending-commit row exists for the path, the dirty bytes are that
991
+ // queued save's and the discard would silently destroy a landed edit,
992
+ // so we skip it and let the worker publish them.
993
+ let skipDiscard = false;
994
+ if (lock.mode === 'coordination') {
995
+ try {
996
+ skipDiscard = await this.pendingCommits.hasLiveRowFor(workspaceId, branch, targetPath);
997
+ } catch (err) {
998
+ // Queue unreadable — fall back to the discard (the strict default:
999
+ // never let a coordination hold end with publishable stray bytes).
1000
+ console.warn(
1001
+ `[workflow] pending-commit lookup failed for workspace=${workspaceId} path=${targetPath}; discarding:`,
1002
+ err instanceof Error ? err.message : err,
1003
+ );
1004
+ }
1005
+ }
1006
+ let discarded = false;
1007
+ if (!skipDiscard) {
1008
+ try {
1009
+ await this.git.discardPath(workspaceId, targetPath);
1010
+ discarded = true;
1011
+ } catch (err) {
1012
+ // Best-effort: a discard failure is logged but doesn't block the
1013
+ // lock release. Worst case the working tree stays dirty for one
1014
+ // path until the next save on it cleans up.
1015
+ console.warn(
1016
+ `[workflow] discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`,
1017
+ err instanceof Error ? err.message : err,
1018
+ );
1019
+ }
953
1020
  }
954
1021
  await this.fileLocks.release(workspaceId, branch, targetPath, user);
955
1022
  console.log(
956
- `[lock] RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → discarded + released`,
1023
+ `[lock] RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${discarded ? 'discarded + released' : 'released (working tree untouched)'}`,
957
1024
  );
958
1025
  this.events?.emit({
959
1026
  kind: 'lock-released',
@@ -962,16 +1029,69 @@ export class WorkflowService implements IWorkflowService {
962
1029
  path: targetPath,
963
1030
  });
964
1031
  // The disk just changed (revert / removal). Tell anyone watching so
965
- // their open tabs refresh to the post-discard state.
1032
+ // their open tabs refresh to the post-discard state — but only when the
1033
+ // discard actually RAN: a skipped one (coordination hold shielding a
1034
+ // queued save's bytes) or a failed one left the disk exactly as it was,
1035
+ // and announcing a change would trigger refresh/invalidation for nothing.
1036
+ if (discarded) {
1037
+ this.events?.emit({
1038
+ kind: 'file-changed',
1039
+ workspaceId,
1040
+ branch,
1041
+ path: targetPath,
1042
+ newSha: null,
1043
+ byUserId: user.id,
1044
+ byUserName: user.name,
1045
+ });
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Third release shape (see the interface doc): the caller held the lock
1051
+ * but never touched the path, so the release must leave BOTH the disk and
1052
+ * the commit queue exactly as they are. `releaseLock` would enqueue a
1053
+ * commit — re-attributing a prior save's still-queued dirty bytes to this
1054
+ * caller and resetting that row's retry ladder; `releaseLockNoCommit`
1055
+ * would discard to HEAD — destroying those same bytes. This drops the
1056
+ * lock row, nothing else. Same idempotent non-holder no-op (and emit
1057
+ * discipline) as `releaseLockNoCommit`.
1058
+ */
1059
+ async releaseLockUntouched(
1060
+ workspaceId: string,
1061
+ branch: string,
1062
+ targetPath: string,
1063
+ user: AuthUser,
1064
+ ): Promise<void> {
1065
+ console.log(
1066
+ `[lock] RELEASE-UNTOUCHED start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
1067
+ );
1068
+ const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
1069
+ if (!lock || lock.holderUserId !== user.id) {
1070
+ console.log(
1071
+ `[lock] RELEASE-UNTOUCHED no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
1072
+ );
1073
+ return;
1074
+ }
1075
+ await this.fileLocks.release(workspaceId, branch, targetPath, user);
1076
+ console.log(
1077
+ `[lock] RELEASE-UNTOUCHED done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → released (disk + queue untouched)`,
1078
+ );
966
1079
  this.events?.emit({
967
- kind: 'file-changed',
1080
+ kind: 'lock-released',
968
1081
  workspaceId,
969
1082
  branch,
970
1083
  path: targetPath,
971
- newSha: null,
972
- byUserId: user.id,
973
- byUserName: user.name,
974
1084
  });
1085
+ // No `file-changed` here on purpose — unlike the no-commit release,
1086
+ // nothing on disk moved.
1087
+ }
1088
+
1089
+ hasQueuedCommit(workspaceId: string, branch: string, targetPath: string): Promise<boolean> {
1090
+ return this.pendingCommits.hasLiveRowFor(workspaceId, branch, targetPath);
1091
+ }
1092
+
1093
+ hasUnpushedCommits(workspaceId: string): Promise<boolean> {
1094
+ return this.git.hasUnpushedCommits(workspaceId);
975
1095
  }
976
1096
 
977
1097
  getLock(
@@ -1958,7 +2078,7 @@ export class WorkflowService implements IWorkflowService {
1958
2078
  }
1959
2079
 
1960
2080
  // Commit ONLY roles.yaml via commitFile (`git add -- roles.yaml`), never
1961
- // `commitChanges` (`git add -A`): on this shared per-branch workspace other
2081
+ // an unscoped `commitChanges` (`git add -A`): on this shared per-branch workspace other
1962
2082
  // files may be dirty from a concurrent same-branch save, and `add -A` would
1963
2083
  // sweep those unrelated edits into our "preserve roles.yaml" commit under the
1964
2084
  // wrong author/message. Feature branch → the protected-branch gate doesn't