@bevel-software/platform-core-backend 0.30.0 → 0.31.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.map +1 -1
- package/dist/core/create-core-services.js +4 -2
- package/dist/core/create-core-services.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/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/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- 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/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/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 +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- 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/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 +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- 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/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/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -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 +4 -2
- package/src/modules/access/access-control.service.ts +1 -6
- 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/database/core-schema.ts +15 -6
- 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__/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/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__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- 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/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- 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/no-link-below.ts +29 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { Router, RequestHandler } from 'express';
|
|
2
2
|
import type { IToolRegistry, JsonSchema } from '../../tool-registry/tool.contract.js';
|
|
3
|
-
import { ToolError, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
3
|
+
import { ToolError, hasHttpStatus, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
4
|
+
import { isCommitSha } from '../../../shared/git.contract.js';
|
|
4
5
|
import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
5
6
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
6
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
9
|
+
import { assertAgentWriteAllowed, notifyAgentRead, SESSION_ID_INPUT, type AgentAccessGate } from '../../workspace/index.js';
|
|
10
|
+
import { readablePathsAt, readableAtCommits, assertReadableAtCommits } from '../read-on-branch.js';
|
|
7
11
|
import type { KbContext } from '../../../shared/kb-context.js';
|
|
8
12
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
13
|
import { assertInsideRepo, normalizePathArgs } from '../../kb-fs/repo-path.js';
|
|
@@ -176,6 +180,21 @@ export function registerWorkflowTools(
|
|
|
176
180
|
* nor links a request it answers no for.
|
|
177
181
|
*/
|
|
178
182
|
maySeeChangeRequest: (ctx: ToolContext, number: number) => Promise<boolean>,
|
|
183
|
+
/**
|
|
184
|
+
* The per-file read gate the history tools apply: a file's past is the
|
|
185
|
+
* file, so a history read takes the read the file itself takes — on the
|
|
186
|
+
* workspace for its own branch's history (`canRead`), at the commit
|
|
187
|
+
* another branch's content was served from (`canReadAtRef`, through
|
|
188
|
+
* `readableAtCommits`, the compare route's own rule), and in batches for
|
|
189
|
+
* the path lists `branch_status` shows.
|
|
190
|
+
*/
|
|
191
|
+
accessControl: Pick<IAccessControl, 'canRead' | 'canReadAtRef' | 'canReadBatch' | 'canReadBatchAtRef'>,
|
|
192
|
+
/**
|
|
193
|
+
* The deployment's blocking read hook, told of every file content a history
|
|
194
|
+
* tool serves, as `read_file` tells it — a hook that refuses the read
|
|
195
|
+
* refuses the history too.
|
|
196
|
+
*/
|
|
197
|
+
agentAccessGate: AgentAccessGate,
|
|
179
198
|
): void {
|
|
180
199
|
const { kbDirName } = kb;
|
|
181
200
|
const mount = (spec: {
|
|
@@ -748,4 +767,446 @@ export function registerWorkflowTools(
|
|
|
748
767
|
};
|
|
749
768
|
},
|
|
750
769
|
});
|
|
770
|
+
|
|
771
|
+
// ── History and syncing ────────────────────────────────────────────────────
|
|
772
|
+
//
|
|
773
|
+
// What the app's History tab, compare view and "update from target" do,
|
|
774
|
+
// as tools: the agent never runs git itself. Each reads through the
|
|
775
|
+
// workflow service's gated methods and takes the per-file read the file
|
|
776
|
+
// itself takes.
|
|
777
|
+
|
|
778
|
+
/** The branch's workspace, made when it is not on disk yet, and its id. */
|
|
779
|
+
const workspaceOf = async (ctx: ToolContext, branch: string): Promise<string> => {
|
|
780
|
+
await ctx.workspaceService.getOrCreateForBranch(branch);
|
|
781
|
+
return workspaceIdForBranch(branch);
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The per-file read gate on the workspace's own branch, and the
|
|
786
|
+
* deployment's read hook told of the read (as `read_file` tells it);
|
|
787
|
+
* answers the repository-relative path.
|
|
788
|
+
*/
|
|
789
|
+
const requireRead = async (ctx: ToolContext, branch: string, workspaceId: string, path: string): Promise<string> => {
|
|
790
|
+
const rel = stripKbDir(path, kbDirName);
|
|
791
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
792
|
+
if (!(await accessControl.canRead(workspaceId, ctx.user.email, rel))) {
|
|
793
|
+
throw new ToolError(`You don't have permission to read "${rel}".`, 403, { kind: 'read-denied', path: rel });
|
|
794
|
+
}
|
|
795
|
+
return rel;
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The per-file read gate for content served from ANOTHER branch — the
|
|
800
|
+
* compare route's rule: at every commit the branch resolves to (the one
|
|
801
|
+
* the service serves it from, and its other tip when the local head and
|
|
802
|
+
* the published tip differ), all of which must grant, asked by the
|
|
803
|
+
* service inside its reservation before anything is said about the path
|
|
804
|
+
* there.
|
|
805
|
+
*/
|
|
806
|
+
const requireReadAtCommits = (ctx: ToolContext, workspaceId: string, rel: string, commits: readonly string[], branch: string): Promise<void> =>
|
|
807
|
+
assertReadableAtCommits(accessControl, workspaceId, ctx.user.email, rel, commits, branch);
|
|
808
|
+
|
|
809
|
+
/** Which of `paths` the caller may read at `origin/<base>` — the change-request reads' rule, `readablePathsAt`. */
|
|
810
|
+
const readableAt = (ctx: ToolContext, workspaceId: string, base: string, paths: string[]): Promise<Set<string>> =>
|
|
811
|
+
readablePathsAt(accessControl, workspaceId, ctx.user.email, base, paths);
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
mount({
|
|
815
|
+
name: 'list_file_changes',
|
|
816
|
+
description:
|
|
817
|
+
'The history of one file on `branch`: its commits, newest first — who made each, when, and its ' +
|
|
818
|
+
'subject — as the app\'s History tab shows them. `limit` caps the list (default 20, at most 100). ' +
|
|
819
|
+
'Read a version with `read_file_at` and its diff with `compare_file`. Takes the read the file itself ' +
|
|
820
|
+
'takes; a folder has no history of its own (ask per file).',
|
|
821
|
+
inputs: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
properties: {
|
|
824
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative (as `read_file` takes it).' },
|
|
825
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many commits at most (default 20).' },
|
|
826
|
+
sessionId: SESSION_ID_INPUT,
|
|
827
|
+
},
|
|
828
|
+
required: ['path'],
|
|
829
|
+
additionalProperties: false,
|
|
830
|
+
},
|
|
831
|
+
outputs: {
|
|
832
|
+
type: 'object',
|
|
833
|
+
properties: {
|
|
834
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
835
|
+
changes: { type: 'array', items: commitSchema, description: 'Newest first.' },
|
|
836
|
+
},
|
|
837
|
+
required: ['path', 'changes'],
|
|
838
|
+
},
|
|
839
|
+
write: false,
|
|
840
|
+
handler: async (args, ctx: ToolContext) => {
|
|
841
|
+
const branch = args.branch as string;
|
|
842
|
+
const path = args.path;
|
|
843
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
844
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
845
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
846
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
847
|
+
const changes = await ctx.workflowService.listChangesForFile(workspaceId, path, limit);
|
|
848
|
+
return { path: rel, changes };
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
mount({
|
|
853
|
+
name: 'read_file_at',
|
|
854
|
+
description:
|
|
855
|
+
'One file as it was at a point in history: `at` is a commit (from `list_file_changes`, on `branch`\'s ' +
|
|
856
|
+
'own history) or the name of another branch (its published tip). Answers `content` (null when the ' +
|
|
857
|
+
'file was not there) and `sha`, the commit it was read at; for a commit, also `before`, the file ' +
|
|
858
|
+
'just before that commit. To put an earlier version back, write it with `write_file`. Takes the read ' +
|
|
859
|
+
'the file takes at that point.',
|
|
860
|
+
inputs: {
|
|
861
|
+
type: 'object',
|
|
862
|
+
properties: {
|
|
863
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
864
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha, or a branch name.' },
|
|
865
|
+
sessionId: SESSION_ID_INPUT,
|
|
866
|
+
},
|
|
867
|
+
required: ['path', 'at'],
|
|
868
|
+
additionalProperties: false,
|
|
869
|
+
},
|
|
870
|
+
outputs: {
|
|
871
|
+
type: 'object',
|
|
872
|
+
properties: {
|
|
873
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
874
|
+
at: { type: 'string', description: 'What was asked for.' },
|
|
875
|
+
kind: { type: 'string', enum: ['commit', 'branch'] },
|
|
876
|
+
sha: { type: 'string', description: 'The commit the content was read at.' },
|
|
877
|
+
content: { type: ['string', 'null'], description: 'The file at that commit; null when it was not there.' },
|
|
878
|
+
before: { type: ['string', 'null'], description: 'For a commit: the file just before it; null when it was not there (or the commit is the first).' },
|
|
879
|
+
},
|
|
880
|
+
required: ['path', 'at', 'kind', 'sha', 'content'],
|
|
881
|
+
},
|
|
882
|
+
write: false,
|
|
883
|
+
handler: async (args, ctx: ToolContext) => {
|
|
884
|
+
const branch = args.branch as string;
|
|
885
|
+
const path = args.path;
|
|
886
|
+
const at = args.at;
|
|
887
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
888
|
+
if (typeof at !== 'string' || at.length === 0) throw new ToolError('`at` is required: a commit sha or a branch name.', 400);
|
|
889
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
890
|
+
// The hook and the workspace gate first, whichever point in history is
|
|
891
|
+
// read: the file is this workspace's file.
|
|
892
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
893
|
+
if (isCommitSha(at)) {
|
|
894
|
+
const { baseline, current } = await ctx.workflowService.fileAtChange(workspaceId, path, at);
|
|
895
|
+
return { path: rel, at, kind: 'commit', sha: at, content: current, before: baseline };
|
|
896
|
+
}
|
|
897
|
+
// Read at the branch's published tip, authorized at that very commit
|
|
898
|
+
// inside the read — before anything is said about the path there,
|
|
899
|
+
// the folder refusal included: the ref is a name a fetch may move
|
|
900
|
+
// between a verdict and a read; the commit is not.
|
|
901
|
+
const read = await ctx.workflowService.fileAtBranch(workspaceId, path, at, (commits) => requireReadAtCommits(ctx, workspaceId, rel, commits, at));
|
|
902
|
+
return { path: rel, at, kind: 'branch', sha: read.sha, content: read.content };
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
mount({
|
|
907
|
+
name: 'compare_file',
|
|
908
|
+
description:
|
|
909
|
+
'The unified diff of one file: between two branches (`from`, `to`; either may be `branch` itself), ' +
|
|
910
|
+
'or of one commit on `branch` (`at`: what that commit changed in the file). Empty when identical. ' +
|
|
911
|
+
'Takes the read the file takes on each side.',
|
|
912
|
+
inputs: {
|
|
913
|
+
type: 'object',
|
|
914
|
+
properties: {
|
|
915
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
916
|
+
from: { type: 'string', minLength: 1, description: 'A branch: the diff\'s left side.' },
|
|
917
|
+
to: { type: 'string', minLength: 1, description: 'A branch: the diff\'s right side.' },
|
|
918
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha on `branch`: the diff that commit made in the file.' },
|
|
919
|
+
sessionId: SESSION_ID_INPUT,
|
|
920
|
+
},
|
|
921
|
+
required: ['path'],
|
|
922
|
+
additionalProperties: false,
|
|
923
|
+
},
|
|
924
|
+
outputs: {
|
|
925
|
+
type: 'object',
|
|
926
|
+
properties: {
|
|
927
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
928
|
+
diff: { type: 'string', description: 'A unified diff; empty when the two sides are identical.' },
|
|
929
|
+
},
|
|
930
|
+
required: ['path', 'diff'],
|
|
931
|
+
},
|
|
932
|
+
write: false,
|
|
933
|
+
handler: async (args, ctx: ToolContext) => {
|
|
934
|
+
const branch = args.branch as string;
|
|
935
|
+
const path = args.path;
|
|
936
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
937
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
938
|
+
// As the compare route: the workspace's own gate on the path (the
|
|
939
|
+
// checked-out side can be served from the working tree), then each
|
|
940
|
+
// compared side at the commit it was served from.
|
|
941
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
942
|
+
if (typeof args.at === 'string' && args.at.length > 0) {
|
|
943
|
+
if (!isCommitSha(args.at)) throw new ToolError('`at` must be a full commit sha.', 400);
|
|
944
|
+
return { path: rel, diff: await ctx.workflowService.showFileAtChange(workspaceId, path, args.at) };
|
|
945
|
+
}
|
|
946
|
+
const from = args.from;
|
|
947
|
+
const to = args.to;
|
|
948
|
+
if (typeof from !== 'string' || from.length === 0 || typeof to !== 'string' || to.length === 0) {
|
|
949
|
+
throw new ToolError('Give `from` and `to` (two branches), or `at` (one commit).', 400);
|
|
950
|
+
}
|
|
951
|
+
const compared = await ctx.workflowService.compareFile(workspaceId, path, from, to, async (sides) => {
|
|
952
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.from, from);
|
|
953
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.to, to);
|
|
954
|
+
});
|
|
955
|
+
return { path: rel, diff: compared.diff };
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
mount({
|
|
960
|
+
name: 'branch_status',
|
|
961
|
+
description:
|
|
962
|
+
'Where `branch` stands, before proposing or syncing a draft. Answers whether it is on the shared ' +
|
|
963
|
+
'repository, how far ahead of and behind its upstream it is, whether the shared branch has commits this ' +
|
|
964
|
+
'workspace lacks (`behindRemote`: `update_branch` brings them in), the files written but not committed ' +
|
|
965
|
+
'yet, the protected branch it was forked from (`forkBase`), and `changedPaths`: what it changes against ' +
|
|
966
|
+
'`base` (default: the fork base) — the files a change request into it would carry.',
|
|
967
|
+
inputs: {
|
|
968
|
+
type: 'object',
|
|
969
|
+
properties: {
|
|
970
|
+
base: { type: 'string', minLength: 1, description: 'The branch to compare against (default: the fork base).' },
|
|
971
|
+
sessionId: SESSION_ID_INPUT,
|
|
972
|
+
},
|
|
973
|
+
additionalProperties: false,
|
|
974
|
+
},
|
|
975
|
+
outputs: {
|
|
976
|
+
type: 'object',
|
|
977
|
+
properties: {
|
|
978
|
+
branch: { type: 'string' },
|
|
979
|
+
isProtected: { type: 'boolean' },
|
|
980
|
+
hasRemote: { type: 'boolean', description: 'True when the shared repository has the branch.' },
|
|
981
|
+
ahead: { type: ['integer', 'null'], description: 'Commits ahead of the upstream (or the fork base); null when unresolved.' },
|
|
982
|
+
behind: { type: ['integer', 'null'], description: 'Commits behind; null when unresolved.' },
|
|
983
|
+
behindRemote: { type: 'boolean', description: 'The shared branch has commits this workspace has not merged yet.' },
|
|
984
|
+
uncommittedPaths: { type: 'array', items: { type: 'string' }, description: 'Written but not committed yet (saves still landing).' },
|
|
985
|
+
forkBase: { type: ['string', 'null'], description: 'The protected branch this one was forked from; null when none can be reached.' },
|
|
986
|
+
base: { type: ['string', 'null'], description: 'What `changedPaths` is against.' },
|
|
987
|
+
changedPaths: { type: 'array', items: { type: 'string' }, description: 'Repository-relative; rename-aware; only the paths you may read. Empty when `base` is null.' },
|
|
988
|
+
withheldPaths: { type: 'integer', description: 'How many changed or uncommitted paths were left out because you may not read them.' },
|
|
989
|
+
},
|
|
990
|
+
required: ['branch', 'isProtected', 'hasRemote', 'ahead', 'behind', 'behindRemote', 'uncommittedPaths', 'forkBase', 'base', 'changedPaths', 'withheldPaths'],
|
|
991
|
+
},
|
|
992
|
+
write: false,
|
|
993
|
+
handler: async (args, ctx: ToolContext) => {
|
|
994
|
+
const branch = args.branch as string;
|
|
995
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
996
|
+
// Each read below takes the workspace's reservation on its own: this
|
|
997
|
+
// is a REPORT of independent facts, not a range measured across steps,
|
|
998
|
+
// and a save or sync landing between two of them is tolerated — the
|
|
999
|
+
// next call reports it. (The one answer that must be exact, what a
|
|
1000
|
+
// merge is of, is taken under one reservation by `update_branch`.)
|
|
1001
|
+
const [status, branches, pending, forkBase] = await Promise.all([
|
|
1002
|
+
ctx.workflowService.branchStatus(workspaceId),
|
|
1003
|
+
ctx.workflowService.listBranches(workspaceId),
|
|
1004
|
+
ctx.workflowService.listPendingChangePaths(workspaceId),
|
|
1005
|
+
ctx.workflowService.resolveForkBase(workspaceId, branch),
|
|
1006
|
+
]);
|
|
1007
|
+
const info = branches.find((b) => b.name === branch);
|
|
1008
|
+
const base = typeof args.base === 'string' && args.base.length > 0 ? args.base : forkBase;
|
|
1009
|
+
// A path's name is information too: the changed paths are shown as the
|
|
1010
|
+
// change-request reads show them (readable at `origin/<base>`), the
|
|
1011
|
+
// uncommitted ones as the workspace's own listing would.
|
|
1012
|
+
const changedAll =
|
|
1013
|
+
base && base !== branch ? await ctx.workflowService.changedPathsBetweenBranches(workspaceId, base, branch) : [];
|
|
1014
|
+
const readableChanged = base ? await readableAt(ctx, workspaceId, base, changedAll) : new Set<string>();
|
|
1015
|
+
const changedPaths = changedAll.filter((p) => readableChanged.has(p));
|
|
1016
|
+
const pendingRel = pending.map((p) => stripKbDir(p, kbDirName));
|
|
1017
|
+
const pendingVerdicts = pendingRel.length > 0 ? await accessControl.canReadBatch(workspaceId, ctx.user.email, pendingRel) : new Map<string, boolean>();
|
|
1018
|
+
const uncommittedPaths = pendingRel.filter((p) => pendingVerdicts.get(p) === true);
|
|
1019
|
+
return {
|
|
1020
|
+
branch,
|
|
1021
|
+
isProtected: info?.isProtected ?? kb.protectedBranches.has(branch),
|
|
1022
|
+
hasRemote: info?.hasRemote ?? status.hasUpstream,
|
|
1023
|
+
ahead: info?.ahead ?? null,
|
|
1024
|
+
behind: info?.behind ?? null,
|
|
1025
|
+
behindRemote: status.unmergedFromUpstream,
|
|
1026
|
+
uncommittedPaths,
|
|
1027
|
+
forkBase,
|
|
1028
|
+
base: base && base !== branch ? base : null,
|
|
1029
|
+
changedPaths,
|
|
1030
|
+
withheldPaths: changedAll.length - changedPaths.length + (pendingRel.length - uncommittedPaths.length),
|
|
1031
|
+
};
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
mount({
|
|
1036
|
+
name: 'update_branch',
|
|
1037
|
+
description:
|
|
1038
|
+
'Bring another branch into `branch` as a merge commit by you, and publish it: `from` (a draft\'s ' +
|
|
1039
|
+
'target, to refresh the draft), or left out, the branch\'s own shared state when it moved while this ' +
|
|
1040
|
+
'workspace held commits (`branch_status` says `behindRemote`). Never a protected branch. CONFLICTS: ' +
|
|
1041
|
+
'the first call answers `conflicts` naming every conflicted path with the file at the merge base, on ' +
|
|
1042
|
+
'each side (`ours` = `branch`, `theirs` = `from`) and the working copy with git\'s markers, and leaves ' +
|
|
1043
|
+
'the branch as it was. Decide each file (keep both sides\' information unless one is clearly stale; ' +
|
|
1044
|
+
'`list_file_changes` shows intent), then call again with `resolutions`, one per conflicted path: ' +
|
|
1045
|
+
'`content` (the resolved text) or `choose` (`ours`, `theirs`, `delete`). Only conflicted paths are ' +
|
|
1046
|
+
'written here; other edits go through the file tools. Answers `merged`, `nothing-to-merge`, ' +
|
|
1047
|
+
'`conflicts` again (something changed meanwhile), or `pending-commits` (writes still landing: retry).',
|
|
1048
|
+
inputs: {
|
|
1049
|
+
type: 'object',
|
|
1050
|
+
properties: {
|
|
1051
|
+
from: { type: 'string', minLength: 1, description: 'The branch to bring in (default: this branch\'s own shared state).' },
|
|
1052
|
+
sessionId: SESSION_ID_INPUT,
|
|
1053
|
+
resolutions: {
|
|
1054
|
+
type: 'array',
|
|
1055
|
+
description: 'One per conflicted path, from a previous `conflicts` answer.',
|
|
1056
|
+
items: {
|
|
1057
|
+
type: 'object',
|
|
1058
|
+
properties: {
|
|
1059
|
+
path: { type: 'string', minLength: 1, description: 'The conflicted path, as the answer named it.' },
|
|
1060
|
+
content: { type: 'string', description: 'The resolved text of the file.' },
|
|
1061
|
+
choose: { type: 'string', enum: ['ours', 'theirs', 'delete'], description: 'Keep one side whole, or drop the file.' },
|
|
1062
|
+
},
|
|
1063
|
+
required: ['path'],
|
|
1064
|
+
additionalProperties: false,
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
additionalProperties: false,
|
|
1069
|
+
},
|
|
1070
|
+
outputs: {
|
|
1071
|
+
type: 'object',
|
|
1072
|
+
properties: {
|
|
1073
|
+
kind: { type: 'string', enum: ['merged', 'nothing-to-merge', 'conflicts', 'pending-commits'] },
|
|
1074
|
+
from: { type: 'string', description: 'The branch brought in.' },
|
|
1075
|
+
sha: { type: 'string', description: '`merged`: the merge commit, now the tip of `branch`, published; `nothing-to-merge`: the unchanged tip.' },
|
|
1076
|
+
resolvedPaths: { type: 'array', items: { type: 'string' }, description: '`merged`: the conflicts the resolutions settled.' },
|
|
1077
|
+
mergeBase: { type: ['string', 'null'], description: '`conflicts`: the common ancestor; null when the histories are unrelated.' },
|
|
1078
|
+
ours: { type: 'string', description: '`conflicts`: the tip of `branch`.' },
|
|
1079
|
+
theirs: { type: 'string', description: '`conflicts`: the tip of `from`.' },
|
|
1080
|
+
files: {
|
|
1081
|
+
type: 'array',
|
|
1082
|
+
description:
|
|
1083
|
+
'`conflicts`: every conflicted path you may read, with the file at the base, on each side, and the marked-up ' +
|
|
1084
|
+
'working copy. A side is null when that side has no file — or when `truncated` names it (too large or not ' +
|
|
1085
|
+
'text): then read `ours` with `read_file`, `theirs` with `read_file_at` at `from`, `base` with `read_file_at` ' +
|
|
1086
|
+
'at `mergeBase`.',
|
|
1087
|
+
items: {
|
|
1088
|
+
type: 'object',
|
|
1089
|
+
properties: {
|
|
1090
|
+
path: { type: 'string' },
|
|
1091
|
+
base: { type: ['string', 'null'] },
|
|
1092
|
+
ours: { type: ['string', 'null'] },
|
|
1093
|
+
theirs: { type: ['string', 'null'] },
|
|
1094
|
+
merged: { type: ['string', 'null'] },
|
|
1095
|
+
truncated: { type: 'array', items: { type: 'string', enum: ['base', 'ours', 'theirs', 'merged'] } },
|
|
1096
|
+
},
|
|
1097
|
+
required: ['path', 'base', 'ours', 'theirs', 'merged'],
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
unresolved: { type: 'array', items: { type: 'string' }, description: '`conflicts`: the paths you may read that no resolution covered.' },
|
|
1101
|
+
withheldConflicts: {
|
|
1102
|
+
type: 'integer',
|
|
1103
|
+
description:
|
|
1104
|
+
'`conflicts`: how many conflicted files were left out because you may not read them on one of the two branches. ' +
|
|
1105
|
+
'They are not yours to settle: ask a person who can read them to update the draft in the app.',
|
|
1106
|
+
},
|
|
1107
|
+
branch: { type: 'string', description: '`pending-commits`: the branch whose writes are not committed yet.' },
|
|
1108
|
+
pending: { type: 'integer', description: '`pending-commits`: how many.' },
|
|
1109
|
+
needsAttention: { type: 'string', description: '`pending-commits`: a queued commit failed; the worker\'s message. Tell the user.' },
|
|
1110
|
+
message: { type: 'string', description: '`pending-commits`: what to do next.' },
|
|
1111
|
+
},
|
|
1112
|
+
required: ['kind'],
|
|
1113
|
+
},
|
|
1114
|
+
write: true,
|
|
1115
|
+
handler: async (args, ctx: ToolContext) => {
|
|
1116
|
+
const branch = args.branch as string;
|
|
1117
|
+
const from = args.from;
|
|
1118
|
+
if (from !== undefined && (typeof from !== 'string' || from.length === 0)) {
|
|
1119
|
+
throw new ToolError('`from` must be a branch name.', 400);
|
|
1120
|
+
}
|
|
1121
|
+
let resolutions: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' }[] | undefined;
|
|
1122
|
+
if (args.resolutions !== undefined) {
|
|
1123
|
+
if (!Array.isArray(args.resolutions)) throw new ToolError('`resolutions` must be a list.', 400);
|
|
1124
|
+
resolutions = args.resolutions.map((r: unknown, i: number) => {
|
|
1125
|
+
const entry = r as Record<string, unknown>;
|
|
1126
|
+
if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
1127
|
+
throw new ToolError(`resolutions[${i}]: \`path\` is required.`, 400);
|
|
1128
|
+
}
|
|
1129
|
+
const out: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' } = { path: entry.path };
|
|
1130
|
+
if (entry.content !== undefined) {
|
|
1131
|
+
if (typeof entry.content !== 'string') throw new ToolError(`resolutions[${i}]: \`content\` must be text.`, 400);
|
|
1132
|
+
out.content = entry.content;
|
|
1133
|
+
}
|
|
1134
|
+
if (entry.choose !== undefined) {
|
|
1135
|
+
if (entry.choose !== 'ours' && entry.choose !== 'theirs' && entry.choose !== 'delete') {
|
|
1136
|
+
throw new ToolError(`resolutions[${i}]: \`choose\` is \`ours\`, \`theirs\` or \`delete\`.`, 400);
|
|
1137
|
+
}
|
|
1138
|
+
out.choose = entry.choose;
|
|
1139
|
+
}
|
|
1140
|
+
return out;
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
1144
|
+
/**
|
|
1145
|
+
* Whether the caller may read `rel` on both sides of this merge — at
|
|
1146
|
+
* the two COMMITS the answer says the merge was of, which is what its
|
|
1147
|
+
* bytes are from, never at a branch ref that may have moved since the
|
|
1148
|
+
* reservation was released — with the deployment's read hook told of
|
|
1149
|
+
* the read, as every history read tells it: a hook that refuses makes
|
|
1150
|
+
* the file one the caller may not read; a hook that fails is not an
|
|
1151
|
+
* answer and propagates.
|
|
1152
|
+
*/
|
|
1153
|
+
const mayReadBothSides = async (rel: string, at: { ours: string; theirs: string }): Promise<boolean> => {
|
|
1154
|
+
try {
|
|
1155
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, `${kbDirName}/${rel}`);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
// A hook's own refusal carries a status; anything else is a failure of the hook, not an answer.
|
|
1158
|
+
if (hasHttpStatus(err)) return false;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
1161
|
+
return readableAtCommits(accessControl, workspaceId, ctx.user.email, rel, [at.ours, at.theirs]);
|
|
1162
|
+
};
|
|
1163
|
+
// A resolution reads a file on both branches and writes it on the
|
|
1164
|
+
// draft, so the deployment's hooks are asked as they are for any read
|
|
1165
|
+
// and any write. The access model's own verdicts — may the caller read
|
|
1166
|
+
// the file on both branches, may they change it on the draft — are the
|
|
1167
|
+
// service's, asked inside the merge against the source commit as
|
|
1168
|
+
// fetched (`updateBranch`), so a rule that changed with the source
|
|
1169
|
+
// cannot be outrun by a check made before it was fetched.
|
|
1170
|
+
for (const r of resolutions ?? []) {
|
|
1171
|
+
const wsPath = `${kbDirName}/${stripKbDir(r.path, kbDirName)}`;
|
|
1172
|
+
try {
|
|
1173
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, wsPath);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
if (hasHttpStatus(err)) {
|
|
1176
|
+
throw new ToolError(`You may not read "${stripKbDir(r.path, kbDirName)}" here, so it is not yours to settle.`, 403, {
|
|
1177
|
+
kind: 'read-denied',
|
|
1178
|
+
path: stripKbDir(r.path, kbDirName),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPath);
|
|
1184
|
+
}
|
|
1185
|
+
// A resolution's content lands on the draft by the merge, not by the
|
|
1186
|
+
// file tools' filesystem: it takes the same pre-disk gate that
|
|
1187
|
+
// filesystem runs (`roles.yaml` must parse, no new role, no unknown
|
|
1188
|
+
// group), handed to the service to ask before the merge.
|
|
1189
|
+
const outcome =
|
|
1190
|
+
resolutions !== undefined
|
|
1191
|
+
? await ctx.workflowService.updateBranch(workspaceId, ctx.user, {
|
|
1192
|
+
...(from !== undefined ? { from } : {}),
|
|
1193
|
+
resolutions,
|
|
1194
|
+
validateWrite: await ctx.getWriteValidator(branch),
|
|
1195
|
+
})
|
|
1196
|
+
: await ctx.workflowService.updateBranch(workspaceId, ctx.user, from !== undefined ? { from } : {});
|
|
1197
|
+
if (outcome.kind !== 'conflicts') return outcome;
|
|
1198
|
+
// A conflict answer carries the file on both branches, and a path's
|
|
1199
|
+
// name is information too: a file the caller may not read on both is
|
|
1200
|
+
// left out of `files` and `unresolved` alike and only counted.
|
|
1201
|
+
const files = [];
|
|
1202
|
+
for (const f of outcome.files) if (await mayReadBothSides(f.path, outcome)) files.push(f);
|
|
1203
|
+
const shown = new Set(files.map((f) => f.path));
|
|
1204
|
+
return {
|
|
1205
|
+
...outcome,
|
|
1206
|
+
files,
|
|
1207
|
+
unresolved: outcome.unresolved.filter((p) => shown.has(p)),
|
|
1208
|
+
withheldConflicts: outcome.files.length - files.length,
|
|
1209
|
+
};
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
751
1212
|
}
|
|
@@ -9,6 +9,8 @@ import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
|
9
9
|
import { WorkflowHooks } from '../../workflow-hooks.js';
|
|
10
10
|
import { GitService } from '../git.service.js';
|
|
11
11
|
import { WorkflowValidationError, BranchNameError } from '../../../../shared/domain-errors.js';
|
|
12
|
+
import { GitRunError, type IGitRunner } from '../../../../shared/git.contract.js';
|
|
13
|
+
import { NodeGitRunner } from '../node-git-runner.js';
|
|
12
14
|
|
|
13
15
|
const execFileAsync = promisify(execFile);
|
|
14
16
|
const PROCESS_MAP_DIR = 'knowledge-base';
|
|
@@ -107,7 +109,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
107
109
|
await runGit(repo, ['checkout', '-b', 'alice/edit-foo']);
|
|
108
110
|
await commitFile(repo, 'Knowledge/Foo.md', 'line one\nline two\n', 'add line two');
|
|
109
111
|
|
|
110
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
112
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
111
113
|
workspaceId,
|
|
112
114
|
'Knowledge/Foo.md',
|
|
113
115
|
'current-company-state',
|
|
@@ -117,6 +119,69 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
117
119
|
expect(diff).toContain('+line two');
|
|
118
120
|
});
|
|
119
121
|
|
|
122
|
+
it('asks the caller\'s verdict at the two commits served, before the path is described at either', async () => {
|
|
123
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
124
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), testKbContext());
|
|
125
|
+
await commitFile(repo, 'Knowledge/Foo.md', 'line one\n', 'base');
|
|
126
|
+
await commitFile(repo, 'Docs/a.md', 'a\n', 'a folder');
|
|
127
|
+
const base = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim();
|
|
128
|
+
await runGit(repo, ['checkout', '-b', 'alice/edit-foo']);
|
|
129
|
+
await commitFile(repo, 'Knowledge/Foo.md', 'line one\nline two\n', 'add line two');
|
|
130
|
+
const tip = (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo })).stdout.trim();
|
|
131
|
+
|
|
132
|
+
const asked: unknown[] = [];
|
|
133
|
+
// Local branches only: each side resolves to the one commit it was served from.
|
|
134
|
+
const sides = { from: [base], to: [tip] };
|
|
135
|
+
const { diff } = await svc.diffFileBetweenBranches(workspaceId, 'Knowledge/Foo.md', 'current-company-state', 'alice/edit-foo', async (s) => {
|
|
136
|
+
asked.push(s);
|
|
137
|
+
});
|
|
138
|
+
expect(diff).toContain('+line two');
|
|
139
|
+
expect(asked).toEqual([sides]);
|
|
140
|
+
|
|
141
|
+
// A folder the caller may not read is refused as unreadable, never as a folder.
|
|
142
|
+
const refuse = async (s: unknown): Promise<void> => {
|
|
143
|
+
asked.push(s);
|
|
144
|
+
throw new WorkflowValidationError('not yours to read');
|
|
145
|
+
};
|
|
146
|
+
await expect(svc.diffFileBetweenBranches(workspaceId, 'Docs', 'current-company-state', 'alice/edit-foo', refuse)).rejects.toThrow(/not yours to read/);
|
|
147
|
+
expect(asked).toEqual([sides, sides]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('fails closed when a lookup of a side\'s other tip fails for any reason but absence: no diff, the verdict never asked', async () => {
|
|
151
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
152
|
+
await commitFile(repo, 'Knowledge/Foo.md', 'line one\n', 'base');
|
|
153
|
+
await runGit(repo, ['checkout', '-b', 'alice/edit-foo']);
|
|
154
|
+
await commitFile(repo, 'Knowledge/Foo.md', 'line one\nline two\n', 'add line two');
|
|
155
|
+
const inner = new NodeGitRunner();
|
|
156
|
+
// The secondary lookup (`refs/remotes/origin/alice/edit-foo`, absent in
|
|
157
|
+
// this local-only repository) fails the way a corrupt repository or a
|
|
158
|
+
// deadline fails — not the way an absent ref answers.
|
|
159
|
+
let failure: GitRunError | null = null;
|
|
160
|
+
const gitRunner = {
|
|
161
|
+
defaultTimeoutMs: inner.defaultTimeoutMs,
|
|
162
|
+
credentials: inner.credentials,
|
|
163
|
+
run: async (cwd: string, args: string[], opts?: object) => {
|
|
164
|
+
if (failure && args[0] === 'rev-parse' && args.includes('refs/remotes/origin/alice/edit-foo')) throw failure;
|
|
165
|
+
return inner.run(cwd, args, opts as never);
|
|
166
|
+
},
|
|
167
|
+
} as unknown as IGitRunner;
|
|
168
|
+
const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), new WorkflowHooks(), testKbContext(), undefined, null, gitRunner);
|
|
169
|
+
const asked: unknown[] = [];
|
|
170
|
+
const authorize = async (s: unknown): Promise<void> => { asked.push(s); };
|
|
171
|
+
for (const f of [
|
|
172
|
+
new GitRunError('git rev-parse failed', { exitCode: 128, stderr: 'fatal: bad object' }),
|
|
173
|
+
new GitRunError('git rev-parse timed out', { timedOut: true }),
|
|
174
|
+
]) {
|
|
175
|
+
failure = f;
|
|
176
|
+
await expect(svc.diffFileBetweenBranches(workspaceId, 'Knowledge/Foo.md', 'current-company-state', 'alice/edit-foo', authorize)).rejects.toBe(f);
|
|
177
|
+
}
|
|
178
|
+
expect(asked).toEqual([]);
|
|
179
|
+
// An absent ref is the one answer that is "no such commit": the diff goes on.
|
|
180
|
+
failure = null;
|
|
181
|
+
expect((await svc.diffFileBetweenBranches(workspaceId, 'Knowledge/Foo.md', 'current-company-state', 'alice/edit-foo', authorize)).diff).toContain('+line two');
|
|
182
|
+
expect(asked).toHaveLength(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
120
185
|
it('returns the empty string when the file is identical on both branches', async () => {
|
|
121
186
|
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
122
187
|
const svc = new GitService(
|
|
@@ -130,7 +195,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
130
195
|
// no edits to Same.md on the new branch
|
|
131
196
|
await commitFile(repo, 'Knowledge/Other.md', 'sidecar\n', 'unrelated edit');
|
|
132
197
|
|
|
133
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
198
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
134
199
|
workspaceId,
|
|
135
200
|
'Knowledge/Same.md',
|
|
136
201
|
'current-company-state',
|
|
@@ -151,7 +216,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
151
216
|
await runGit(repo, ['checkout', '-b', 'alice/cleanup']);
|
|
152
217
|
await deleteFileCommit(repo, 'Knowledge/Old.md', 'remove Old.md');
|
|
153
218
|
|
|
154
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
219
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
155
220
|
workspaceId,
|
|
156
221
|
'Knowledge/Old.md',
|
|
157
222
|
'current-company-state',
|
|
@@ -173,7 +238,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
173
238
|
await runGit(repo, ['checkout', '-b', 'alice/add-new']);
|
|
174
239
|
await commitFile(repo, 'Knowledge/New.md', 'brand new\n', 'add New.md');
|
|
175
240
|
|
|
176
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
241
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
177
242
|
workspaceId,
|
|
178
243
|
'Knowledge/New.md',
|
|
179
244
|
'current-company-state',
|
|
@@ -211,7 +276,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
211
276
|
testKbContext(),
|
|
212
277
|
);
|
|
213
278
|
|
|
214
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
279
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
215
280
|
workspaceId,
|
|
216
281
|
'Knowledge/Foo.md',
|
|
217
282
|
'current-company-state',
|
|
@@ -240,7 +305,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
240
305
|
await runGit(repo, ['checkout', '-b', 'alice/edit-foo']);
|
|
241
306
|
await commitFile(repo, 'Knowledge/Foo.md', 'baseline\nedit\n', 'edit');
|
|
242
307
|
|
|
243
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
308
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
244
309
|
workspaceId,
|
|
245
310
|
'knowledge-base/Knowledge/Foo.md',
|
|
246
311
|
'current-company-state',
|
|
@@ -267,7 +332,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
267
332
|
// and pressing save before sharing.
|
|
268
333
|
await writeFile(repo, 'Knowledge/Foo.md', 'baseline\nfresh edit\n');
|
|
269
334
|
|
|
270
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
335
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
271
336
|
workspaceId,
|
|
272
337
|
'Knowledge/Foo.md',
|
|
273
338
|
'current-company-state',
|
|
@@ -291,7 +356,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
291
356
|
// Brand-new file, never committed — only on disk.
|
|
292
357
|
await writeFile(repo, 'Knowledge/Brandnew.md', 'fresh content\n');
|
|
293
358
|
|
|
294
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
359
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
295
360
|
workspaceId,
|
|
296
361
|
'Knowledge/Brandnew.md',
|
|
297
362
|
'current-company-state',
|
|
@@ -313,7 +378,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
313
378
|
await runGit(repo, ['checkout', '-b', 'alice/draft']);
|
|
314
379
|
await writeFile(repo, 'Knowledge/Brandnew.md', 'fresh content\n');
|
|
315
380
|
|
|
316
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
381
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
317
382
|
workspaceId,
|
|
318
383
|
'Knowledge/Brandnew.md',
|
|
319
384
|
'alice/draft',
|
|
@@ -339,7 +404,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
339
404
|
// side. Lines added on current-company-state — i.e. the *removal* of
|
|
340
405
|
// "draft edit" — should appear as `+` on the to side, and "draft edit"
|
|
341
406
|
// should appear as `-`.
|
|
342
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
407
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
343
408
|
workspaceId,
|
|
344
409
|
'Knowledge/Foo.md',
|
|
345
410
|
'alice/edit-foo',
|
|
@@ -443,7 +508,7 @@ describe('GitService.diffFileBetweenBranches', () => {
|
|
|
443
508
|
await writeFile(repo, relPath, 'base content\nfeature edit\n');
|
|
444
509
|
await svc.commitFile(workspaceId, user, relPath, 'feature edit');
|
|
445
510
|
|
|
446
|
-
const diff = await svc.diffFileBetweenBranches(
|
|
511
|
+
const { diff } = await svc.diffFileBetweenBranches(
|
|
447
512
|
workspaceId,
|
|
448
513
|
relPath,
|
|
449
514
|
'current-company-state',
|