@bevel-software/platform-core-backend 0.12.1 → 0.13.3

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 (159) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -3
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +123 -7
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  37. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  39. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  40. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  41. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  42. package/dist/modules/kb-fs/repo-path.js +54 -0
  43. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  44. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  45. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  46. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  47. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  48. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  50. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  52. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  53. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  55. package/dist/modules/tool-helpers/tool-context.js +1 -0
  56. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  57. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  58. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  59. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  60. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  62. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  63. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  64. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  66. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  68. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  70. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  71. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  72. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  73. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  74. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  75. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +70 -5
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  85. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  87. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  89. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  90. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  91. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  92. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  95. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.js +12 -0
  97. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  98. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  99. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.service.js +4 -0
  101. package/dist/modules/workflow/workflow.service.js.map +1 -1
  102. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  103. package/dist/modules/workspace/workspace.tools.js +31 -15
  104. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  105. package/kb-template/AGENTS.md +52 -8
  106. package/package.json +4 -3
  107. package/src/core/create-core-server.ts +13 -1
  108. package/src/core/create-core-services.ts +1 -0
  109. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  110. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  111. package/src/modules/access/__tests__/access-own-read-grant.test.ts +116 -0
  112. package/src/modules/access/access-control.service.ts +198 -37
  113. package/src/modules/access/admin-locked-commit.ts +1 -0
  114. package/src/modules/access/synced-groups-committer.ts +1 -1
  115. package/src/modules/access-model/__tests__/access-grammar.test.ts +199 -1
  116. package/src/modules/access-model/access-errors.ts +19 -0
  117. package/src/modules/access-model/access-grammar.ts +123 -6
  118. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  119. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  120. package/src/modules/declared-variables/index.ts +1 -0
  121. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  122. package/src/modules/diff/diff.routes.ts +3 -2
  123. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  124. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  126. package/src/modules/kb-fs/repo-path.ts +56 -0
  127. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  128. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  129. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  130. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  131. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  132. package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
  133. package/src/modules/tool-helpers/tool-context.ts +1 -0
  134. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  135. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  136. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  137. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  138. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  139. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  140. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  141. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  142. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  143. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  144. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  145. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  146. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  147. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  148. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  149. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  150. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  151. package/src/modules/workflow/git/git.service.ts +73 -6
  152. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  153. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  154. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  155. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  156. package/src/modules/workflow/workflow.routes.ts +12 -0
  157. package/src/modules/workflow/workflow.service.ts +5 -1
  158. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  159. package/src/modules/workspace/workspace.tools.ts +35 -15
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import type { AuthUser } from '@bevel-software/platform-shared';
8
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
9
+ import { WorkflowHooks } from '../../workflow-hooks.js';
10
+ import { GitService } from '../git.service.js';
11
+ import { WorkflowValidationError } from '../../../../shared/domain-errors.js';
12
+
13
+ /**
14
+ * `commitFile` on a path whose bytes sit BESIDE the repository.
15
+ *
16
+ * The workspace dir holds the clone as `knowledge-base/`. A caller that
17
+ * writes a workspace-relative path without that prefix puts the file next to
18
+ * the clone, where `git status` inside the clone reports it clean. Before,
19
+ * `commitFile` read that as "nothing to commit" and returned null, so the
20
+ * write was reported as landed while git never saw it. Now it throws, and the
21
+ * pending-commits ladder escalates the row instead of dropping it.
22
+ *
23
+ * The three honest null cases must survive unchanged: a clean committed path,
24
+ * a path that exists nowhere (a queued re-apply of a committed deletion), and
25
+ * a repo-relative path some internal callers pass directly.
26
+ */
27
+
28
+ const execFileAsync = promisify(execFile);
29
+ const KB = 'knowledge-base';
30
+
31
+ const USER: AuthUser = { id: 'u1', email: 'alice@example.com', name: 'Alice' };
32
+
33
+ async function mkTmpRoot(): Promise<string> {
34
+ return fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-stray-'));
35
+ }
36
+
37
+ const GIT_ENV = {
38
+ ...process.env,
39
+ GIT_AUTHOR_NAME: 'Test',
40
+ GIT_AUTHOR_EMAIL: 't@x.com',
41
+ GIT_COMMITTER_NAME: 'Test',
42
+ GIT_COMMITTER_EMAIL: 't@x.com',
43
+ };
44
+
45
+ async function runGit(cwd: string, args: string[]): Promise<string> {
46
+ const { stdout } = await execFileAsync('git', args, { cwd, env: GIT_ENV });
47
+ return stdout.toString().trim();
48
+ }
49
+
50
+ async function seedWorkspace(root: string, workspaceId: string): Promise<{ workspaceDir: string; repo: string }> {
51
+ const workspaceDir = path.join(root, workspaceId);
52
+ const repo = path.join(workspaceDir, KB);
53
+ await fs.mkdir(repo, { recursive: true });
54
+ await runGit(repo, ['init', '-b', 'main']);
55
+ await runGit(repo, ['config', 'user.email', 't@x.com']);
56
+ await runGit(repo, ['config', 'user.name', 'Test']);
57
+ await runGit(repo, ['config', 'gc.auto', '0']);
58
+ // One committed file so HEAD exists and the "clean path" cases have something to be clean about.
59
+ await fs.mkdir(path.join(repo, 'Knowledge'), { recursive: true });
60
+ await fs.writeFile(path.join(repo, 'Knowledge/A.md'), 'a\n');
61
+ await runGit(repo, ['add', 'Knowledge/A.md']);
62
+ await runGit(repo, ['commit', '-m', 'seed']);
63
+ return { workspaceDir, repo };
64
+ }
65
+
66
+ function stubWorkspaceService(workspaceId: string, workspaceDir: string): WorkspaceService {
67
+ return {
68
+ getWorkspacePath: async (id: string) => {
69
+ if (id !== workspaceId) throw new Error(`unexpected workspace ${id}`);
70
+ return workspaceDir;
71
+ },
72
+ } as unknown as WorkspaceService;
73
+ }
74
+
75
+ describe('GitService.commitFile with bytes beside the repository', () => {
76
+ let root: string;
77
+ const workspaceId = 'ws-stray';
78
+
79
+ beforeEach(async () => {
80
+ root = await mkTmpRoot();
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
85
+ });
86
+
87
+ it('throws instead of returning null when the file exists at the workspace root and not in the clone', async () => {
88
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
89
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
90
+ // The prefix-less write the agent made: beside the clone, not inside it.
91
+ await fs.mkdir(path.join(workspaceDir, 'KnowledgeBase/Reviews'), { recursive: true });
92
+ await fs.writeFile(path.join(workspaceDir, 'KnowledgeBase/Reviews/PR-12.html'), '<p>review</p>');
93
+ const headBefore = await runGit(repo, ['rev-parse', 'HEAD']);
94
+
95
+ const attempt = svc.commitFile(workspaceId, USER, 'KnowledgeBase/Reviews/PR-12.html');
96
+ await expect(attempt).rejects.toBeInstanceOf(WorkflowValidationError);
97
+ await expect(attempt).rejects.toThrow('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
98
+
99
+ // Nothing landed, and the stray is left where it is for someone to recover.
100
+ expect(await runGit(repo, ['rev-parse', 'HEAD'])).toBe(headBefore);
101
+ expect(await fs.readFile(path.join(workspaceDir, 'KnowledgeBase/Reviews/PR-12.html'), 'utf-8')).toBe('<p>review</p>');
102
+ });
103
+
104
+ it('still returns null for a clean, committed path (the honest no-op)', async () => {
105
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
106
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
107
+ expect(await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/A.md`)).toBeNull();
108
+ });
109
+
110
+ it('still returns null for a path that exists nowhere (a queued re-apply of a committed deletion)', async () => {
111
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
112
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
113
+ expect(await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/Gone.md`)).toBeNull();
114
+ });
115
+
116
+ it('still returns null for a repo-relative path that is clean inside the clone (the form internal callers pass)', async () => {
117
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
118
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
119
+ expect(await svc.commitFile(workspaceId, USER, 'Knowledge/A.md')).toBeNull();
120
+ });
121
+
122
+ it('still returns null for a repo-relative path when the clone has that file too, even with a same-named file at the workspace root', async () => {
123
+ // Not provably stray: the clone holds `Knowledge/A.md` and it is clean, so
124
+ // a repo-relative caller's no-op stays honest whatever else sits beside
125
+ // the clone under the same name.
126
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
127
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
128
+ await fs.mkdir(path.join(workspaceDir, 'Knowledge'), { recursive: true });
129
+ await fs.writeFile(path.join(workspaceDir, 'Knowledge/A.md'), 'a copy beside the clone\n');
130
+ expect(await svc.commitFile(workspaceId, USER, 'Knowledge/A.md')).toBeNull();
131
+ });
132
+
133
+ it('does not mistake an unreadable stray for an absent one', async () => {
134
+ // Only ENOENT means "nothing there". Any other stat failure must surface,
135
+ // or a stray the process cannot read is silently reported as committed.
136
+ // root ignores mode bits, and Windows has none to set (`chmod` there only
137
+ // toggles read-only, so the stat below still succeeds): nothing to prove.
138
+ if (process.getuid?.() === 0 || process.platform === 'win32') return;
139
+ const { workspaceDir } = await seedWorkspace(root, workspaceId);
140
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
141
+ const strayDir = path.join(workspaceDir, 'KnowledgeBase');
142
+ await fs.mkdir(strayDir, { recursive: true });
143
+ await fs.writeFile(path.join(strayDir, 'PR-12.html'), 'x');
144
+ await fs.chmod(strayDir, 0o000);
145
+ try {
146
+ const attempt = svc.commitFile(workspaceId, USER, 'KnowledgeBase/PR-12.html');
147
+ await expect(attempt).rejects.toMatchObject({ code: 'EACCES' });
148
+ } finally {
149
+ await fs.chmod(strayDir, 0o755);
150
+ }
151
+ });
152
+
153
+ it('still commits a prefixed path inside the clone', async () => {
154
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
155
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), KB);
156
+ await fs.writeFile(path.join(repo, 'Knowledge/B.md'), 'b\n');
157
+ const committed = await svc.commitFile(workspaceId, USER, `${KB}/Knowledge/B.md`);
158
+ expect(committed).not.toBeNull();
159
+ expect(committed!.authorEmail).toBe('alice@example.com');
160
+ expect(await runGit(repo, ['log', '--oneline'])).toMatch(/^\S+ .*\n\S+ seed$/);
161
+ });
162
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ import { PullRequestService } from '../pull-request.service.js';
4
+ import type { Database } from '../../../database/connection.js';
5
+ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
6
+ import type { GitService } from '../git.service.js';
7
+ import type { IAccessControl } from '../../../access/access-control.interface.js';
8
+
9
+ const ROW = {
10
+ id: 'cr-7',
11
+ number: 7,
12
+ sourceBranch: 'alice/feature',
13
+ targetBranch: 'current-company-state',
14
+ title: 'Feature',
15
+ body: '',
16
+ authorEmail: 'alice@bevel.software',
17
+ authorName: 'Alice',
18
+ state: 'open',
19
+ mergedSha: null,
20
+ createdAt: new Date('2026-08-01T00:00:00Z'),
21
+ updatedAt: null,
22
+ closedAt: null,
23
+ };
24
+
25
+ function makeDb(): Database {
26
+ return {
27
+ select: () => ({
28
+ from: () => ({
29
+ where: () => ({ limit: async () => [ROW] }),
30
+ }),
31
+ }),
32
+ } as unknown as Database;
33
+ }
34
+
35
+ const BASE = 'b'.repeat(40);
36
+ const HEAD = 'a'.repeat(40);
37
+
38
+ /**
39
+ * A detail read is the hot path behind every change-request poll, every
40
+ * approval click, and every merge. It resolves the SHAs first (that fetches
41
+ * both refs), so the file list must be pinned to those same commits rather
42
+ * than fetch and resolve again. The SHAs and the file list are read on every
43
+ * call (a new head must show); what the detail cache saves is the DB
44
+ * enrichment (comments, approvals), so that is where "cached" is observed.
45
+ */
46
+ describe('PullRequestService.getPrDetail — git work per read', () => {
47
+ function harness() {
48
+ const resolvePrShas = vi.fn(async () => ({ baseSha: BASE, headSha: HEAD }));
49
+ const changedFilesForPr = vi.fn(async () => []);
50
+ const git = { resolvePrShas, changedFilesForPr } as unknown as GitService;
51
+ const workspace = {
52
+ findAnyWorkspaceId: async () => 'ws-main',
53
+ ensureRemotesFetched: async () => undefined,
54
+ } as unknown as WorkspaceService;
55
+ const access = { canWriteAtRef: async () => false } as unknown as IAccessControl;
56
+ const svc = new PullRequestService(makeDb(), workspace, access, git);
57
+ const listComments = vi.fn(async () => []);
58
+ const getApprovalStates = vi.fn(async () => []);
59
+ svc.setDetailEnricher({
60
+ listComments,
61
+ getApprovalStates,
62
+ evaluateMergeGate: () => ({ mergeable: false, reasons: [], warnings: [] }),
63
+ });
64
+ return { svc, resolvePrShas, changedFilesForPr, getApprovalStates };
65
+ }
66
+
67
+ it('pins the file list to the SHAs it just resolved, and keeps patches for a client read', async () => {
68
+ const { svc, resolvePrShas, changedFilesForPr } = harness();
69
+ const detail = await svc.getPrDetail(7, { fresh: true });
70
+
71
+ expect(detail?.headSha).toBe(HEAD);
72
+ expect(detail?.baseSha).toBe(BASE);
73
+ expect(resolvePrShas).toHaveBeenCalledTimes(1);
74
+ expect(changedFilesForPr).toHaveBeenCalledTimes(1);
75
+ expect(changedFilesForPr).toHaveBeenCalledWith(
76
+ 'ws-main',
77
+ 'current-company-state',
78
+ 'alice/feature',
79
+ { at: { baseSha: BASE, headSha: HEAD } },
80
+ );
81
+ // The SHAs (and their fetch) come first; the file list rides on it.
82
+ expect(resolvePrShas.mock.invocationCallOrder[0]).toBeLessThan(
83
+ changedFilesForPr.mock.invocationCallOrder[0]!,
84
+ );
85
+ });
86
+
87
+ it('a client read is cached: the next plain read skips the enrichment', async () => {
88
+ const { svc, getApprovalStates } = harness();
89
+ await svc.getPrDetail(7, { fresh: true });
90
+ await svc.getPrDetail(7);
91
+ expect(getApprovalStates).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ it('an internal read (patches: false) skips patches and is not cached', async () => {
95
+ const { svc, changedFilesForPr, getApprovalStates } = harness();
96
+ await svc.getPrDetail(7, { fresh: true, patches: false });
97
+ expect(changedFilesForPr).toHaveBeenLastCalledWith(
98
+ 'ws-main',
99
+ 'current-company-state',
100
+ 'alice/feature',
101
+ { at: { baseSha: BASE, headSha: HEAD }, patchCap: 0 },
102
+ );
103
+ // Not cached: the next plain read enriches again and asks for its own
104
+ // (full) file list.
105
+ await svc.getPrDetail(7);
106
+ expect(getApprovalStates).toHaveBeenCalledTimes(2);
107
+ expect(changedFilesForPr).toHaveBeenLastCalledWith(
108
+ 'ws-main',
109
+ 'current-company-state',
110
+ 'alice/feature',
111
+ { at: { baseSha: BASE, headSha: HEAD } },
112
+ );
113
+ });
114
+ });
@@ -17,6 +17,7 @@ import type { WorkflowHooks, CommitValidationContext } from '../workflow-hooks.j
17
17
  import type { IAccessControl } from '../../access/access-control.interface.js';
18
18
  import { AccessDeniedError } from '../../access-model/access-errors.js';
19
19
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
20
+ import { assertInsideRepo } from '../../kb-fs/repo-path.js';
20
21
  import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from '../../kb-fs/clone-config.js';
21
22
  import {
22
23
  assertValidBranchName,
@@ -244,6 +245,23 @@ async function synthesizeUntrackedSideDiff(
244
245
  }
245
246
  }
246
247
 
248
+ /**
249
+ * `stat` for the stray check: false only when nothing can be there (ENOENT,
250
+ * or ENOTDIR when a parent segment is a file). Permission and I/O failures
251
+ * propagate: treating them as "absent" would let an unreadable stray pass as
252
+ * committed.
253
+ */
254
+ async function existsForCommitCheck(absolutePath: string): Promise<boolean> {
255
+ try {
256
+ await fs.stat(absolutePath);
257
+ return true;
258
+ } catch (err) {
259
+ const code = (err as NodeJS.ErrnoException).code;
260
+ if (code === 'ENOENT' || code === 'ENOTDIR') return false;
261
+ throw err;
262
+ }
263
+ }
264
+
247
265
  export class GitService implements IGitService {
248
266
  // Per-workspace fetch state, kept OUTSIDE the workspace mutex on purpose.
249
267
  // `git fetch` only writes `refs/remotes/origin/*` and packs new objects —
@@ -928,7 +946,10 @@ export class GitService implements IGitService {
928
946
  const { stdout: scoped } = await this.git(cwd, [
929
947
  'status', '--porcelain=v1', '--', repoRelativePath,
930
948
  ]);
931
- if (!scoped.trim()) return null;
949
+ if (!scoped.trim()) {
950
+ await this.assertNoBytesBesideRepo(workspaceId, cwd, relativePath, repoRelativePath);
951
+ return null;
952
+ }
932
953
 
933
954
  // **No access check at commit time.** Per the "disk is the source of
934
955
  // truth" rule, by the time content is on disk it must not be rejected.
@@ -1969,24 +1990,39 @@ export class GitService implements IGitService {
1969
1990
  * list, statuses, and +/- counts; per-file patches are generated for up to
1970
1991
  * `patchCap` files (default 400) — beyond that `patch` is left undefined,
1971
1992
  * exactly like the old API's binary/oversized files (the UI renders those with
1972
- * no inline diff). Patch generation is skipped for binary files.
1993
+ * no inline diff). Patch generation is skipped for binary files; `patchCap: 0`
1994
+ * skips it entirely, which is one git subprocess per changed file saved.
1995
+ *
1996
+ * `at` pins the diff to two commits the caller has ALREADY resolved (the
1997
+ * change-request detail resolves its SHAs first, which fetches both refs):
1998
+ * no fetch, no ref resolution, and the file list is computed from exactly
1999
+ * the commits the caller pins its head SHA to, so a fetch that lands in
2000
+ * between cannot make the two disagree. Never pass SHAs from a path that
2001
+ * has not just resolved them: the diff would describe a stale head.
1973
2002
  */
1974
2003
  async changedFilesForPr(
1975
2004
  workspaceId: string,
1976
2005
  baseBranch: string,
1977
2006
  headBranch: string,
1978
- opts: { patchCap?: number } = {},
2007
+ opts: { patchCap?: number; at?: { baseSha: string; headSha: string } } = {},
1979
2008
  ): Promise<PullRequestFile[]> {
1980
2009
  assertValidBranchName(baseBranch);
1981
2010
  assertValidBranchName(headBranch);
2011
+ if (opts.at) {
2012
+ for (const sha of [opts.at.baseSha, opts.at.headSha]) {
2013
+ if (!/^[0-9a-f]{40,64}$/.test(sha)) {
2014
+ throw new WorkflowValidationError(`invalid commit sha: ${sha}`);
2015
+ }
2016
+ }
2017
+ }
1982
2018
  const patchCap = opts.patchCap ?? 400;
1983
2019
  // Fetch outside the mutex (network round-trip) so origin latency can't hold
1984
2020
  // the workspace lock; the lock guards only the local diff work below.
1985
2021
  const cwd = await this.repoDir(workspaceId);
1986
- await this.fetchPrRefs(cwd, baseBranch, headBranch);
2022
+ if (!opts.at) await this.fetchPrRefs(cwd, baseBranch, headBranch);
1987
2023
  return this.mutex.run(workspaceId, async () => {
1988
- const baseRef = await this.resolveBranchRef(cwd, baseBranch);
1989
- const headRef = await this.resolveBranchRef(cwd, headBranch);
2024
+ const baseRef = opts.at ? opts.at.baseSha : await this.resolveBranchRef(cwd, baseBranch);
2025
+ const headRef = opts.at ? opts.at.headSha : await this.resolveBranchRef(cwd, headBranch);
1990
2026
  const range = `${baseRef}...${headRef}`; // three-dot = changes on head since merge-base
1991
2027
 
1992
2028
  const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
@@ -2383,6 +2419,37 @@ export class GitService implements IGitService {
2383
2419
  }
2384
2420
  }
2385
2421
 
2422
+ /**
2423
+ * A clean `git status` for a path has two honest readings (already
2424
+ * committed; a queued re-apply of something that landed) and one dishonest
2425
+ * one: the bytes exist, but BESIDE the repository, because a caller wrote a
2426
+ * workspace-relative path without the `<kbDirName>/` prefix. Git never sees
2427
+ * that file, so returning null would report "nothing to commit" for a write
2428
+ * that silently never landed. Throw instead; the pending-commits ladder
2429
+ * escalates the row to a needs-attention notice rather than dropping it.
2430
+ *
2431
+ * The stray has to be PROVABLE, because some callers pass repo-relative
2432
+ * paths on purpose (the decline-revert flow, the merge-time `roles.yaml`
2433
+ * preservation): bytes at the workspace-relative location AND nothing at
2434
+ * the same repo-relative location inside the clone. A clean, committed
2435
+ * `roles.yaml` keeps its null even if something of that name sits beside the
2436
+ * clone, so an unrelated stray cannot abort a merge. Only ENOENT (or a
2437
+ * parent that is not a directory) means "absent"; any other stat failure
2438
+ * surfaces, so an unreadable stray is never reported as committed.
2439
+ */
2440
+ private async assertNoBytesBesideRepo(
2441
+ workspaceId: string,
2442
+ repoDir: string,
2443
+ relativePath: string,
2444
+ repoRelativePath: string,
2445
+ ): Promise<void> {
2446
+ if (relativePath !== repoRelativePath) return;
2447
+ const workspaceDir = await this.workspaceService.getWorkspacePath(workspaceId);
2448
+ if (!(await existsForCommitCheck(path.join(workspaceDir, relativePath)))) return;
2449
+ if (await existsForCommitCheck(path.join(repoDir, repoRelativePath))) return;
2450
+ assertInsideRepo(relativePath, this.kbDirName);
2451
+ }
2452
+
2386
2453
  private async repoDir(workspaceId: string): Promise<string> {
2387
2454
  return path.join(await this.workspaceService.getWorkspacePath(workspaceId), this.kbDirName);
2388
2455
  }
@@ -7,12 +7,13 @@ import type {
7
7
  PullRequestFile,
8
8
  PullRequestState,
9
9
  PullRequestSummary,
10
+ IGitService,
10
11
  } from '@bevel-software/platform-shared';
11
12
  import type { Database } from '../../database/connection.js';
12
13
  import { changeRequests } from '../../database/schema.js';
13
14
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
14
15
  import type { IAccessControl } from '../../access/access-control.interface.js';
15
- import type { GitService } from './git.service.js';
16
+ import { AccessUnreadableError } from '../../access-model/access-errors.js';
16
17
  import { WorkflowValidationError } from '../../../shared/domain-errors.js';
17
18
  import { hashEmail } from '../../../shared/hash-email.js';
18
19
 
@@ -100,7 +101,7 @@ export class PullRequestService implements IPullRequestService {
100
101
  private readonly db: Database,
101
102
  private readonly workspaceService: WorkspaceService,
102
103
  private readonly accessControl: IAccessControl,
103
- private readonly gitService: GitService,
104
+ private readonly gitService: IGitService,
104
105
  ) {}
105
106
 
106
107
  setDetailEnricher(enricher: PrDetailEnricher): void {
@@ -276,7 +277,7 @@ export class PullRequestService implements IPullRequestService {
276
277
 
277
278
  async getPrDetail(
278
279
  prNumber: number,
279
- opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string } = {},
280
+ opts: { fresh?: boolean; workspaceId?: string; viewerEmail?: string; patches?: boolean } = {},
280
281
  ): Promise<PullRequestDetail | null> {
281
282
  if (!Number.isInteger(prNumber) || prNumber <= 0) {
282
283
  throw new WorkflowValidationError('PR number must be a positive integer');
@@ -303,10 +304,19 @@ export class PullRequestService implements IPullRequestService {
303
304
  );
304
305
  baseSha = shas.baseSha;
305
306
  headSha = shas.headSha;
307
+ // The file list is pinned to the SHAs just resolved (`at`), so it and
308
+ // the `headSha` approvals pin against describe the same commits even if
309
+ // another fetch lands on this workspace in between, and the second
310
+ // fetch of the same two refs is gone. `patches: false` is for the
311
+ // internal detail an approve / withdraw / revert fetches to pin its
312
+ // work: those never read `files[].patch`, and generating it cost one git
313
+ // subprocess per changed file per click. The detail served to clients
314
+ // keeps its patches, so the published payload is unchanged.
306
315
  files = await this.gitService.changedFilesForPr(
307
316
  workspaceId,
308
317
  row.targetBranch,
309
318
  row.sourceBranch,
319
+ { at: { baseSha, headSha }, ...(opts.patches === false ? { patchCap: 0 } : {}) },
310
320
  );
311
321
  }
312
322
 
@@ -343,6 +353,10 @@ export class PullRequestService implements IPullRequestService {
343
353
  opts.viewerEmail,
344
354
  )
345
355
  .catch((err) => {
356
+ // An unreadable access tree fails the whole read (503): a detail
357
+ // with empty approvals would tell the merge gate "nothing to
358
+ // approve", and a reviewer nothing at all.
359
+ if (err instanceof AccessUnreadableError) throw err;
346
360
  console.warn(`[cr] getApprovalStates failed for #${prNumber}:`, err);
347
361
  return [] as FileApprovalState[];
348
362
  }),
@@ -403,7 +417,11 @@ export class PullRequestService implements IPullRequestService {
403
417
  viewerCanCancel,
404
418
  };
405
419
 
406
- this.detailCache.set(cacheKey, { at: now, headSha: detail.headSha, value: detail });
420
+ // A patch-less detail is an internal read; it must not be served to the
421
+ // next client poll as if it were the full one.
422
+ if (opts.patches !== false) {
423
+ this.detailCache.set(cacheKey, { at: now, headSha: detail.headSha, value: detail });
424
+ }
407
425
  return detail;
408
426
  }
409
427
 
@@ -26,6 +26,7 @@ import type {
26
26
  } from './pending-commits.service.js';
27
27
  import { BACKOFF_MS, N_RECOVERY, N_TRANSIENT } from './pending-commits.service.js';
28
28
  import { sanitizeError } from './sanitize-error.js';
29
+ import { WorkflowDomainError } from '../../shared/domain-errors.js';
29
30
 
30
31
  /**
31
32
  * Where the worker escalates terminal failures — the narrow, workflow-owned
@@ -276,6 +277,8 @@ export class PendingCommitsWorker {
276
277
  /**
277
278
  * Process a single claimed row. Outcome paths (see plan §lifecycle):
278
279
  * - commit + push succeeds → delete the row.
280
+ * - throws because the path is outside the repository → markNeedsAttention
281
+ * + feedback notice at once (no retry can change it, see below).
279
282
  * - throws, transient budget remains → markTransientFailure.
280
283
  * - throws, transient exhausted, recovery budget remains → spawn agent.
281
284
  * - throws, recovery exhausted → markNeedsAttention + feedback notice.
@@ -303,6 +306,19 @@ export class PendingCommitsWorker {
303
306
  // contain credentialed URLs ("https://x-access-token:ghp_…@…"); we
304
307
  // can't undo a persisted leak, so mask before any of them sees it.
305
308
  const message = sanitizeError(err);
309
+
310
+ const corrected = pathOutsideRepoCorrection(err);
311
+ if (corrected !== null) {
312
+ // The bytes sit BESIDE the clone (a workspace-relative path without
313
+ // the clone-folder prefix), so git will never see them: no retry can
314
+ // succeed, and a recovery agent has no commit to repair. Every attempt
315
+ // would throw this same refusal, so escalate now, with the corrected
316
+ // path in the notice, instead of spending the transient budget and
317
+ // N_RECOVERY agent runs to reach the same row state.
318
+ await this.escalate(row, message, strayPathNotice(row, message, corrected), 'path outside the repository');
319
+ return;
320
+ }
321
+
306
322
  const nextAttempts = row.attempts + 1;
307
323
 
308
324
  if (nextAttempts < N_TRANSIENT) {
@@ -346,30 +362,76 @@ export class PendingCommitsWorker {
346
362
  }
347
363
 
348
364
  // Recovery budget exhausted — escalate.
349
- await this.deps.service.markNeedsAttention(row.id, message);
365
+ await this.escalate(row, message, terminalFailureNotice(row, message), `after ${N_RECOVERY} recovery runs`);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * The last step of every terminal path: flag the row `needs_attention`
371
+ * and tell the dashboard. `why` is the log line's reason; `notice` is the
372
+ * body the admin reads.
373
+ */
374
+ private async escalate(row: PendingCommit, message: string, notice: string, why: string): Promise<void> {
375
+ await this.deps.service.markNeedsAttention(row.id, message);
376
+ console.error(
377
+ `[pending-commits] TERMINAL ws=${row.workspaceId} branch=${row.branch} path=${row.path} ${why}: ${message}`,
378
+ );
379
+ try {
380
+ await this.deps.feedback.send({
381
+ source: 'system',
382
+ user: this.deps.recoveryBot,
383
+ message: notice,
384
+ });
385
+ } catch (feedbackErr) {
386
+ // Don't let a feedback-sink hiccup mask the underlying terminal
387
+ // failure — the row is already `needs_attention` and the next
388
+ // process restart will keep logging it. Just note that the
389
+ // notice didn't reach the dashboard.
350
390
  console.error(
351
- `[pending-commits] TERMINAL ws=${row.workspaceId} branch=${row.branch} path=${row.path} after ${N_RECOVERY} recovery runs: ${message}`,
391
+ `[pending-commits] failed to emit terminal-failure feedback notice for ws=${row.workspaceId} path=${row.path}:`,
392
+ sanitizeError(feedbackErr),
352
393
  );
353
- try {
354
- await this.deps.feedback.send({
355
- source: 'system',
356
- user: this.deps.recoveryBot,
357
- message: terminalFailureNotice(row, message),
358
- });
359
- } catch (feedbackErr) {
360
- // Don't let a feedback-sink hiccup mask the underlying terminal
361
- // failure — the row is already `needs_attention` and the next
362
- // process restart will keep logging it. Just note that the
363
- // notice didn't reach the dashboard.
364
- console.error(
365
- `[pending-commits] failed to emit terminal-failure feedback notice for ws=${row.workspaceId} path=${row.path}:`,
366
- sanitizeError(feedbackErr),
367
- );
368
- }
369
394
  }
370
395
  }
371
396
  }
372
397
 
398
+ /**
399
+ * The commit layer's refusal of a path whose bytes sit beside the clone
400
+ * rather than in it (`kb-fs/repo-path.ts`): the corrected path it carries,
401
+ * or null for any other error. Read from the payload, not the message: the
402
+ * message the worker keeps is sanitized and truncated to fit `last_error`,
403
+ * and the correction is what gets cut. Deterministic: the same row throws
404
+ * it on every attempt.
405
+ */
406
+ function pathOutsideRepoCorrection(err: unknown): string | null {
407
+ if (!(err instanceof WorkflowDomainError) || err.payload?.kind !== 'path-outside-repo') return null;
408
+ const corrected = err.payload.corrected;
409
+ return typeof corrected === 'string' && corrected.length > 0 ? corrected : '(see error)';
410
+ }
411
+
412
+ function strayPathNotice(row: PendingCommit, error: string, corrected: string): string {
413
+ return [
414
+ '[pending_commits] Commit refused: the file is outside the repository.',
415
+ '',
416
+ `Workspace: ${row.workspaceId}`,
417
+ `Branch: ${row.branch}`,
418
+ `Path: ${row.path}`,
419
+ `Use instead: ${corrected}`,
420
+ `Original author: ${row.authorEmail}`,
421
+ `Queued at: ${row.queuedAt.toISOString()}`,
422
+ `Error: ${error}`,
423
+ '',
424
+ 'The bytes were written beside the git clone (a workspace-relative path',
425
+ 'without the clone-folder prefix), so git never sees them. Nothing was',
426
+ 'committed, retrying cannot change that, and no recovery agent was run.',
427
+ '',
428
+ 'Resolve manually:',
429
+ ` - move the file to "${corrected}" and save it again, or delete it if it`,
430
+ ' should be discarded',
431
+ ' - then delete the row from pending_commits.',
432
+ ].join('\n');
433
+ }
434
+
373
435
  function terminalFailureNotice(row: PendingCommit, error: string): string {
374
436
  const elapsedMs = Date.now() - row.queuedAt.getTime();
375
437
  const elapsed = formatElapsed(elapsedMs);