@bevel-software/platform-core-backend 0.28.0 → 0.29.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 (143) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +5 -2
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +36 -14
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +208 -85
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/auth/index.d.ts +15 -0
  31. package/dist/modules/auth/index.d.ts.map +1 -0
  32. package/dist/modules/auth/index.js +20 -0
  33. package/dist/modules/auth/index.js.map +1 -0
  34. package/dist/modules/embed/embed.interface.d.ts +22 -0
  35. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.d.ts +28 -14
  37. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  38. package/dist/modules/embed/embed.service.js +38 -17
  39. package/dist/modules/embed/embed.service.js.map +1 -1
  40. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  41. package/dist/modules/embed/embed.tools.js +10 -2
  42. package/dist/modules/embed/embed.tools.js.map +1 -1
  43. package/dist/modules/kb-fs/index.d.ts +7 -0
  44. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  45. package/dist/modules/kb-fs/index.js +2 -0
  46. package/dist/modules/kb-fs/index.js.map +1 -0
  47. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  48. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  49. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  50. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.d.ts +8 -1
  52. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  53. package/dist/modules/skills/skills.tools.js +115 -11
  54. package/dist/modules/skills/skills.tools.js.map +1 -1
  55. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  56. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  57. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  58. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  59. package/dist/modules/tool-helpers/index.d.ts +1 -0
  60. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  61. package/dist/modules/tool-helpers/index.js +1 -0
  62. package/dist/modules/tool-helpers/index.js.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  64. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  65. package/dist/modules/workflow/file-lock.service.js +70 -18
  66. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  67. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  68. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  69. package/dist/modules/workflow/git/git.service.js +24 -0
  70. package/dist/modules/workflow/git/git.service.js.map +1 -1
  71. package/dist/modules/workflow/index.d.ts +8 -0
  72. package/dist/modules/workflow/index.d.ts.map +1 -0
  73. package/dist/modules/workflow/index.js +8 -0
  74. package/dist/modules/workflow/index.js.map +1 -0
  75. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  76. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  77. package/dist/modules/workflow/pending-commits.service.js +4 -24
  78. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  79. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  80. package/dist/modules/workflow/workflow.routes.js +20 -1
  81. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  82. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  83. package/dist/modules/workflow/workflow.service.js +5 -3
  84. package/dist/modules/workflow/workflow.service.js.map +1 -1
  85. package/dist/modules/workspace/index.d.ts +10 -0
  86. package/dist/modules/workspace/index.d.ts.map +1 -0
  87. package/dist/modules/workspace/index.js +10 -0
  88. package/dist/modules/workspace/index.js.map +1 -0
  89. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  90. package/dist/modules/workspace/workspace.tools.js +106 -20
  91. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  92. package/dist/shared/workspace-id.d.ts +17 -0
  93. package/dist/shared/workspace-id.d.ts.map +1 -1
  94. package/dist/shared/workspace-id.js +24 -0
  95. package/dist/shared/workspace-id.js.map +1 -1
  96. package/kb-template/access.md +2 -1
  97. package/mcp-app/page.html +142 -8
  98. package/package.json +3 -3
  99. package/src/core/__tests__/compose-embed.test.ts +65 -0
  100. package/src/core/__tests__/lifecycle.test.ts +29 -26
  101. package/src/core/catalog-revision.ts +4 -2
  102. package/src/core/compose-embed.ts +50 -0
  103. package/src/core/core-ports.ts +10 -0
  104. package/src/core/create-core-server.ts +7 -1
  105. package/src/core/create-core-services.ts +27 -21
  106. package/src/core/lifecycle.ts +24 -12
  107. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
  108. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  109. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  110. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  111. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  112. package/src/modules/access/access-control.interface.ts +5 -2
  113. package/src/modules/access/access-control.service.ts +236 -95
  114. package/src/modules/auth/index.ts +29 -0
  115. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  116. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  117. package/src/modules/embed/embed.interface.ts +24 -0
  118. package/src/modules/embed/embed.service.ts +44 -19
  119. package/src/modules/embed/embed.tools.ts +10 -2
  120. package/src/modules/kb-fs/index.ts +6 -0
  121. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  122. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  123. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  124. package/src/modules/skills/skills.contract.ts +4 -0
  125. package/src/modules/skills/skills.tools.ts +120 -10
  126. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  127. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  128. package/src/modules/tool-helpers/index.ts +5 -0
  129. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  130. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  131. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  132. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  133. package/src/modules/workflow/file-lock.service.ts +79 -18
  134. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  135. package/src/modules/workflow/git/git.service.ts +23 -0
  136. package/src/modules/workflow/index.ts +7 -0
  137. package/src/modules/workflow/pending-commits.service.ts +4 -23
  138. package/src/modules/workflow/workflow.routes.ts +24 -1
  139. package/src/modules/workflow/workflow.service.ts +3 -2
  140. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  141. package/src/modules/workspace/index.ts +14 -0
  142. package/src/modules/workspace/workspace.tools.ts +120 -20
  143. package/src/shared/workspace-id.ts +24 -0
