@bevel-software/platform-core-backend 0.30.0 → 0.32.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.
- package/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +16 -3
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access-model/index.d.ts +14 -0
- package/dist/modules/access-model/index.d.ts.map +1 -0
- package/dist/modules/access-model/index.js +13 -0
- package/dist/modules/access-model/index.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/auth/account-hooks.d.ts +4 -6
- package/dist/modules/auth/account-hooks.d.ts.map +1 -1
- package/dist/modules/auth/account-hooks.js +7 -17
- package/dist/modules/auth/account-hooks.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +9 -8
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +19 -18
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +43 -4
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +31 -7
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +28 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +166 -39
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +10 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.js +2 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +458 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/change-request-hooks.d.ts +78 -0
- package/dist/modules/workflow/change-request-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/change-request-hooks.js +15 -0
- package/dist/modules/workflow/change-request-hooks.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +10 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +9 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +124 -24
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +326 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/listener-seam.d.ts +34 -0
- package/dist/shared/listener-seam.d.ts.map +1 -0
- package/dist/shared/listener-seam.js +42 -0
- package/dist/shared/listener-seam.js.map +1 -0
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/0019_oauth_token_rotated_at.sql +7 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/0019_snapshot.json +2435 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +21 -2
- package/src/index.ts +7 -0
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/access-model/index.ts +13 -0
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/auth/__tests__/account-deactivation.test.ts +38 -2
- package/src/modules/auth/account-hooks.ts +13 -21
- package/src/modules/auth/auth.service.ts +19 -18
- package/src/modules/database/core-schema.ts +31 -7
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.pg.test.ts +279 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +190 -15
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +214 -48
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/change-request-hooks.test.ts +62 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.routes.open-change-request.test.ts +79 -0
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +508 -5
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +474 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/change-request-summary.ts +10 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +468 -1
- package/src/modules/workflow/change-request-hooks.ts +96 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/git/pull-request.service.ts +10 -0
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +390 -55
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/listener-seam.ts +52 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -8,6 +8,9 @@ import { createToolAuthMiddleware } from '../../../tool-auth/tool-auth.middlewar
|
|
|
8
8
|
import { createToolContextResolver } from '../../../tool-helpers/tool-context.js';
|
|
9
9
|
import { createToolHandlerFactory } from '../../../tool-helpers/tool-handler.js';
|
|
10
10
|
import { createManualRoutes } from '../../../tool-registry/manual.routes.js';
|
|
11
|
+
import { ToolError } from '../../../tool-helpers/index.js';
|
|
12
|
+
import { WorkflowHooks } from '../../workflow-hooks.js';
|
|
13
|
+
import { ToolDescriptionNotes, type AgentAccessGate } from '../../../workspace/index.js';
|
|
11
14
|
import { registerWorkflowTools } from '../workflow.tools.js';
|
|
12
15
|
import {
|
|
13
16
|
BranchAuthorshipError,
|
|
@@ -182,6 +185,76 @@ const workflowService = {
|
|
|
182
185
|
calls.push(['postComment', number, input.path]);
|
|
183
186
|
return { id: 'c-1' };
|
|
184
187
|
},
|
|
188
|
+
// The history and syncing reads, answered from a fixed script.
|
|
189
|
+
listChangesForFile: async (ws: string, path: string, limit?: number) => {
|
|
190
|
+
calls.push(['listChangesForFile', ws, path, limit]);
|
|
191
|
+
return [{ sha: 'abc1234', authorName: 'Ada', authorEmail: 'ada@x.io', subject: 'edit', committedAt: '2026-10-16T00:00:00Z' }];
|
|
192
|
+
},
|
|
193
|
+
fileAtChange: async (ws: string, path: string, sha: string) => {
|
|
194
|
+
calls.push(['fileAtChange', ws, path, sha]);
|
|
195
|
+
return { baseline: 'before\n', current: 'after\n' };
|
|
196
|
+
},
|
|
197
|
+
fileAtBranch: async (ws: string, path: string, branch: string, authorize?: (commits: string[]) => Promise<void>) => {
|
|
198
|
+
calls.push(['fileAtBranch', ws, path, branch]);
|
|
199
|
+
// As the service: the verdict at every commit the branch resolves to,
|
|
200
|
+
// before anything about the path there. `me/moved` is a branch whose
|
|
201
|
+
// local head has moved past its published tip: two commits.
|
|
202
|
+
await authorize?.(branch === 'me/moved' ? ['tip-of-me/moved', 'local-of-me/moved'] : ['tip-of-' + branch]);
|
|
203
|
+
return { sha: 'tip-of-' + branch, ref: `refs/remotes/origin/${branch}`, content: `on ${branch}\n` };
|
|
204
|
+
},
|
|
205
|
+
showFileAtChange: async (ws: string, path: string, sha: string) => {
|
|
206
|
+
calls.push(['showFileAtChange', ws, path, sha]);
|
|
207
|
+
return '@@ patch of ' + sha;
|
|
208
|
+
},
|
|
209
|
+
compareFile: async (ws: string, path: string, from: string, to: string, authorize?: (s: { from: string[]; to: string[] }) => Promise<void>) => {
|
|
210
|
+
calls.push(['compareFile', ws, path, from, to]);
|
|
211
|
+
const commitsOf = (b: string) => (b === 'me/moved' ? ['tip-of-me/moved', 'local-of-me/moved'] : ['tip-of-' + b]);
|
|
212
|
+
await authorize?.({ from: commitsOf(from), to: commitsOf(to) });
|
|
213
|
+
return { diff: `@@ ${from}..${to}`, from: 'tip-of-' + from, to: 'tip-of-' + to };
|
|
214
|
+
},
|
|
215
|
+
branchStatus: async (ws: string) => {
|
|
216
|
+
calls.push(['branchStatus', ws]);
|
|
217
|
+
return { branch: ws, hasUpstream: true, unmergedFromUpstream: true };
|
|
218
|
+
},
|
|
219
|
+
listPendingChangePaths: async (ws: string) => {
|
|
220
|
+
calls.push(['listPendingChangePaths', ws]);
|
|
221
|
+
return ['knowledge-base/KnowledgeBase/Pending.md'];
|
|
222
|
+
},
|
|
223
|
+
resolveForkBase: async (ws: string, branch: string) => {
|
|
224
|
+
calls.push(['resolveForkBase', ws, branch]);
|
|
225
|
+
return 'main';
|
|
226
|
+
},
|
|
227
|
+
changedPathsBetweenBranches: async (ws: string, base: string, branch: string) => {
|
|
228
|
+
calls.push(['changedPathsBetweenBranches', ws, base, branch]);
|
|
229
|
+
return ['KnowledgeBase/Changed.md'];
|
|
230
|
+
},
|
|
231
|
+
updateBranch: async (
|
|
232
|
+
ws: string,
|
|
233
|
+
user: { id: string },
|
|
234
|
+
opts: { from?: string; resolutions?: { path: string; content?: string }[]; validateWrite?: (wsPath: string, content: string) => Promise<void> },
|
|
235
|
+
) => {
|
|
236
|
+
calls.push(['updateBranch', ws, user.id, opts]);
|
|
237
|
+
// As the service: the caller's write gate is asked for every resolution that brings content.
|
|
238
|
+
for (const r of opts.resolutions ?? []) if (r.content !== undefined && opts.validateWrite) await opts.validateWrite(`knowledge-base/${r.path}`, r.content);
|
|
239
|
+
if (opts.from === 'me/conflicting' && opts.resolutions?.length) {
|
|
240
|
+
return { kind: 'merged', sha: 'resolved-sha', from: 'me/conflicting', resolvedPaths: opts.resolutions.map((r) => r.path) };
|
|
241
|
+
}
|
|
242
|
+
if (opts.from === 'me/conflicting') {
|
|
243
|
+
return {
|
|
244
|
+
kind: 'conflicts',
|
|
245
|
+
from: 'me/conflicting',
|
|
246
|
+
mergeBase: 'b',
|
|
247
|
+
ours: 'o',
|
|
248
|
+
theirs: 't',
|
|
249
|
+
unresolved: ['KnowledgeBase/Open.md', 'Secret/Plan.md'],
|
|
250
|
+
files: [
|
|
251
|
+
{ path: 'KnowledgeBase/Open.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' },
|
|
252
|
+
{ path: 'Secret/Plan.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' },
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return { kind: 'merged', sha: 'merge-sha', from: 'main', resolvedPaths: [] };
|
|
257
|
+
},
|
|
185
258
|
} as never;
|
|
186
259
|
const events = {
|
|
187
260
|
emit: (p: unknown) => {
|
|
@@ -189,6 +262,54 @@ const events = {
|
|
|
189
262
|
return {};
|
|
190
263
|
},
|
|
191
264
|
} as never;
|
|
265
|
+
/**
|
|
266
|
+
* The per-file read gate the history tools take. `Secret/` is readable by
|
|
267
|
+
* nobody on any branch; `me/hidden` is a branch whose refs grant nothing;
|
|
268
|
+
* `me/unknown` a branch none of whose refs resolve.
|
|
269
|
+
*/
|
|
270
|
+
const accessControl = {
|
|
271
|
+
canRead: async (ws: string, email: string, rel: string) => {
|
|
272
|
+
calls.push(['canRead', ws, email, rel]);
|
|
273
|
+
return !rel.startsWith('Secret/');
|
|
274
|
+
},
|
|
275
|
+
canReadAtRef: async (ws: string, ref: string, email: string, rel: string) => {
|
|
276
|
+
calls.push(['canReadAtRef', ws, ref, email, rel]);
|
|
277
|
+
if (ref.endsWith('me/unknown')) return null;
|
|
278
|
+
if (ref.endsWith('me/hidden')) return false;
|
|
279
|
+
// `me/moved`: its published tip grants, its local head (past it) denies.
|
|
280
|
+
if (ref === 'local-of-me/moved') return false;
|
|
281
|
+
return !rel.startsWith('Secret/');
|
|
282
|
+
},
|
|
283
|
+
canReadBatch: async (ws: string, email: string, rels: string[]) => {
|
|
284
|
+
calls.push(['canReadBatch', ws, email, rels]);
|
|
285
|
+
return new Map(rels.map((r) => [r, !r.startsWith('Secret/')]));
|
|
286
|
+
},
|
|
287
|
+
canReadBatchAtRef: async (ws: string, ref: string, email: string, rels: string[]) => {
|
|
288
|
+
calls.push(['canReadBatchAtRef', ws, ref, email, rels]);
|
|
289
|
+
if (ref.endsWith('me/unknown')) return null;
|
|
290
|
+
return new Map(rels.map((r) => [r, !r.startsWith('Secret/')]));
|
|
291
|
+
},
|
|
292
|
+
canWrite: async (ws: string, email: string, rel: string) => {
|
|
293
|
+
calls.push(['canWrite', ws, email, rel]);
|
|
294
|
+
return !rel.startsWith('ReadOnly/');
|
|
295
|
+
},
|
|
296
|
+
} as never;
|
|
297
|
+
/**
|
|
298
|
+
* The deployment's hooks: the read hook refuses `Hooked/`, fails outright on
|
|
299
|
+
* `Failing/` and records every read it is told of (with the session it was
|
|
300
|
+
* told for); the write hook refuses `Locked/`.
|
|
301
|
+
*/
|
|
302
|
+
const agentHooks = new WorkflowHooks();
|
|
303
|
+
agentHooks.onAgentRead(async (op) => {
|
|
304
|
+
calls.push(['agentRead', op.branch, op.wsPath, op.sessionId]);
|
|
305
|
+
if (op.wsPath?.includes('Hooked/')) throw new ToolError('Not in this conversation.', 403);
|
|
306
|
+
if (op.wsPath?.includes('Failing/')) throw new Error('hook store down');
|
|
307
|
+
});
|
|
308
|
+
agentHooks.onPreWrite(async (op) => {
|
|
309
|
+
calls.push(['preWrite', op.branch, op.wsPath]);
|
|
310
|
+
if (op.wsPath?.includes('Locked/')) throw new ToolError('Not yours to write here.', 403);
|
|
311
|
+
});
|
|
312
|
+
const agentAccessGate: AgentAccessGate = { recoveryBotEmail: 'recovery@x', hooks: agentHooks, notes: new ToolDescriptionNotes() };
|
|
192
313
|
|
|
193
314
|
const internalToken = new InternalTokenService({ secret: 's' });
|
|
194
315
|
let httpServer: HttpServer | undefined;
|
|
@@ -204,10 +325,19 @@ async function start(): Promise<string> {
|
|
|
204
325
|
|
|
205
326
|
const router = express.Router();
|
|
206
327
|
// Request #13 is one the caller may not see.
|
|
207
|
-
registerWorkflowTools(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
328
|
+
registerWorkflowTools(
|
|
329
|
+
registry,
|
|
330
|
+
router,
|
|
331
|
+
toolAuth,
|
|
332
|
+
toolHandler,
|
|
333
|
+
testKbContext(),
|
|
334
|
+
async (ctx, number) => {
|
|
335
|
+
calls.push(['maySeeAs', ctx.user.id, number]);
|
|
336
|
+
return number !== 13;
|
|
337
|
+
},
|
|
338
|
+
accessControl,
|
|
339
|
+
agentAccessGate,
|
|
340
|
+
);
|
|
211
341
|
router.use(createManualRoutes(registry, toolAuth));
|
|
212
342
|
|
|
213
343
|
const app = express();
|
|
@@ -522,6 +652,16 @@ describe('registerWorkflowTools', () => {
|
|
|
522
652
|
expect(files.every((f) => !('patch' in f))).toBe(true);
|
|
523
653
|
});
|
|
524
654
|
|
|
655
|
+
it('says who was notified when the service does, and nothing of it otherwise', async () => {
|
|
656
|
+
const base = await start();
|
|
657
|
+
// A read of a request later has no `notified` to report: the key is absent, not empty.
|
|
658
|
+
expect(await open(base)).not.toHaveProperty('notified');
|
|
659
|
+
opened = { ...BIG(), notified: ['email'] };
|
|
660
|
+
expect(await open(base)).toMatchObject({ number: 7, notified: ['email'] });
|
|
661
|
+
opened = { ...BIG(), notified: [] };
|
|
662
|
+
expect((await open(base)).notified).toEqual([]);
|
|
663
|
+
});
|
|
664
|
+
|
|
525
665
|
it('keeps the 27-file answer under 10,000 characters', async () => {
|
|
526
666
|
const base = await start();
|
|
527
667
|
const body = await open(base);
|
|
@@ -945,3 +1085,333 @@ describe('save_file and the repository folder', () => {
|
|
|
945
1085
|
expect(body.properties.path.description).toContain('with or without a leading slash');
|
|
946
1086
|
});
|
|
947
1087
|
});
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* The history and syncing tools: what the app's History tab, compare view and
|
|
1091
|
+
* "update from target" do, each taking the per-file read the file itself
|
|
1092
|
+
* takes, and the branch update handing the service exactly what it was given.
|
|
1093
|
+
*/
|
|
1094
|
+
describe('history and syncing tools', () => {
|
|
1095
|
+
const DRAFT = 'me/draft';
|
|
1096
|
+
const DRAFT_WS = 'me%2Fdraft';
|
|
1097
|
+
const tool = (base: string, name: string) => `${base}/api/agent/tools/${name}`;
|
|
1098
|
+
const SHA = 'a'.repeat(40);
|
|
1099
|
+
|
|
1100
|
+
it('list_file_changes answers the file\'s commits after the read gate, repository-relative', async () => {
|
|
1101
|
+
const base = await start();
|
|
1102
|
+
const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', limit: 5 });
|
|
1103
|
+
expect(res.status).toBe(200);
|
|
1104
|
+
expect(await res.json()).toEqual({
|
|
1105
|
+
path: 'KnowledgeBase/Foo.md',
|
|
1106
|
+
changes: [{ sha: 'abc1234', authorName: 'Ada', authorEmail: 'ada@x.io', subject: 'edit', committedAt: '2026-10-16T00:00:00Z' }],
|
|
1107
|
+
});
|
|
1108
|
+
expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'KnowledgeBase/Foo.md']);
|
|
1109
|
+
expect(calls).toContainEqual(['listChangesForFile', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 5]);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('list_file_changes refuses a file the caller may not read, without reading its history', async () => {
|
|
1113
|
+
const base = await start();
|
|
1114
|
+
const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'Secret/Plan.md' });
|
|
1115
|
+
expect(res.status).toBe(403);
|
|
1116
|
+
expect(calls.some((c) => c[0] === 'listChangesForFile')).toBe(false);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it('read_file_at reads a commit on the branch\'s own history, with the version before it', async () => {
|
|
1120
|
+
const base = await start();
|
|
1121
|
+
const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: SHA });
|
|
1122
|
+
expect(res.status).toBe(200);
|
|
1123
|
+
expect(await res.json()).toEqual({ path: 'KnowledgeBase/Foo.md', at: SHA, kind: 'commit', sha: SHA, content: 'after\n', before: 'before\n' });
|
|
1124
|
+
expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'KnowledgeBase/Foo.md']);
|
|
1125
|
+
expect(calls).toContainEqual(['fileAtChange', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', SHA]);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('read_file_at reads another branch at its published tip and authorizes at that very commit, never at a ref', async () => {
|
|
1129
|
+
const base = await start();
|
|
1130
|
+
const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'main' });
|
|
1131
|
+
expect(res.status).toBe(200);
|
|
1132
|
+
expect(await res.json()).toEqual({ path: 'KnowledgeBase/Foo.md', at: 'main', kind: 'branch', sha: 'tip-of-main', content: 'on main\n' });
|
|
1133
|
+
expect(calls).toContainEqual(['fileAtBranch', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 'main']);
|
|
1134
|
+
expect(calls).toContainEqual(['canReadAtRef', DRAFT_WS, 'tip-of-main', 'e@x', 'KnowledgeBase/Foo.md']);
|
|
1135
|
+
expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-main']);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('read_file_at and compare_file require every commit a branch resolves to grant: a served tip that grants never outruns a local head that denies', async () => {
|
|
1139
|
+
const base = await start();
|
|
1140
|
+
const read = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'me/moved' });
|
|
1141
|
+
expect(read.status).toBe(403);
|
|
1142
|
+
expect(JSON.stringify(await read.json())).not.toContain('on me/moved');
|
|
1143
|
+
expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-me/moved', 'local-of-me/moved']);
|
|
1144
|
+
|
|
1145
|
+
calls = [];
|
|
1146
|
+
const diff = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'me/moved', to: DRAFT });
|
|
1147
|
+
expect(diff.status).toBe(403);
|
|
1148
|
+
expect(JSON.stringify(await diff.json())).not.toContain('@@');
|
|
1149
|
+
expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-me/moved', 'local-of-me/moved']);
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
it('read_file_at refuses a branch whose commit denies the read, and one whose rules cannot be read, disclosing nothing', async () => {
|
|
1153
|
+
const base = await start();
|
|
1154
|
+
for (const at of ['me/hidden', 'me/unknown']) {
|
|
1155
|
+
const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at });
|
|
1156
|
+
expect(res.status, at).toBe(403);
|
|
1157
|
+
expect(JSON.stringify(await res.json())).not.toContain(`on ${at}`);
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('compare_file diffs one commit, or two branches after gating both', async () => {
|
|
1162
|
+
const base = await start();
|
|
1163
|
+
const atCommit = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: SHA });
|
|
1164
|
+
expect(atCommit.status).toBe(200);
|
|
1165
|
+
expect(await atCommit.json()).toEqual({ path: 'KnowledgeBase/Foo.md', diff: `@@ patch of ${SHA}` });
|
|
1166
|
+
|
|
1167
|
+
const between = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'main', to: DRAFT });
|
|
1168
|
+
expect(between.status).toBe(200);
|
|
1169
|
+
expect(await between.json()).toEqual({ path: 'KnowledgeBase/Foo.md', diff: '@@ main..me/draft' });
|
|
1170
|
+
// Authorized at the two commits the diff was made between, never at a ref.
|
|
1171
|
+
for (const b of ['main', DRAFT]) {
|
|
1172
|
+
expect(calls).toContainEqual(['canReadAtRef', DRAFT_WS, `tip-of-${b}`, 'e@x', 'KnowledgeBase/Foo.md']);
|
|
1173
|
+
}
|
|
1174
|
+
expect(calls.filter((c) => c[0] === 'canReadAtRef').map((c) => c[2])).toEqual(['tip-of-main', `tip-of-${DRAFT}`]);
|
|
1175
|
+
|
|
1176
|
+
const neither = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md' });
|
|
1177
|
+
expect(neither.status).toBe(400);
|
|
1178
|
+
const hidden = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'me/hidden', to: DRAFT });
|
|
1179
|
+
expect(hidden.status).toBe(403);
|
|
1180
|
+
expect(JSON.stringify(await hidden.json())).not.toContain('@@');
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('branch_status composes the branch\'s standing, against the fork base unless a base is named', async () => {
|
|
1184
|
+
const base = await start();
|
|
1185
|
+
const res = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT });
|
|
1186
|
+
expect(res.status).toBe(200);
|
|
1187
|
+
expect(await res.json()).toEqual({
|
|
1188
|
+
branch: DRAFT,
|
|
1189
|
+
// The listing double names only `main`, so the draft's own entry is unknown.
|
|
1190
|
+
isProtected: false,
|
|
1191
|
+
hasRemote: true,
|
|
1192
|
+
ahead: null,
|
|
1193
|
+
behind: null,
|
|
1194
|
+
behindRemote: true,
|
|
1195
|
+
uncommittedPaths: ['KnowledgeBase/Pending.md'],
|
|
1196
|
+
forkBase: 'main',
|
|
1197
|
+
base: 'main',
|
|
1198
|
+
changedPaths: ['KnowledgeBase/Changed.md'],
|
|
1199
|
+
withheldPaths: 0,
|
|
1200
|
+
});
|
|
1201
|
+
expect(calls).toContainEqual(['changedPathsBetweenBranches', DRAFT_WS, 'main', DRAFT]);
|
|
1202
|
+
|
|
1203
|
+
calls = [];
|
|
1204
|
+
const named = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'release' });
|
|
1205
|
+
expect(named.status).toBe(200);
|
|
1206
|
+
expect(await named.json()).toMatchObject({ base: 'release' });
|
|
1207
|
+
expect(calls).toContainEqual(['changedPathsBetweenBranches', DRAFT_WS, 'release', DRAFT]);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('update_branch hands the service the branch, the source and the resolutions as given', async () => {
|
|
1211
|
+
const base = await start();
|
|
1212
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), {
|
|
1213
|
+
branch: DRAFT,
|
|
1214
|
+
from: 'main',
|
|
1215
|
+
resolutions: [
|
|
1216
|
+
{ path: 'KnowledgeBase/Foo.md', content: 'resolved\n' },
|
|
1217
|
+
{ path: 'KnowledgeBase/Bar.md', choose: 'theirs' },
|
|
1218
|
+
],
|
|
1219
|
+
});
|
|
1220
|
+
expect(res.status).toBe(200);
|
|
1221
|
+
expect(await res.json()).toEqual({ kind: 'merged', sha: 'merge-sha', from: 'main', resolvedPaths: [] });
|
|
1222
|
+
// Handed as given, plus the agent's write gate for the content one brings.
|
|
1223
|
+
expect(calls).toContainEqual([
|
|
1224
|
+
'updateBranch',
|
|
1225
|
+
DRAFT_WS,
|
|
1226
|
+
'user-A',
|
|
1227
|
+
expect.objectContaining({
|
|
1228
|
+
from: 'main',
|
|
1229
|
+
resolutions: [{ path: 'KnowledgeBase/Foo.md', content: 'resolved\n' }, { path: 'KnowledgeBase/Bar.md', choose: 'theirs' }],
|
|
1230
|
+
validateWrite: expect.any(Function),
|
|
1231
|
+
}),
|
|
1232
|
+
]);
|
|
1233
|
+
|
|
1234
|
+
calls = [];
|
|
1235
|
+
const own = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT });
|
|
1236
|
+
expect(own.status).toBe(200);
|
|
1237
|
+
expect(calls).toContainEqual(['updateBranch', DRAFT_WS, 'user-A', {}]);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('update_branch refuses a malformed resolution before the service is asked', async () => {
|
|
1241
|
+
const base = await start();
|
|
1242
|
+
for (const resolutions of [
|
|
1243
|
+
'not a list',
|
|
1244
|
+
[{ content: 'no path' }],
|
|
1245
|
+
[{ path: 'A.md', choose: 'mine' }],
|
|
1246
|
+
[{ path: 'A.md', content: 42 }],
|
|
1247
|
+
]) {
|
|
1248
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, resolutions });
|
|
1249
|
+
expect(res.status, JSON.stringify(resolutions)).toBe(400);
|
|
1250
|
+
}
|
|
1251
|
+
expect(calls.some((c) => c[0] === 'updateBranch')).toBe(false);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it("tells the deployment's read hook of every history read, and a hook that refuses refuses the history", async () => {
|
|
1255
|
+
const base = await start();
|
|
1256
|
+
const res = await post(tool(base, 'list_file_changes'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md' });
|
|
1257
|
+
expect(res.status).toBe(200);
|
|
1258
|
+
expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', undefined]);
|
|
1259
|
+
const hooked = { branch: DRAFT, path: 'Hooked/Foo.md' };
|
|
1260
|
+
for (const [name, body] of [
|
|
1261
|
+
['list_file_changes', hooked],
|
|
1262
|
+
['read_file_at', { ...hooked, at: SHA }],
|
|
1263
|
+
['compare_file', { ...hooked, from: 'main', to: DRAFT }],
|
|
1264
|
+
] as const) {
|
|
1265
|
+
const refused = await post(tool(base, name), writeTok(), body);
|
|
1266
|
+
expect(refused.status, name).toBe(403);
|
|
1267
|
+
}
|
|
1268
|
+
expect(
|
|
1269
|
+
calls.some((c) => ['listChangesForFile', 'fileAtChange', 'showFileAtChange', 'compareFile'].includes(c[0] as string) && String(c[2]).includes('Hooked/')),
|
|
1270
|
+
).toBe(false);
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it("compare_file takes the workspace's own read on the path before the branches' refs, as the compare route does", async () => {
|
|
1274
|
+
const base = await start();
|
|
1275
|
+
const res = await post(tool(base, 'compare_file'), writeTok(), { branch: DRAFT, path: 'Secret/Plan.md', from: 'main', to: DRAFT });
|
|
1276
|
+
expect(res.status).toBe(403);
|
|
1277
|
+
expect(calls).toContainEqual(['canRead', DRAFT_WS, 'e@x', 'Secret/Plan.md']);
|
|
1278
|
+
expect(calls.some((c) => c[0] === 'canReadAtRef')).toBe(false);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it('read_file_at takes a full commit id; a short hex string is a branch name', async () => {
|
|
1282
|
+
const base = await start();
|
|
1283
|
+
const res = await post(tool(base, 'read_file_at'), writeTok(), { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'abc1234' });
|
|
1284
|
+
expect(res.status).toBe(200);
|
|
1285
|
+
expect(await res.json()).toMatchObject({ kind: 'branch', at: 'abc1234' });
|
|
1286
|
+
expect(calls).toContainEqual(['fileAtBranch', DRAFT_WS, 'knowledge-base/KnowledgeBase/Foo.md', 'abc1234']);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it('branch_status shows only the changed and uncommitted paths the caller may read, and counts the rest', async () => {
|
|
1290
|
+
const base = await start();
|
|
1291
|
+
calls = [];
|
|
1292
|
+
const res = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'main' });
|
|
1293
|
+
expect(res.status).toBe(200);
|
|
1294
|
+
expect(await res.json()).toMatchObject({ changedPaths: ['KnowledgeBase/Changed.md'], uncommittedPaths: ['KnowledgeBase/Pending.md'], withheldPaths: 0 });
|
|
1295
|
+
expect(calls).toContainEqual(['canReadBatchAtRef', DRAFT_WS, 'origin/main', 'e@x', ['KnowledgeBase/Changed.md']]);
|
|
1296
|
+
expect(calls).toContainEqual(['canReadBatch', DRAFT_WS, 'e@x', ['KnowledgeBase/Pending.md']]);
|
|
1297
|
+
|
|
1298
|
+
// A base whose rules do not resolve withholds every changed path.
|
|
1299
|
+
calls = [];
|
|
1300
|
+
const unknown = await post(tool(base, 'branch_status'), writeTok(), { branch: DRAFT, base: 'me/unknown' });
|
|
1301
|
+
expect(unknown.status).toBe(200);
|
|
1302
|
+
expect(await unknown.json()).toMatchObject({ changedPaths: [], withheldPaths: 1 });
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it('update_branch leaves a conflicted file the caller may not read on both sides out by name, and refuses to let them settle it', async () => {
|
|
1306
|
+
const base = await start();
|
|
1307
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting' });
|
|
1308
|
+
expect(res.status).toBe(200);
|
|
1309
|
+
const body = await res.json();
|
|
1310
|
+
expect(body.kind).toBe('conflicts');
|
|
1311
|
+
expect(body.unresolved).toEqual(['KnowledgeBase/Open.md']);
|
|
1312
|
+
expect(body.files).toEqual([{ path: 'KnowledgeBase/Open.md', base: 'b\n', ours: 'o\n', theirs: 't\n', merged: '<<<<<<<\n' }]);
|
|
1313
|
+
expect(body.withheldConflicts).toBe(1);
|
|
1314
|
+
expect(JSON.stringify(body)).not.toContain('Secret/Plan.md');
|
|
1315
|
+
// (Whether the caller may settle a file is the service's verdict, made
|
|
1316
|
+
// inside the merge against the source as fetched — see the facade test.)
|
|
1317
|
+
// A readable one is settled, and the service gets exactly that.
|
|
1318
|
+
calls = [];
|
|
1319
|
+
const side = await post(tool(base, 'update_branch'), writeTok(), {
|
|
1320
|
+
branch: DRAFT,
|
|
1321
|
+
from: 'me/conflicting',
|
|
1322
|
+
resolutions: [{ path: 'KnowledgeBase/Open.md', choose: 'theirs' }],
|
|
1323
|
+
});
|
|
1324
|
+
expect(side.status).toBe(200);
|
|
1325
|
+
expect(await side.json()).toEqual({ kind: 'merged', sha: 'resolved-sha', from: 'me/conflicting', resolvedPaths: ['KnowledgeBase/Open.md'] });
|
|
1326
|
+
expect(calls).toContainEqual([
|
|
1327
|
+
'updateBranch',
|
|
1328
|
+
DRAFT_WS,
|
|
1329
|
+
'user-A',
|
|
1330
|
+
expect.objectContaining({ from: 'me/conflicting', resolutions: [{ path: 'KnowledgeBase/Open.md', choose: 'theirs' }], validateWrite: expect.any(Function) }),
|
|
1331
|
+
]);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it("update_branch takes the agent's pre-disk write gate for a resolution's content: a roles.yaml that creates a role is refused as the file tools refuse it", async () => {
|
|
1335
|
+
const base = await start();
|
|
1336
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), {
|
|
1337
|
+
branch: DRAFT,
|
|
1338
|
+
from: 'me/conflicting',
|
|
1339
|
+
// Well-formed, and declaring a role the draft's (absent) roles.yaml does not: the agent gate's new-role refusal.
|
|
1340
|
+
resolutions: [{ path: 'roles.yaml', content: 'roles:\n Admin:\n - a@x.eu\n' }],
|
|
1341
|
+
});
|
|
1342
|
+
expect(res.status).toBe(422);
|
|
1343
|
+
expect(((await res.json()) as { error: string }).error).toMatch(/never create app roles/);
|
|
1344
|
+
// The gate travels with every resolution set (the service refuses one
|
|
1345
|
+
// without); a side kept whole brings no content and is never asked.
|
|
1346
|
+
const call = calls.find((c) => c[0] === 'updateBranch') as unknown[] | undefined;
|
|
1347
|
+
expect(typeof (call?.[3] as { validateWrite?: unknown })?.validateWrite).toBe('function');
|
|
1348
|
+
calls = [];
|
|
1349
|
+
const side = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path: 'roles.yaml', choose: 'theirs' }] });
|
|
1350
|
+
expect(side.status).toBe(200);
|
|
1351
|
+
expect(calls).toContainEqual([
|
|
1352
|
+
'updateBranch',
|
|
1353
|
+
DRAFT_WS,
|
|
1354
|
+
'user-A',
|
|
1355
|
+
expect.objectContaining({ from: 'me/conflicting', resolutions: [{ path: 'roles.yaml', choose: 'theirs' }], validateWrite: expect.any(Function) }),
|
|
1356
|
+
]);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("update_branch asks the deployment's hooks for a resolution as for any read and any write", async () => {
|
|
1360
|
+
const base = await start();
|
|
1361
|
+
for (const [path, status] of [
|
|
1362
|
+
['Locked/A.md', 403],
|
|
1363
|
+
['Hooked/A.md', 403],
|
|
1364
|
+
['KnowledgeBase/Open.md', 200],
|
|
1365
|
+
] as const) {
|
|
1366
|
+
calls = [];
|
|
1367
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path, content: 'x\n' }] });
|
|
1368
|
+
expect(res.status, path).toBe(status);
|
|
1369
|
+
expect(calls, path).toContainEqual(['agentRead', DRAFT, `knowledge-base/${path}`, undefined]);
|
|
1370
|
+
// The write hook is asked after the read hook let the path through:
|
|
1371
|
+
// it is what refuses `Locked/`, and is never reached for `Hooked/`.
|
|
1372
|
+
if (path !== 'Hooked/A.md') expect(calls, path).toContainEqual(['preWrite', DRAFT, `knowledge-base/${path}`]);
|
|
1373
|
+
else expect(calls.some((c) => c[0] === 'preWrite')).toBe(false);
|
|
1374
|
+
if (status !== 200) expect(calls.some((c) => c[0] === 'updateBranch')).toBe(false);
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("update_branch tells the deployment's read hook of each conflicted file before showing it; a hook that fails is not an answer", async () => {
|
|
1379
|
+
const base = await start();
|
|
1380
|
+
calls = [];
|
|
1381
|
+
const res = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting' });
|
|
1382
|
+
expect(res.status).toBe(200);
|
|
1383
|
+
expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Open.md', undefined]);
|
|
1384
|
+
expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/Secret/Plan.md', undefined]);
|
|
1385
|
+
// A failing hook propagates as a failure (500), not as a withheld file.
|
|
1386
|
+
const failing = await post(tool(base, 'update_branch'), writeTok(), { branch: DRAFT, from: 'me/conflicting', resolutions: [{ path: 'Failing/A.md', content: 'x' }] });
|
|
1387
|
+
expect(failing.status).toBe(500);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
it('carries the conversation id of an external caller to the read hook, as every file tool does', async () => {
|
|
1391
|
+
const base = await start();
|
|
1392
|
+
calls = [];
|
|
1393
|
+
const res = await post(tool(base, 'list_file_changes'), 'bevel_key', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', sessionId: 'conv-7' });
|
|
1394
|
+
expect(res.status).toBe(200);
|
|
1395
|
+
expect(calls).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', 'conv-7']);
|
|
1396
|
+
for (const [name, body] of [
|
|
1397
|
+
['read_file_at', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', at: 'main', sessionId: 'conv-7' }],
|
|
1398
|
+
['compare_file', { branch: DRAFT, path: 'KnowledgeBase/Foo.md', from: 'main', to: DRAFT, sessionId: 'conv-7' }],
|
|
1399
|
+
] as const) {
|
|
1400
|
+
calls = [];
|
|
1401
|
+
const r = await post(tool(base, name), 'bevel_key', body);
|
|
1402
|
+
expect(r.status, name).toBe(200);
|
|
1403
|
+
expect(calls, name).toContainEqual(['agentRead', DRAFT, 'knowledge-base/KnowledgeBase/Foo.md', 'conv-7']);
|
|
1404
|
+
}
|
|
1405
|
+
// `branch_status` reads no file content, so it tells the hook nothing; it still takes the id.
|
|
1406
|
+
const status = await post(tool(base, 'branch_status'), 'bevel_key', { branch: DRAFT, sessionId: 'conv-7' });
|
|
1407
|
+
expect(status.status).toBe(200);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it('lists the five on the external catalog too: a remote agent reads history and syncs its drafts', async () => {
|
|
1411
|
+
await start();
|
|
1412
|
+
const external = await registryRef!.listExternal();
|
|
1413
|
+
for (const name of ['list_file_changes', 'read_file_at', 'compare_file', 'branch_status', 'update_branch']) {
|
|
1414
|
+
expect(external.some((t) => t.name === name), name).toBe(true);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
@@ -11,6 +11,7 @@ import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
|
11
11
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
12
12
|
import type { KbContext } from '../../../shared/kb-context.js';
|
|
13
13
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
14
|
+
import { readablePathsAt } from '../read-on-branch.js';
|
|
14
15
|
import {
|
|
15
16
|
PER_PAGE_DEFAULT,
|
|
16
17
|
PER_PAGE_MAX,
|
|
@@ -668,28 +669,9 @@ export function changeRequestScope(
|
|
|
668
669
|
const repoGlobalWorkspaceId = async (ctx: ToolContext): Promise<string> =>
|
|
669
670
|
(await ctx.workspaceService.findAnyWorkspaceId()) ?? kb.defaultWorkspaceId();
|
|
670
671
|
|
|
671
|
-
/**
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
* resolve) withholds everything, which is the same reading the app's
|
|
675
|
-
* fork-point route gives it.
|
|
676
|
-
*/
|
|
677
|
-
const readablePaths = async (
|
|
678
|
-
ctx: ToolContext,
|
|
679
|
-
workspaceId: string,
|
|
680
|
-
base: string,
|
|
681
|
-
paths: string[],
|
|
682
|
-
): Promise<Set<string>> => {
|
|
683
|
-
if (paths.length === 0) return new Set();
|
|
684
|
-
const verdicts = await accessControl.canReadBatchAtRef(
|
|
685
|
-
workspaceId,
|
|
686
|
-
`origin/${base}`,
|
|
687
|
-
ctx.user.email,
|
|
688
|
-
[...new Set(paths)],
|
|
689
|
-
);
|
|
690
|
-
if (!verdicts) return new Set();
|
|
691
|
-
return new Set([...verdicts].filter(([, allowed]) => allowed).map(([p]) => p));
|
|
692
|
-
};
|
|
672
|
+
/** What the caller MAY read of `paths` at `origin/<base>` — the one rule, `readablePathsAt`. */
|
|
673
|
+
const readablePaths = (ctx: ToolContext, workspaceId: string, base: string, paths: string[]): Promise<Set<string>> =>
|
|
674
|
+
readablePathsAt(accessControl, workspaceId, ctx.user.email, base, paths);
|
|
693
675
|
|
|
694
676
|
const scopedDetail = async (
|
|
695
677
|
ctx: ToolContext,
|
|
@@ -96,6 +96,13 @@ export interface ChangeRequestSummary {
|
|
|
96
96
|
files: ChangeRequestSummaryFile[];
|
|
97
97
|
/** How many files the request changes in total, cut or not. */
|
|
98
98
|
totalFiles: number;
|
|
99
|
+
/**
|
|
100
|
+
* The channels that reached the approvers as the request was opened
|
|
101
|
+
* (`['email']`, say), as the deployment's listeners said; empty when nobody
|
|
102
|
+
* was told. Only on the answer to an open — a request read later has no
|
|
103
|
+
* such moment to report.
|
|
104
|
+
*/
|
|
105
|
+
notified?: string[];
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
/**
|
|
@@ -157,7 +164,7 @@ function summaryFileOf(file: ChangedFile, withPatch: boolean): ChangeRequestSumm
|
|
|
157
164
|
* by that: `mergeBlockedReasons` names every missing approval, cut or not.
|
|
158
165
|
*/
|
|
159
166
|
export function summarizeChangeRequest(
|
|
160
|
-
detail: ChangeRequestDetail,
|
|
167
|
+
detail: ChangeRequestDetail & { notified?: string[] },
|
|
161
168
|
include: readonly ChangeRequestInclude[] = [],
|
|
162
169
|
): ChangeRequestSummary {
|
|
163
170
|
const all = detail.files ?? [];
|
|
@@ -176,6 +183,8 @@ export function summarizeChangeRequest(
|
|
|
176
183
|
mergeBlockedReasons: detail.mergeBlockedReasons ?? [],
|
|
177
184
|
files: listed.map((f) => summaryFileOf(f, withPatch)),
|
|
178
185
|
totalFiles: all.length,
|
|
186
|
+
// Only an open carries it; a read of a request later has none to report.
|
|
187
|
+
...(detail.notified ? { notified: detail.notified } : {}),
|
|
179
188
|
};
|
|
180
189
|
}
|
|
181
190
|
|