@bevel-software/platform-core-backend 0.12.1 → 0.13.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/THIRD-PARTY-NOTICES.md +5 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +4 -3
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +5 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- 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())
|
|
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
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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]
|
|
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);
|