@@ -23,6 +23,28 @@
23
23
  * never be keyed on a path no file verb would accept. See
24
24
  * `canonicalFileIdentity`.
25
25
  *
26
+ * Workspace identity works the same way. The lock routes pass the `:id`
27
+ * path param, which Express URL-decodes, so a slashed branch's workspace
28
+ * arrives as `alice/feature`; the deletion gate and the git status probe ask
29
+ * with the encoded `alice%2Ffeature`. Every method keys rows on
30
+ * `canonicalWorkspaceId`, so both spellings are one workspace and a held file
31
+ * is seen whichever spelling asks.
32
+ *
33
+ * Unlike raw path spellings (below), rows written under the decoded workspace id before this
34
+ * landed are still honoured until they go: every read, heartbeat and release
35
+ * also matches that spelling, and an acquire is refused while a live row
36
+ * under it holds the same file. Otherwise a lock held across the upgrade
37
+ * would be invisible for up to its TTL — to the deletion gate and to a second
38
+ * editor alike. Nothing new is ever written under it, so once the last such
39
+ * row has expired or been released the extra spelling matches nothing.
40
+ *
41
+ * This covers rows LEFT by the previous version, not rows it is still
42
+ * writing: a previous-version process never looks at a canonical row, so no
43
+ * check here could make the two contend while both run. They never do — one
44
+ * server process owns the workspaces directory (see `branchLifecycle` in the
45
+ * workflow service; this service's deletion gate is in-process for the same
46
+ * reason), and an upgrade stops the old process before the new one starts.
47
+ *
26
48
  * There is no transition handling for rows written under a raw spelling
27
49
  * before this landed: such a row is now unreachable by name and expires on
28
50
  * its own TTL. For one deploy an in-flight edit's lock can linger up to the
@@ -35,12 +57,13 @@
35
57
  * bounded by concurrent editors, not history, so lazy GC is fine.
36
58
  */
37
59
 
38
- import { and, eq, gt, lte } from 'drizzle-orm';
60
+ import { and, eq, gt, inArray, lte } from 'drizzle-orm';
39
61
  import type { Database } from '../database/connection.js';
40
62
  import { fileLocks } from '../database/schema.js';
41
63
  import type { AcquireLockResult, AuthUser, FileLock } from '@bevel-software/platform-shared';
42
64
  import { WorkflowValidationError } from '../../shared/domain-errors.js';
43
65
  import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
66
+ import { branchForWorkspaceId, canonicalWorkspaceId } from '../../shared/workspace-id.js';
44
67
 
45
68
  /**
46
69
  * Lock lifetime without a heartbeat. The client is expected to heartbeat
@@ -51,6 +74,17 @@ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
51
74
  */
52
75
  const LOCK_TTL_MS = 60_000;
53
76
 
