@bevel-software/platform-core-backend 0.10.0 → 0.11.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/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +165 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +174 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- 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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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)
|
|
272
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 —
|
|
304
|
-
//
|
|
305
|
-
|
|
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
|
-
// `
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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: '
|
|
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
|