77
+ /**
78
+ * The workspace ids a lock row of canonical workspace `workspaceId` can sit
79
+ * under: the canonical one every write uses, then — for a slashed branch —
80
+ * the decoded one rows were written under before workspace ids were
81
+ * canonicalised. See the module comment.
82
+ */
83
+ function workspaceSpellings(workspaceId: string): string[] {
84
+ const legacy = branchForWorkspaceId(workspaceId);
85
+ return legacy === workspaceId ? [workspaceId] : [workspaceId, legacy];
86
+ }
87
+
54
88
  function rowToFileLock(row: typeof fileLocks.$inferSelect): FileLock {
55
89
  return {
56
90
  branch: row.branch,
@@ -125,13 +159,14 @@ export class FileLockService {
125
159
  * (or vice versa).
126
160
  */
127
161
  async acquire(
128
- workspaceId: string,
162
+ rawWorkspaceId: string,
129
163
  branch: string,
130
164
  rawPath: string,
131
165
  user: AuthUser,
132
166
  opts?: { coordination?: boolean },
133
167
  ): Promise<AcquireLockResult> {
134
168
  const targetPath = canonicalFileIdentity(rawPath);
169
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
135
170
  if (this.closing.has(branch)) {
136
171
  throw new WorkflowValidationError(`"${branch}" is being deleted, so "${targetPath}" cannot be held for editing.`, {
137
172
  kind: 'branch-being-deleted',
@@ -190,6 +225,25 @@ export class FileLockService {
190
225
  // `heartbeat()` (which has its own same-user UPDATE), and the
191
226
  // editor's save flow detects "I already hold it" via `getLock` in
192
227
  // `workspace.routes.withLock` rather than re-acquiring.
228
+ // A live row under the pre-canonical spelling holds this file as surely
229
+ // as one under the canonical id; the upsert below cannot see it.
230
+ const legacy = workspaceSpellings(workspaceId).slice(1);
231
+ if (legacy.length > 0) {
232
+ const [held] = await this.db
233
+ .select()
234
+ .from(fileLocks)
235
+ .where(
236
+ and(
237
+ inArray(fileLocks.workspaceId, legacy),
238
+ eq(fileLocks.branch, branch),
239
+ eq(fileLocks.path, targetPath),
240
+ gt(fileLocks.expiresAt, now),
241
+ ),
242
+ )
243
+ .limit(1);
244
+ if (held) return { acquired: false, lock: rowToFileLock(held) };
245
+ }
246
+
193
247
  const mode = opts?.coordination ? 'coordination' : 'edit';
194
248
  const upsertResult = await this.db
195
249
  .insert(fileLocks)
@@ -255,12 +309,13 @@ export class FileLockService {
255
309
  * current holder.
256
310
  */
257
311
  async heartbeat(
258
- workspaceId: string,
312
+ rawWorkspaceId: string,
259
313
  branch: string,
260
314
  rawPath: string,
261
315
  user: AuthUser,
262
316
  ): Promise<FileLock> {
263
317
  const targetPath = canonicalFileIdentity(rawPath);
318
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
264
319
  const now = new Date();
265
320
  const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);
266
321
  // The expiry guard (`expiresAt > now`) matters because an expired
@@ -279,7 +334,7 @@ export class FileLockService {
279
334
  .set({ lastHeartbeatAt: now, expiresAt })
280
335
  .where(
281
336
  and(
282
- eq(fileLocks.workspaceId, workspaceId),
337
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
283
338
  eq(fileLocks.branch, branch),
284
339
  eq(fileLocks.path, targetPath),
285
340
  eq(fileLocks.holderUserId, user.id),
@@ -302,17 +357,18 @@ export class FileLockService {
302
357
  * over by someone else — that's not an error from the caller's POV).
303
358
  */
304
359
  async release(
305
- workspaceId: string,
360
+ rawWorkspaceId: string,
306
361
  branch: string,
307
362
  rawPath: string,
308
363
  user: AuthUser,
309
364
  ): Promise<void> {
310
365
  const targetPath = canonicalFileIdentity(rawPath);
366
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
311
367
  await this.db
312
368
  .delete(fileLocks)
313
369
  .where(
314
370
  and(
315
- eq(fileLocks.workspaceId, workspaceId),
371
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
316
372
  eq(fileLocks.branch, branch),
317
373
  eq(fileLocks.path, targetPath),
318
374
  eq(fileLocks.holderUserId, user.id),
@@ -326,25 +382,30 @@ export class FileLockService {
326
382
  * are purged here lazily.
327
383
  */
328
384
  async get(
329
- workspaceId: string,
385
+ rawWorkspaceId: string,
330
386
  branch: string,
331
387
  rawPath: string,
332
388
  ): Promise<FileLock | null> {
333
389
  const targetPath = canonicalFileIdentity(rawPath);
390
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
334
391
  const rows = await this.db
335
392
  .select()
336
393
  .from(fileLocks)
337
394
  .where(
338
395
  and(
339
- eq(fileLocks.workspaceId, workspaceId),
396
+ inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
340
397
  eq(fileLocks.branch, branch),
341
398
  eq(fileLocks.path, targetPath),
342
399
  ),
343
- )
344
- .limit(1);
345
- if (rows.length === 0) return null;
346
- const row = rows[0];
347
- if (row.expiresAt.getTime() <= Date.now()) {
400
+ );
401
+ // At most one row per spelling; the canonical one first.
402
+ rows.sort((x, y) => Number(x.workspaceId !== workspaceId) - Number(y.workspaceId !== workspaceId));
403
+ let live: (typeof rows)[number] | null = null;
404
+ for (const row of rows) {
405
+ if (row.expiresAt.getTime() > Date.now()) {
406
+ live ??= row;
407
+ continue;
408
+ }
348
409
  // Lazy GC — drop the stale row so the next `acquire` doesn't have
349
410
  // to bypass it. Bind the delete to this exact row's `expiresAt` so
350
411
  // a concurrent acquire that wrote a fresh row in the gap between
@@ -356,15 +417,14 @@ export class FileLockService {
356
417
  .delete(fileLocks)
357
418
  .where(
358
419
  and(
359
- eq(fileLocks.workspaceId, workspaceId),
420
+ eq(fileLocks.workspaceId, row.workspaceId),
360
421
  eq(fileLocks.branch, branch),
361
422
  eq(fileLocks.path, targetPath),
362
423
  eq(fileLocks.expiresAt, row.expiresAt),
363
424
  ),
364
425
  );
365
- return null;
366
426
  }
367
- return rowToFileLock(row);
427
+ return live ? rowToFileLock(live) : null;
368
428
  }
369
429
 
370
430
  /**
@@ -406,11 +466,12 @@ export class FileLockService {
406
466
  * the "missed lock-release commit" the loud warning is for. Expired rows
407
467
  * don't count (their holder is gone; they explain nothing).
408
468
  */
409
- async hasAnyActive(workspaceId: string): Promise<boolean> {
469
+ async hasAnyActive(rawWorkspaceId: string): Promise<boolean> {
470
+ const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
410
471
  const rows = await this.db
411
472
  .select({ path: fileLocks.path })
412
473
  .from(fileLocks)
413
- .where(and(eq(fileLocks.workspaceId, workspaceId), gt(fileLocks.expiresAt, new Date())))
474
+ .where(and(inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)), gt(fileLocks.expiresAt, new Date())))
414
475
  .limit(1);
415
476
  return rows.length > 0;
416
477
  }
@@ -100,3 +100,45 @@ describe('GitService.readFileAtRef', () => {
100
100
  ).rejects.toThrow();
101
101
  });
102
102
  });
103
+
104
+ describe('GitService.commitAtRef', () => {
105
+ let root: string;
106
+ const workspaceId = 'ws-commit-at-ref-1';
107
+
108
+ beforeEach(async () => {
109
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-commit-at-ref-'));
110
+ });
111
+ afterEach(async () => {
112
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
113
+ });
114
+
115
+ function makeSvc(workspaceDir: string) {
116
+ return new GitService(
117
+ stubWorkspaceService(workspaceId, workspaceDir),
118
+ new WorkflowHooks(),
119
+ testKbContext({ kbDirName: PROCESS_MAP_DIR }),
120
+ );
121
+ }
122
+
123
+ it('answers the commit a ref names, and null for a ref that names none', async () => {
124
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
125
+ const svc = makeSvc(workspaceDir);
126
+ // Unborn HEAD: a repository with no commit yet names none.
127
+ expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBeNull();
128
+ await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles: {}\n');
129
+ await runGit(repo, ['add', 'roles.yaml']);
130
+ await runGit(repo, ['commit', '-m', 'add roles']);
131
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo });
132
+ expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBe(stdout.trim());
133
+ expect(await svc.commitAtRef(workspaceId, 'origin/does-not-exist')).toBeNull();
134
+ });
135
+
136
+ it('THROWS on a failure that is not "names no commit", such as no repository there', async () => {
137
+ // The workspace folder exists; the clone inside it does not. Git fails
138
+ // with "not a git repository", which must not read as an empty one.
139
+ const workspaceDir = path.join(root, workspaceId);
140
+ await fs.mkdir(path.join(workspaceDir, PROCESS_MAP_DIR), { recursive: true });
141
+ const svc = makeSvc(workspaceDir);
142
+ await expect(svc.commitAtRef(workspaceId, 'HEAD')).rejects.toThrow();
143
+ });
144
+ });
@@ -4017,6 +4017,29 @@ export class GitService implements IGitService {
4017
4017
  return stdout.split('\0').filter((line) => line.length > 0);
4018
4018
  }
4019
4019
 
4020
+ /**
4021
+ * The commit `ref` names right now, or null when it names none (an unborn
4022
+ * `HEAD`, a ref that does not exist). A caller that reads several things
4023
+ * "at HEAD" pins the commit once with this and asks each read at that
4024
+ * commit, so a commit landing between the reads cannot hand it one
4025
+ * snapshot's files with another's rules.
4026
+ *
4027
+ * Null is ONLY "names no commit": `rev-parse --verify --quiet` says that
4028
+ * with exit status 1 and nothing on stderr. Any other failure — no
4029
+ * repository there, git itself failing, a deadline — is thrown, so a
4030
+ * caller cannot read a broken clone as an empty one.
4031
+ */
4032
+ async commitAtRef(workspaceId: string, ref: string): Promise<string | null> {
4033
+ const cwd = await this.repoDir(workspaceId);
4034
+ try {
4035
+ const { stdout } = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
4036
+ return stdout.trim() || null;
4037
+ } catch (err) {
4038
+ if (err instanceof GitRunError && !err.timedOut && err.exitCode === 1 && !(err.stderr ?? '').trim()) return null;
4039
+ throw err;
4040
+ }
4041
+ }
4042
+
4020
4043
  /**
4021
4044
  * A clean `git status` for a path has two honest readings (already
4022
4045
  * committed; a queued re-apply of something that landed) and one dishonest
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What the workflow module offers the other modules: the addresses of its
3
+ * change requests, so a module that lands a proposal can hand back a link
4
+ * built by the one rule — rather than each spelling the route for itself.
5
+ * The services are wired by the composition root.
6
+ */
7
+ export { CHANGE_REQUEST_URL_NOTE, changeRequestLink, changeRequestLinkBase } from './git/change-request-link.js';
@@ -41,6 +41,7 @@ const log = logger('pending-commits');
41
41
  import type { Database } from '../database/connection.js';
42
42
  import { canonicalEmail } from '../../shared/email-identity.js';
43
43
  import { pendingCommits } from '../database/schema.js';
44
+ import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
44
45
 
45
46
  /**
46
47
  * Transient-failure budget per recovery cycle. After this many consecutive
@@ -80,29 +81,9 @@ export const N_RECOVERY = 3;
80
81
  */
81
82
  export const BACKOFF_MS = [1_000, 5_000, 30_000];
82
83
 
83
- /**
84
- * Canonicalize a workspace id so an enqueued row and the worker's claim always
85
- * key on the SAME string.
86
- *
87
- * The id reaches `enqueue` via a route `:id` path param, which Express
88
- * URL-decodes — so a slashed feature branch arrives as `alice/feature`. But the
89
- * worker claims per `WorkspaceService.knownWorkspaces()`, whose ids are
90
- * `encodeURIComponent(branch)` → `alice%2Ffeature`. Those strings differ, so
91
- * `WHERE workspace_id = …` never matched and the row was never drained —
92
- * silently stranding every human save on a feature branch (protected branches
93
- * have no `/`, so encoded == decoded and they were unaffected).
94
- *
95
- * `encodeURIComponent(decodeURIComponent(id))` is idempotent and collapses both
96
- * forms to the encoded id `knownWorkspaces()` uses. A malformed `%` sequence
97
- * (which `decodeURIComponent` would throw on) is left untouched.
98
- */
99
- export function canonicalWorkspaceId(id: string): string {
100
- try {
101
- return encodeURIComponent(decodeURIComponent(id));
102
- } catch {
103
- return id;
104
- }
105
- }
84
+ // Lives in `shared/workspace-id.ts`, beside the encoding it canonicalises to;
85
+ // re-exported for this module's existing importers.
86
+ export { canonicalWorkspaceId };
106
87
 
107
88
  export interface PendingCommit {
108
89
  id: string;
@@ -40,6 +40,7 @@ import type { WorkflowEventBus } from './event-bus.js';
40
40
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
41
41
  import { assertValidRelativePath } from '../kb-fs/branch-name.js';
42
42
  import { domainErrorBody } from '../../shared/http-errors.js';
43
+ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
43
44
  import '../auth/auth.middleware.js'; // Express Request augmentation
44
45
 
45
46
  function toHttpError(
@@ -501,6 +502,8 @@ export function createWorkflowRoutes(
501
502
  // answer for that same input, and `toHttpError` passes it through unchanged.
502
503
  // Nothing is canonicalised at this layer on purpose: a route-level fix would
503
504
  // leave any other caller of the service coordinating on its own identity.
505
+ // (The status read canonicalises once for its read gate only; the lookup
506
+ // still takes the caller's spelling.)
504
507
 
505
508
  router.post('/workspace/:id/workflow/locks', async (req, res) => {
506
509
  const user = await requireUser(req, res);
@@ -589,7 +592,8 @@ export function createWorkflowRoutes(
589
592
  });
590
593
 
591
594
  router.get('/workspace/:id/workflow/locks', async (req, res) => {
592
- if (!(await requireUser(req, res))) return;
595
+ const user = await requireUser(req, res);
596
+ if (!user) return;
593
597
  const branch = typeof req.query.branch === 'string' ? req.query.branch : '';
594
598
  const targetPath = typeof req.query.path === 'string' ? req.query.path : '';
595
599
  if (!branch || !targetPath) {
@@ -597,6 +601,25 @@ export function createWorkflowRoutes(
597
601
  return;
598
602
  }
599
603
  try {
604
+ // A lock names who is editing the file, so a caller who may not read
605
+ // the file is refused before the lookup: the answer is the same whether
606
+ // or not it is held. The gate judges the canonical spelling, which is
607
+ // the one the lock is found under (`./kb//x.md` would otherwise slip
608
+ // past the KB-prefix test), and a refused spelling keeps the file
609
+ // verbs' status. A path without the KB prefix is not exempted as
610
+ // non-KB: the git layer also takes the repo-relative form (`GTM/x.md`
611
+ // for `knowledge-base/GTM/x.md`, see `requireReadPermission`), so it is
612
+ // judged as the repository file it would name.
613
+ const canonical = canonicalFileIdentity(targetPath);
614
+ const readable = await accessControl.canRead(
615
+ req.params.id,
616
+ user.email,
617
+ toKbRelative(canonical, kbDirName) ?? canonical,
618
+ );
619
+ if (!readable) {
620
+ res.status(403).json({ error: `You don't have permission to read "${targetPath}".` });
621
+ return;
622
+ }
600
623
  res.json({ lock: await workflow.getLock(req.params.id, branch, targetPath) });
601
624
  } catch (err) {
602
625
  const { status, body } = toHttpError(err);
@@ -542,7 +542,7 @@ export class WorkflowService implements IWorkflowService {
542
542
  // One line per deletion, whoever asked: the commit is what a restore
543
543
  // starts from, and for a deletion made in the app or by the cleanup this
544
544
  // line is the only trace there is.
545
- // By id, never by address: log lines carry no email (the release's own promise).
545
+ // By id: log lines carry no email addresses.
546
546
  const actor = opts?.systemCleanup ? 'system' : user.id;
547
547
  log.info(`branch deleted by ${actor}: "${name}" at ${lastCommit ?? '(no commit)'}`);
548
548
  // Retire the deleted branch's own workspace clone, best-effort. A stale
@@ -629,6 +629,7 @@ export class WorkflowService implements IWorkflowService {
629
629
  */
630
630
  private async closeIfEmpty(
631
631
  number: number,
632
+ // `actor` is a user id or `system`, never an address: it goes into a log line.
632
633
  opts: { reason: string; actor: string; workspaceId?: string; fresh?: boolean },
633
634
  ): Promise<{ base: string } | null> {
634
635
  const summary = await this.prs.getPr(number);
@@ -3360,7 +3361,7 @@ export class WorkflowService implements IWorkflowService {
3360
3361
  * Returns true when THIS call closed it.
3361
3362
  */
3362
3363
  async closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean> {
3363
- const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.email });
3364
+ const closed = await this.closeIfEmpty(number, { reason: 'found empty', actor: user.id });
3364
3365
  if (!closed) return false;
3365
3366
  await this.retireMergedSourceBranch(number, closed.base, user);
3366
3367
  return true;
@@ -48,12 +48,18 @@ const allowAll = {
48
48
  canReadBatch: async (_w: string, _u: string, paths: string[]) =>
49
49
  new Map(paths.map((p) => [p, true])),
50
50
  // The verbs `file_stat` and the move/delete preflight ask about. Allowing
51
- // everything, and answering "no rules at HEAD" for the batch write check,
52
- // keeps these tests about what they test rather than about access.
51
+ // everything keeps these tests about what they test rather than about
52
+ // access.
53
53
  canWrite: async () => true,
54
+ canWriteBatch: async (_w: string, _u: string, paths: string[]) =>
55
+ new Map(paths.map((p) => [p, true])),
54
56
  canDownload: async () => true,
55
57
  canOwner: async () => true,
56
- canWriteBatchAtRef: async () => null,
58
+ // The `canWrite` the read tools report is judged at the default branch's
59
+ // HEAD, so a write-scope caller here gets a COMPUTED `true` — never the
60
+ // fail-closed `false` of a missing method or an unresolvable ref.
61
+ canWriteBatchAtRef: async (_w: string, _r: string, _u: string, paths: string[]) =>
62
+ new Map(paths.map((p) => [p, true])),
57
63
  // The `after` half of a move's or copy's preview. Allowing everything here
58
64
  // keeps it the same answer as the four gates above; what the relocation of
59
65
  // a folder's `access.md` files actually does to it is exercised against
@@ -372,7 +378,7 @@ async function isDeadOrZombie(pid: number): Promise<boolean> {
372
378
  describe('workspace file primitives', () => {
373
379
  it('read_file returns the content', async () => {
374
380
  const base = await start();
375
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
381
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
376
382
  });
377
383
 
378
384
  it('write_file then read_file round-trips', async () => {
@@ -505,7 +511,7 @@ describe('workspace file primitives', () => {
505
511
  // that same text into an agent: a leading slash names the same path.
506
512
  it('every path input accepts a leading slash as the same workspace path', async () => {
507
513
  const base = await start();
508
- expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n' });
514
+ expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `/${KB_DIR}/a.md` })).json()).toEqual({ path: `${KB_DIR}/a.md`, content: 'hello\nworld\n', canWrite: true });
509
515
  expect(await (await post(`${base}/api/agent/tools/file_stat`, { path: `/${KB_DIR}/a.md` })).json()).toMatchObject({ type: 'file' });
510
516
  await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/b.md`, content: 'fresh' });
511
517
  await post(`${base}/api/agent/tools/write_file`, { path: `/${KB_DIR}/c.md`, content: 'batch' });
@@ -1117,14 +1123,14 @@ describe('write modes and per-path outcomes', () => {
1117
1123
  describe("the agent guide at the guide's name", () => {
1118
1124
  const GUIDE = `${KB_DIR}/AGENTS.md`;
1119
1125
  const read = (base: string, p = GUIDE, extra: Record<string, unknown> = {}) =>
1120
- post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string }>);
1126
+ post(`${base}/api/agent/tools/read_file`, { path: p, ...extra }).then((r) => r.json() as Promise<{ path: string; content: string; canWrite?: boolean }>);
1121
1127
  const statOf = (base: string, p = GUIDE) =>
1122
1128
  post(`${base}/api/agent/tools/file_stat`, { path: p }).then((r) => r.json() as Promise<Record<string, unknown>>);
1123
1129
 
1124
1130
  it('answers with the guide when the knowledge base has no file of that name', async () => {
1125
1131
  guideText = 'THE PLATFORM GUIDE\n';
1126
1132
  const base = await start();
1127
- expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
1133
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1128
1134
  // By the root-anchored and the prefix-less spellings too, like any path.
1129
1135
  expect((await read(base, `/${GUIDE}`)).content).toBe('THE PLATFORM GUIDE\n');
1130
1136
  expect((await read(base, 'AGENTS.md')).content).toBe('THE PLATFORM GUIDE\n');
@@ -1145,7 +1151,9 @@ describe("the agent guide at the guide's name", () => {
1145
1151
  guideText = 'THE PLATFORM GUIDE\n';
1146
1152
  const base = await start();
1147
1153
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n\nWrite tickets in the present tense.\n');
1148
- const { content } = await read(base);
1154
+ const { content, canWrite } = await read(base);
1155
+ // Its own file is a file like any other: the write verdict is the rules' answer for it.
1156
+ expect(canWrite).toBe(true);
1149
1157
  expect(content.startsWith('# Acme\n\nWrite tickets in the present tense.\n\n---\n')).toBe(true);
1150
1158
  expect(content.endsWith('\n\nTHE PLATFORM GUIDE\n')).toBe(true);
1151
1159
  expect(content).toContain("The text above is this knowledge base's own conventions file.");
@@ -1155,7 +1163,7 @@ describe("the agent guide at the guide's name", () => {
1155
1163
  guideText = 'THE PLATFORM GUIDE\n';
1156
1164
  const base = await start();
1157
1165
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Knowledge base\n\n> **This file is managed by the platform.** Stale.\n');
1158
- expect((await read(base)).content).toBe('THE PLATFORM GUIDE\n');
1166
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1159
1167
  });
1160
1168
 
1161
1169
  it("never tells a caller who may not read the knowledge base's own file that it exists", async () => {
@@ -1199,7 +1207,7 @@ describe("the agent guide at the guide's name", () => {
1199
1207
  };
1200
1208
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1201
1209
  vanish = true;
1202
- expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n' });
1210
+ expect(await read(base)).toEqual({ path: GUIDE, content: 'THE PLATFORM GUIDE\n', canWrite: false });
1203
1211
  await fs.writeFile(`${KB_DIR}/AGENTS.md`, '# Acme\n');
1204
1212
  vanish = true;
1205
1213
  expect(await statOf(base)).toMatchObject({ platformGuide: true });
@@ -2211,7 +2219,7 @@ describe('images', () => {
2211
2219
  const body = (await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/photo.jpg` })).json()) as ImageSentinel;
2212
2220
  expect(body.mimeType).toBe('image/jpeg');
2213
2221
  expect(body.data).toBe(bytes.toString('base64'));
2214
- expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes]`);
2222
+ expect(body.note).toBe(`[image: ${KB_DIR}/photo.jpg — image/jpeg, ${bytes.length} bytes] (canWrite: true)`);
2215
2223
  });
2216
2224
 
2217
2225
  it('read_file refuses an image over 3.5 MiB raw with the downscale message, not a sentinel', async () => {
@@ -2237,6 +2245,7 @@ describe('images', () => {
2237
2245
  expect(await (await post(`${base}/api/agent/tools/read_file`, { path: `${KB_DIR}/icon.svg` })).json()).toEqual({
2238
2246
  path: `${KB_DIR}/icon.svg`,
2239
2247
  content: svg,
2248
+ canWrite: true,
2240
2249
  });
2241
2250
  });
2242
2251
 
@@ -2776,6 +2785,7 @@ describe('the tools place an unprefixed path inside the repository', () => {
2776
2785
  expect(await (await tool(base, 'read_file', { path: 'Notes.md' })).json()).toEqual({
2777
2786
  path: `${KB_DIR}/Notes.md`,
2778
2787
  content: 'hello\nworld\n',
2788
+ canWrite: true,
2779
2789
  });
2780
2790
  expect(await (await tool(base, 'file_stat', { path: 'Notes.md' })).json()).toMatchObject({ type: 'file' });
2781
2791
  const edited = await tool(base, 'edit_file', { path: 'Notes.md', old_string: 'world', new_string: 'earth' });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What the workspace module offers the other modules: how a file's bytes are
3
+ * read and told apart — the reader registry `read_file` dispatches on, and
4
+ * the raw content type a file is served with — so a module that serves a
5
+ * file answers exactly as a read of it would. The services are wired by the
6
+ * composition root.
7
+ */
8
+ export {
9
+ FileReaderRegistry,
10
+ type FileKind,
11
+ type FileReader,
12
+ type ReadResult,
13
+ } from './file-readers/file-reader.js';
14
+ export { OCTET_STREAM, rawExtensionOf, rawMimeFor } from './file-readers/raw-mime.js';