@bevel-software/platform-core-backend 0.21.0 → 0.22.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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +32 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +13 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +51 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-requests.contract.d.ts +75 -0
- package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
- package/dist/modules/access/access-requests.contract.js +20 -0
- package/dist/modules/access/access-requests.contract.js.map +1 -0
- package/dist/modules/access/access-requests.routes.d.ts +35 -0
- package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
- package/dist/modules/access/access-requests.routes.js +237 -0
- package/dist/modules/access/access-requests.routes.js.map +1 -0
- package/dist/modules/access/access-requests.service.d.ts +123 -0
- package/dist/modules/access/access-requests.service.d.ts.map +1 -0
- package/dist/modules/access/access-requests.service.js +337 -0
- package/dist/modules/access/access-requests.service.js.map +1 -0
- package/dist/modules/access-model/access-grammar.d.ts +12 -0
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +24 -0
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +26 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +11 -2
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/github-app/github-app.client.d.ts +101 -0
- package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.client.js +222 -0
- package/dist/modules/github-app/github-app.client.js.map +1 -0
- package/dist/modules/github-app/github-app.connection.d.ts +102 -0
- package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.connection.js +185 -0
- package/dist/modules/github-app/github-app.connection.js.map +1 -0
- package/dist/modules/github-app/github-app.routes.d.ts +59 -0
- package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
- package/dist/modules/github-app/github-app.routes.js +323 -0
- package/dist/modules/github-app/github-app.routes.js.map +1 -0
- package/dist/modules/github-app/index.d.ts +4 -0
- package/dist/modules/github-app/index.d.ts.map +1 -0
- package/dist/modules/github-app/index.js +4 -0
- package/dist/modules/github-app/index.js.map +1 -0
- package/dist/modules/kb-fs/remote-url.d.ts +22 -0
- package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
- package/dist/modules/kb-fs/remote-url.js +35 -0
- package/dist/modules/kb-fs/remote-url.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +17 -5
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
- package/dist/modules/plugins/join-proposals.js +76 -22
- package/dist/modules/plugins/join-proposals.js.map +1 -1
- package/dist/modules/plugins/join-requests.service.d.ts +111 -18
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-requests.service.js +173 -29
- package/dist/modules/plugins/join-requests.service.js.map +1 -1
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
- package/dist/modules/plugins/plugins.routes.js +3 -2
- package/dist/modules/plugins/plugins.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +93 -4
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/managed-repository.d.ts +43 -0
- package/dist/modules/settings/managed-repository.d.ts.map +1 -0
- package/dist/modules/settings/managed-repository.js +60 -0
- package/dist/modules/settings/managed-repository.js.map +1 -0
- package/dist/modules/settings/repository-source.d.ts +128 -0
- package/dist/modules/settings/repository-source.d.ts.map +1 -0
- package/dist/modules/settings/repository-source.js +150 -0
- package/dist/modules/settings/repository-source.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +27 -4
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +187 -13
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
- package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
- package/dist/modules/skills/skill-access-requests.routes.js +24 -63
- package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +15 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +38 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +3 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +17 -4
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
- package/dist/modules/workflow/git/node-git-runner.js +5 -0
- package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +99 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +3 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +34 -7
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +148 -48
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +66 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +148 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +17 -1
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +28 -2
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/domain-errors.d.ts +39 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +55 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +10 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js.map +1 -1
- package/package.json +3 -3
- package/src/core/create-core-server.ts +37 -1
- package/src/core/create-core-services.ts +65 -8
- package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
- package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
- package/src/modules/access/access-requests.contract.ts +93 -0
- package/src/modules/access/access-requests.routes.ts +286 -0
- package/src/modules/access/access-requests.service.ts +420 -0
- package/src/modules/access-model/access-grammar.ts +22 -0
- package/src/modules/auth/__tests__/auth.service.test.ts +38 -0
- package/src/modules/auth/auth.service.ts +28 -1
- package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
- package/src/modules/github-app/github-app.client.ts +268 -0
- package/src/modules/github-app/github-app.connection.ts +204 -0
- package/src/modules/github-app/github-app.routes.ts +359 -0
- package/src/modules/github-app/index.ts +19 -0
- package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
- package/src/modules/kb-fs/remote-url.ts +35 -0
- package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
- package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
- package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
- package/src/modules/plugins/join-proposals.ts +87 -19
- package/src/modules/plugins/join-requests.service.ts +199 -39
- package/src/modules/plugins/plugins.routes.ts +3 -2
- package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +353 -0
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +282 -0
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +233 -0
- package/src/modules/settings/deployment-settings.service.ts +109 -3
- package/src/modules/settings/managed-repository.ts +68 -0
- package/src/modules/settings/repository-source.ts +197 -0
- package/src/modules/settings/setup.routes.ts +214 -10
- package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
- package/src/modules/skills/skill-access-requests.routes.ts +25 -75
- package/src/modules/tool-helpers/tool-context.ts +15 -0
- package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
- package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +141 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +49 -3
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
- package/src/modules/workflow/git/git.service.ts +25 -4
- package/src/modules/workflow/git/node-git-runner.ts +6 -0
- package/src/modules/workflow/git/pull-request.service.ts +119 -0
- package/src/modules/workflow/workflow.routes.ts +3 -1
- package/src/modules/workflow/workflow.service.ts +170 -52
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +233 -7
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +35 -3
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +211 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +159 -0
- package/src/modules/workspace/workspace.service.ts +17 -1
- package/src/modules/workspace/workspace.tools.ts +35 -0
- package/src/shared/domain-errors.ts +58 -0
- package/src/shared/git.contract.ts +10 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { Server as HttpServer } from 'node:http';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import express from 'express';
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
9
|
+
import '@utcp/http'; // registers the http CallTemplate serializer + protocol
|
|
10
|
+
import { UtcpManualSerializer } from '@utcp/sdk';
|
|
11
|
+
import { HttpCallTemplateSerializer } from '@utcp/http';
|
|
12
|
+
import { CodeModeUtcpClient } from '../../code-mode/index.js';
|
|
9
13
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
10
14
|
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
11
15
|
import { ToolRegistry } from '../../tool-registry/tool-registry.js';
|
|
@@ -86,7 +90,12 @@ describe('workspace tools — a branch that cannot be opened', () => {
|
|
|
86
90
|
* real tool-context resolver — so a tool call clones (or fails to clone)
|
|
87
91
|
* exactly as it does in production.
|
|
88
92
|
*/
|
|
89
|
-
|
|
93
|
+
/** The tool defs the mounted registry serves, kept for the sweep below. */
|
|
94
|
+
let internalTools: { name: string; inputs: unknown }[] = [];
|
|
95
|
+
|
|
96
|
+
async function start(
|
|
97
|
+
opts: { repoUrl?: 'origin' | 'unreachable'; scope?: 'read' | 'write'; source?: 'internal' | 'external' } = {},
|
|
98
|
+
): Promise<string> {
|
|
90
99
|
root = await mkdtemp(join(tmpdir(), 'ws-tools-branch-'));
|
|
91
100
|
spillRoot = await mkdtemp(join(tmpdir(), 'ws-tools-spill-'));
|
|
92
101
|
docCacheDir = await mkdtemp(join(tmpdir(), 'ws-tools-doc-'));
|
|
@@ -128,11 +137,33 @@ describe('workspace tools — a branch that cannot be opened', () => {
|
|
|
128
137
|
const app = express();
|
|
129
138
|
app.use(express.json());
|
|
130
139
|
const router = express.Router();
|
|
140
|
+
const registry = new ToolRegistry();
|
|
141
|
+
// The internal discovery endpoint, exactly as `manual.routes` serves it —
|
|
142
|
+
// what a real `CodeModeUtcpClient` reads to learn the tools it can call
|
|
143
|
+
// from inside a chain. Served here so the chain test below drives the SAME
|
|
144
|
+
// routes as every direct call in this file.
|
|
145
|
+
router.get('/agent/internal/utcp', async (_req, res) => {
|
|
146
|
+
res.json(
|
|
147
|
+
new UtcpManualSerializer().validateDict({
|
|
148
|
+
utcp_version: '1.1.0',
|
|
149
|
+
manual_version: '1.0.0',
|
|
150
|
+
tools: await registry.listInternal(),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
131
154
|
registerWorkspaceTools(
|
|
132
|
-
|
|
155
|
+
registry,
|
|
133
156
|
router,
|
|
134
157
|
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
|
135
|
-
req.toolAuth = {
|
|
158
|
+
req.toolAuth = {
|
|
159
|
+
// The sweep below asks for `internal` + `write`, which is what reaches
|
|
160
|
+
// the internal-only tools and gets past the write-scope refusal that
|
|
161
|
+
// sits in FRONT of every handler. Everything else in this file stays
|
|
162
|
+
// on the external read surface the bug was reported from.
|
|
163
|
+
source: opts.source ?? 'external',
|
|
164
|
+
userId: 'u',
|
|
165
|
+
scope: opts.scope ?? 'read',
|
|
166
|
+
} as ToolAuth;
|
|
136
167
|
next();
|
|
137
168
|
},
|
|
138
169
|
createToolHandlerFactory(resolve),
|
|
@@ -151,21 +182,34 @@ describe('workspace tools — a branch that cannot be opened', () => {
|
|
|
151
182
|
{} as never,
|
|
152
183
|
);
|
|
153
184
|
app.use('/api', router);
|
|
185
|
+
internalTools = (await registry.listInternal()) as unknown as { name: string; inputs: unknown }[];
|
|
154
186
|
httpServer = await new Promise<HttpServer>((r) => {
|
|
155
187
|
const s = app.listen(0, () => r(s));
|
|
156
188
|
});
|
|
157
189
|
return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
|
|
158
190
|
}
|
|
159
191
|
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
/** Call a tool with an arbitrary body — including one that omits `branch`. */
|
|
193
|
+
const callTool = async (
|
|
194
|
+
base: string,
|
|
195
|
+
tool: string,
|
|
196
|
+
body: unknown,
|
|
197
|
+
): Promise<{ status: number; body: Record<string, unknown> }> => {
|
|
198
|
+
const res = await fetch(`${base}/api/agent/tools/${tool}`, {
|
|
162
199
|
method: 'POST',
|
|
163
200
|
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
|
|
164
|
-
body: JSON.stringify(
|
|
201
|
+
body: JSON.stringify(body),
|
|
165
202
|
});
|
|
166
203
|
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
167
204
|
};
|
|
168
205
|
|
|
206
|
+
const listFiles = async (base: string, branch: string): Promise<{ status: number; body: Record<string, unknown> }> =>
|
|
207
|
+
callTool(base, 'list_files', { branch, path: '' });
|
|
208
|
+
|
|
209
|
+
/** Every entry currently under the workspaces root. */
|
|
210
|
+
const workspaceDirs = async (): Promise<string[]> =>
|
|
211
|
+
(await readdir(join(root, 'workspaces'))).sort();
|
|
212
|
+
|
|
169
213
|
const deleteOnHost = (branch: string): Promise<void> =>
|
|
170
214
|
runGit(join(root, 'upstream.git'), ['branch', '-D', branch]);
|
|
171
215
|
|
|
@@ -182,6 +226,188 @@ describe('workspace tools — a branch that cannot be opened', () => {
|
|
|
182
226
|
});
|
|
183
227
|
});
|
|
184
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The reported bug, from the outside: every tool call made without a
|
|
231
|
+
* `branch` answered `Branch "undefined" no longer exists on the remote.` —
|
|
232
|
+
* the missing input was carried all the way down, turned into a workspace
|
|
233
|
+
* directory named `undefined`, and the failed clone of that "branch" was
|
|
234
|
+
* then explained as a branch that had been deleted.
|
|
235
|
+
*
|
|
236
|
+
* The input is refused BY NAME instead, at the boundary, before any of that
|
|
237
|
+
* can happen. `list_files` stands in for every knowledge-base tool here
|
|
238
|
+
* because they share the one choke point (`getFilesystem`) — the refusal is
|
|
239
|
+
* the context resolver's, not this tool's.
|
|
240
|
+
*/
|
|
241
|
+
describe('a call that names no branch', () => {
|
|
242
|
+
// Every shape of "the caller sent no branch". The literals are in here
|
|
243
|
+
// because a client that interpolates a variable it never set sends the
|
|
244
|
+
// STRING "undefined" — indistinguishable, in intent, from omitting it, and
|
|
245
|
+
// the one value that must never reach a clone.
|
|
246
|
+
const absent: Array<[label: string, body: Record<string, unknown>]> = [
|
|
247
|
+
['the field is missing entirely', { path: '' }],
|
|
248
|
+
['the field is empty', { branch: '', path: '' }],
|
|
249
|
+
['the field is null', { branch: null, path: '' }],
|
|
250
|
+
['the field is a number', { branch: 42, path: '' }],
|
|
251
|
+
['the field is an object', { branch: { name: 'main' }, path: '' }],
|
|
252
|
+
['the field is an array', { branch: ['main'], path: '' }],
|
|
253
|
+
['the field is the literal "undefined"', { branch: 'undefined', path: '' }],
|
|
254
|
+
['the field is the literal "null"', { branch: 'null', path: '' }],
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const [label, body] of absent) {
|
|
258
|
+
it(`400s branch-required when ${label}`, async () => {
|
|
259
|
+
const base = await start();
|
|
260
|
+
|
|
261
|
+
const res = await callTool(base, 'list_files', body);
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(400);
|
|
264
|
+
expect(res.body).toEqual({
|
|
265
|
+
kind: 'branch-required',
|
|
266
|
+
error: '`branch` is required: pass the branch (draft) you are working on.',
|
|
267
|
+
});
|
|
268
|
+
// The refusal is the whole story: nothing was created and nothing was
|
|
269
|
+
// cloned. An empty workspaces root is the strongest form of that
|
|
270
|
+
// claim — there is no directory named for any branch, least of all one
|
|
271
|
+
// named `undefined`.
|
|
272
|
+
expect(await workspaceDirs()).toEqual([]);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
it('never writes `undefined` or `null` as a branch name into the answer', async () => {
|
|
277
|
+
const base = await start();
|
|
278
|
+
|
|
279
|
+
const res = await callTool(base, 'list_files', { path: '' });
|
|
280
|
+
|
|
281
|
+
// AC3, checked against the whole response rather than one field: the
|
|
282
|
+
// message names the INPUT and what to pass, never a stringified value of
|
|
283
|
+
// what was missing. `Branch "undefined" no longer exists` is exactly the
|
|
284
|
+
// sentence this must never produce again.
|
|
285
|
+
const serialized = JSON.stringify(res.body);
|
|
286
|
+
expect(serialized).not.toContain('undefined');
|
|
287
|
+
expect(serialized).not.toContain('null');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `call_tool_chain` is the other way every one of these tools gets called,
|
|
292
|
+
* and it is where the bug was reported from just as often. It is not a
|
|
293
|
+
* second implementation to guard: the chain's UTCP http transport POSTs to
|
|
294
|
+
* the SAME `/agent/tools/...` route this file has been calling directly, so
|
|
295
|
+
* the one refusal covers both. This test is what proves that claim rather
|
|
296
|
+
* than assuming it — with a real `CodeModeUtcpClient` reading a real
|
|
297
|
+
* manual and running real JavaScript.
|
|
298
|
+
*/
|
|
299
|
+
it('400s branch-required from inside call_tool_chain, and creates no workspace', async () => {
|
|
300
|
+
const base = await start();
|
|
301
|
+
const client = await CodeModeUtcpClient.create(process.cwd(), {
|
|
302
|
+
variables: { bevel_API_URL: base, bevel_CONNECTION_KEY: 'internal-token' },
|
|
303
|
+
} as never);
|
|
304
|
+
await client.registerManual(
|
|
305
|
+
new HttpCallTemplateSerializer().validateDict({
|
|
306
|
+
name: 'bevel',
|
|
307
|
+
call_template_type: 'http',
|
|
308
|
+
http_method: 'GET',
|
|
309
|
+
url: '${API_URL}/api/agent/internal/utcp',
|
|
310
|
+
content_type: 'application/json',
|
|
311
|
+
headers: { Authorization: 'Bearer ${CONNECTION_KEY}' },
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// The exact call from the specification: a chain that names the path and
|
|
316
|
+
// forgets the branch. The tool THROWS inside the chain, so the failure is
|
|
317
|
+
// caught and its message returned — the runtime's own contract for
|
|
318
|
+
// surfacing a tool error to the model.
|
|
319
|
+
const { result } = await client.callToolChain(
|
|
320
|
+
`try { return { ok: bevel.list_files({ body: { path: 'knowledge-base' } }) }; }
|
|
321
|
+
catch (err) { return { failed: err.message }; }`,
|
|
322
|
+
30_000,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// The tool did not run.
|
|
326
|
+
expect(result.ok).toBeUndefined();
|
|
327
|
+
// The message the chain hands the model carries the whole refusal: the
|
|
328
|
+
// status, the sentence, and the `kind` the direct caller switches on.
|
|
329
|
+
// (The status and payload are IN the message rather than on the thrown
|
|
330
|
+
// object — the sandboxed error exposes no own properties — which is why
|
|
331
|
+
// this asserts on the text the model actually reads.)
|
|
332
|
+
expect(result.failed).toContain('HTTP 400');
|
|
333
|
+
expect(result.failed).toContain('`branch` is required: pass the branch (draft) you are working on.');
|
|
334
|
+
expect(result.failed).toContain('"kind":"branch-required"');
|
|
335
|
+
// Not the sentence the bug produced — and no invented branch name in it.
|
|
336
|
+
expect(result.failed).not.toContain('no longer exists');
|
|
337
|
+
expect(result.failed).not.toContain('undefined');
|
|
338
|
+
// And the chain path refuses just as early: nothing was cloned.
|
|
339
|
+
expect(await workspaceDirs()).toEqual([]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* AC1 says "any knowledge-base tool", so this asserts it of EVERY tool the
|
|
344
|
+
* module mounts rather than of the one this file otherwise drives. The list
|
|
345
|
+
* comes from the registry, so a tool added later is swept in automatically
|
|
346
|
+
* and cannot quietly reintroduce the bug on its own route. The guard runs
|
|
347
|
+
* ahead of every other check, which is what lets one empty body stand in
|
|
348
|
+
* for a valid call to each of them.
|
|
349
|
+
*/
|
|
350
|
+
it('400s branch-required on every mounted tool that declares `branch`', async () => {
|
|
351
|
+
const base = await start({ source: 'internal', scope: 'write' });
|
|
352
|
+
const declaresBranch = internalTools
|
|
353
|
+
.filter((t) => {
|
|
354
|
+
const body = (t.inputs as { properties?: { body?: { required?: string[] } } }).properties?.body;
|
|
355
|
+
return (body?.required ?? []).includes('branch');
|
|
356
|
+
})
|
|
357
|
+
.map((t) => t.name);
|
|
358
|
+
|
|
359
|
+
// The whole branch-taking surface, not a sample of it.
|
|
360
|
+
expect(declaresBranch.length).toBeGreaterThan(10);
|
|
361
|
+
|
|
362
|
+
for (const tool of declaresBranch) {
|
|
363
|
+
const res = await callTool(base, tool, {});
|
|
364
|
+
expect(res.status, `${tool} must 400 on a branch-less call`).toBe(400);
|
|
365
|
+
expect(res.body.kind, `${tool} must answer kind branch-required`).toBe('branch-required');
|
|
366
|
+
// No tool's refusal may name the value it did not get.
|
|
367
|
+
expect(JSON.stringify(res.body), `${tool} must not echo an absent value`).not.toContain('undefined');
|
|
368
|
+
}
|
|
369
|
+
expect(await workspaceDirs()).toEqual([]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('still serves the same tool once a branch is named', async () => {
|
|
373
|
+
const base = await start();
|
|
374
|
+
// The guard refuses an absent input, not the tool: the same call with a
|
|
375
|
+
// real branch works, so the refusal cannot be a tool that simply broke.
|
|
376
|
+
expect((await callTool(base, 'list_files', { path: '' })).status).toBe(400);
|
|
377
|
+
|
|
378
|
+
const { status, body } = await listFiles(base, 'alice/draft');
|
|
379
|
+
|
|
380
|
+
expect(status).toBe(200);
|
|
381
|
+
expect((body.entries as { name: string }[]).map((e) => e.name)).toContain(KB_DIR);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The second half of the bug: a bootstrap that failed to clone used to leave
|
|
387
|
+
* a directory behind, and a directory named after a branch is how the
|
|
388
|
+
* platform remembers having known one. The first attempt's leftovers turned
|
|
389
|
+
* the second attempt's honest "no such branch" into "it was deleted".
|
|
390
|
+
*/
|
|
391
|
+
it('answers 404 again on a second attempt at a name that never existed', async () => {
|
|
392
|
+
const base = await start();
|
|
393
|
+
|
|
394
|
+
const first = await listFiles(base, 'nobody/never-made-this');
|
|
395
|
+
expect(first.status).toBe(404);
|
|
396
|
+
// Nothing survived the failed clone — not the repo directory, not the
|
|
397
|
+
// workspace shell around it. This is what keeps the next attempt honest.
|
|
398
|
+
expect(await workspaceDirs()).toEqual([]);
|
|
399
|
+
|
|
400
|
+
const second = await listFiles(base, 'nobody/never-made-this');
|
|
401
|
+
|
|
402
|
+
expect(second.status).toBe(404);
|
|
403
|
+
expect(second.body).toEqual({
|
|
404
|
+
kind: 'branch-not-found',
|
|
405
|
+
branch: 'nobody/never-made-this',
|
|
406
|
+
error: 'There is no branch named nobody/never-made-this.',
|
|
407
|
+
});
|
|
408
|
+
expect(await workspaceDirs()).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
|
|
185
411
|
it('410s a branch a listing showed us and origin no longer has', async () => {
|
|
186
412
|
const base = await start();
|
|
187
413
|
workspaces!.noteBranchesListed(['target-company-state', 'alice/draft']);
|
|
@@ -233,9 +233,24 @@ async function start(
|
|
|
233
233
|
return `http://127.0.0.1:${(httpServer.address() as { port: number }).port}`;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
/** POST a body EXACTLY as given — for the tests about a malformed call. */
|
|
237
|
+
const postRaw = (url: string, body: unknown = {}) =>
|
|
237
238
|
fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer x' }, body: JSON.stringify(body) });
|
|
238
239
|
|
|
240
|
+
/**
|
|
241
|
+
* POST a well-formed call. Every KB tool requires `branch` — a call that names
|
|
242
|
+
* none is refused at the mount with 400 `branch-required` — so this names one
|
|
243
|
+
* unless the test already did. A test ABOUT the missing input uses `postRaw`,
|
|
244
|
+
* so the thing under test is never papered over by the helper.
|
|
245
|
+
*/
|
|
246
|
+
const post = (url: string, body: unknown = {}) =>
|
|
247
|
+
postRaw(
|
|
248
|
+
url,
|
|
249
|
+
body !== null && typeof body === 'object' && !Array.isArray(body) && !('branch' in body)
|
|
250
|
+
? { branch: 'main', ...body }
|
|
251
|
+
: body,
|
|
252
|
+
);
|
|
253
|
+
|
|
239
254
|
beforeEach(() => {
|
|
240
255
|
/* fresh per test via start() */
|
|
241
256
|
});
|
|
@@ -456,7 +471,8 @@ describe('workspace file primitives', () => {
|
|
|
456
471
|
// named "undefined" — it fails closed with a clear 4xx naming the missing
|
|
457
472
|
// branch context, BEFORE any workspace resolve.
|
|
458
473
|
expect(focusedBranch).toBeUndefined();
|
|
459
|
-
|
|
474
|
+
// `postRaw`: the absent field IS the test here, so the body goes as written.
|
|
475
|
+
const res = await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo should-not-run' });
|
|
460
476
|
expect(res.status).toBe(400);
|
|
461
477
|
expect((await res.json()).error).toMatch(/branch/i);
|
|
462
478
|
// The guard runs before `getOrCreateForBranch`, so no workspace (least of all
|
|
@@ -471,7 +487,7 @@ describe('workspace file primitives', () => {
|
|
|
471
487
|
// against that branch's workspace and returns output end to end (AC2) —
|
|
472
488
|
// rather than failing closed the way a context-less external call does.
|
|
473
489
|
focusedBranch = 'main';
|
|
474
|
-
const res = (await (await
|
|
490
|
+
const res = (await (await postRaw(`${base}/api/agent/tools/execute_command`, { command: 'echo hello-exec' })).json()) as { stdout: string; exitCode: number };
|
|
475
491
|
expect(res.exitCode).toBe(0);
|
|
476
492
|
expect(res.stdout).toContain('hello-exec');
|
|
477
493
|
// Resolved against the focused branch — never an "undefined" workspace.
|
|
@@ -555,6 +571,20 @@ describe('workspace file primitives', () => {
|
|
|
555
571
|
expect(workspacePathCalls).toEqual([]);
|
|
556
572
|
});
|
|
557
573
|
|
|
574
|
+
it('answers a branch-less call with the same kind every other KB tool uses', async () => {
|
|
575
|
+
const base = await start();
|
|
576
|
+
// The MESSAGE here is this tool's own — only `execute_command` has a
|
|
577
|
+
// focused-branch fallback to explain. The DISCRIMINATOR is shared, so a
|
|
578
|
+
// client switches on one kind across the whole surface instead of matching
|
|
579
|
+
// prose per tool.
|
|
580
|
+
for (const body of [{ command: 'x' }, { branch: '', command: 'x' }, { branch: 'undefined', command: 'x' }]) {
|
|
581
|
+
const res = await postRaw(`${base}/api/agent/tools/execute_command`, body);
|
|
582
|
+
expect(res.status).toBe(400);
|
|
583
|
+
expect((await res.json()).kind).toBe('branch-required');
|
|
584
|
+
}
|
|
585
|
+
expect(workspacePathCalls).toEqual([]);
|
|
586
|
+
});
|
|
587
|
+
|
|
558
588
|
it('execute_command validates the branch BEFORE the write-policy gate', async () => {
|
|
559
589
|
const base = await start();
|
|
560
590
|
// A routine-restricted session is refused by `assertUnrestricted` with 403.
|
|
@@ -2266,6 +2296,8 @@ describe('the tools place an unprefixed path inside the repository', () => {
|
|
|
2266
2296
|
describe('folders never vanish', () => {
|
|
2267
2297
|
const KB = (p: string) => `${KB_DIR}/${p}`;
|
|
2268
2298
|
const tool = async (base: string, name: string, body: Record<string, unknown>) => {
|
|
2299
|
+
// `post` names the branch every KB tool requires; these tests are about
|
|
2300
|
+
// folders, not about the branch input.
|
|
2269
2301
|
const res = await post(`${base}/api/agent/tools/${name}`, body);
|
|
2270
2302
|
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
2271
2303
|
};
|
|
@@ -79,6 +79,7 @@ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> =
|
|
|
79
79
|
gitRunner: new NodeGitRunner(undefined, credentials),
|
|
80
80
|
kbRepoUrl: () => upstream,
|
|
81
81
|
workspacesRoot,
|
|
82
|
+
setAsideRoot: path.join(root, 'set-aside'),
|
|
82
83
|
kbDirName: 'knowledge-base',
|
|
83
84
|
templateDir: path.join(root, 'template'),
|
|
84
85
|
defaultBranch: () => DEFAULT_BRANCH,
|
|
@@ -103,6 +104,216 @@ async function checkout(branch: string): Promise<string> {
|
|
|
103
104
|
return dir;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The configured repository was replaced while working copies of the old one
|
|
109
|
+
* were still on disk. A clone fetches through its OWN stored address, so
|
|
110
|
+
* every one of them kept asking the repository that was gone: the boot
|
|
111
|
+
* stopped on "repository not found" and took the setup screen with it.
|
|
112
|
+
*/
|
|
113
|
+
describe('KbStartupRunner — the repository was replaced', () => {
|
|
114
|
+
const touchDefault = step('touch', async (ctx) => {
|
|
115
|
+
await (await ctx.defaultBranch()).repoDir();
|
|
116
|
+
return { outcome: 'ok' };
|
|
117
|
+
});
|
|
118
|
+
const cloneDir = (branch: string) => path.join(workspacesRoot, encodeURIComponent(branch), 'knowledge-base');
|
|
119
|
+
|
|
120
|
+
/** A second, unrelated repository with the same branch names. */
|
|
121
|
+
async function replacementUpstream(): Promise<string> {
|
|
122
|
+
const other = path.join(root, 'replacement.git');
|
|
123
|
+
await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, other]);
|
|
124
|
+
const seed = path.join(root, '.seed-replacement');
|
|
125
|
+
await fs.mkdir(seed);
|
|
126
|
+
await git(seed, ['init', '-b', DEFAULT_BRANCH]);
|
|
127
|
+
await fs.writeFile(path.join(seed, 'marker.txt'), 'replacement', 'utf8');
|
|
128
|
+
await git(seed, ['add', '-A']);
|
|
129
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
130
|
+
await git(seed, ['branch', PROTECTED[1]!]);
|
|
131
|
+
await git(seed, ['remote', 'add', 'origin', other]);
|
|
132
|
+
await git(seed, ['push', 'origin', ...PROTECTED]);
|
|
133
|
+
return other;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Boot once against the first repository, and leave a draft's clone beside it. */
|
|
137
|
+
async function bootedOnTheOldRepository(): Promise<void> {
|
|
138
|
+
await populatedUpstream();
|
|
139
|
+
await makeRunner([touchDefault]).runAll();
|
|
140
|
+
await fs.mkdir(path.dirname(cloneDir('someone/draft')), { recursive: true });
|
|
141
|
+
await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, cloneDir('someone/draft')]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Where one run set its working copies aside: the single dated folder under the root. */
|
|
145
|
+
async function setAside(): Promise<string> {
|
|
146
|
+
const runs = await fs.readdir(path.join(root, 'set-aside'));
|
|
147
|
+
expect(runs).toHaveLength(1);
|
|
148
|
+
return path.join(root, 'set-aside', runs[0]!);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it('clones the new repository in place of the old one\'s clones, even with the old one gone', async () => {
|
|
152
|
+
await bootedOnTheOldRepository();
|
|
153
|
+
const replacement = await replacementUpstream();
|
|
154
|
+
// Gone, as it was in production: any fetch through the old address fails.
|
|
155
|
+
await fs.rm(upstream, { recursive: true, force: true });
|
|
156
|
+
|
|
157
|
+
await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
|
|
158
|
+
|
|
159
|
+
const repo = cloneDir(DEFAULT_BRANCH);
|
|
160
|
+
expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(replacement);
|
|
161
|
+
expect(await fs.readFile(path.join(repo, 'marker.txt'), 'utf8')).toBe('replacement');
|
|
162
|
+
// A clone this boot had no reason to touch leaves the workspaces root
|
|
163
|
+
// too: the workspace service would otherwise adopt it and fetch from the
|
|
164
|
+
// old address.
|
|
165
|
+
await expect(fs.access(cloneDir('someone/draft'))).rejects.toThrow();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The old repository is GONE, so its clones are the last copy of what it
|
|
170
|
+
* held, and the commit nobody pushed exists nowhere else at all. They
|
|
171
|
+
* leave the workspaces root and nothing else happens to them.
|
|
172
|
+
*/
|
|
173
|
+
it('sets the old repository\'s clones aside whole, unpushed work included, and deletes nothing', async () => {
|
|
174
|
+
await bootedOnTheOldRepository();
|
|
175
|
+
const old = cloneDir(DEFAULT_BRANCH);
|
|
176
|
+
await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
|
|
177
|
+
await git(old, ['add', '-A']);
|
|
178
|
+
await git(old, ['commit', '-m', 'never pushed']);
|
|
179
|
+
const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
|
|
180
|
+
const replacement = await replacementUpstream();
|
|
181
|
+
await fs.rm(upstream, { recursive: true, force: true });
|
|
182
|
+
|
|
183
|
+
await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
|
|
184
|
+
|
|
185
|
+
const kept = await setAside();
|
|
186
|
+
const keptDefault = path.join(kept, encodeURIComponent(DEFAULT_BRANCH));
|
|
187
|
+
expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
188
|
+
expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
|
|
189
|
+
expect(await fs.readFile(path.join(keptDefault, 'marker.txt'), 'utf8')).toBe('seeded');
|
|
190
|
+
// The draft's clone too, under its own workspace's name.
|
|
191
|
+
await expect(fs.access(path.join(kept, encodeURIComponent('someone/draft'), '.git'))).resolves.toBeUndefined();
|
|
192
|
+
// And the probe that compared the histories left no refs behind in it.
|
|
193
|
+
expect((await git(keptDefault, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The shipped compose files mount the backups root as a volume of its own,
|
|
198
|
+
* where a rename across to it is refused. That is the path production
|
|
199
|
+
* takes: a copy, and the original removed only once the copy is whole.
|
|
200
|
+
*/
|
|
201
|
+
it('sets a clone aside across volumes, by copying it whole', async () => {
|
|
202
|
+
await bootedOnTheOldRepository();
|
|
203
|
+
const old = cloneDir(DEFAULT_BRANCH);
|
|
204
|
+
await fs.writeFile(path.join(old, 'unpushed.md'), 'the only copy', 'utf8');
|
|
205
|
+
await git(old, ['add', '-A']);
|
|
206
|
+
await git(old, ['commit', '-m', 'never pushed']);
|
|
207
|
+
const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
|
|
208
|
+
const replacement = await replacementUpstream();
|
|
209
|
+
await fs.rm(upstream, { recursive: true, force: true });
|
|
210
|
+
const crossDevice = Object.assign(new Error('EXDEV: cross-device link not permitted'), { code: 'EXDEV' });
|
|
211
|
+
const rename = vi.spyOn(fs, 'rename').mockRejectedValue(crossDevice);
|
|
212
|
+
try {
|
|
213
|
+
await makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll();
|
|
214
|
+
} finally {
|
|
215
|
+
rename.mockRestore();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const keptDefault = path.join(await setAside(), encodeURIComponent(DEFAULT_BRANCH));
|
|
219
|
+
expect((await git(keptDefault, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
220
|
+
expect(await fs.readFile(path.join(keptDefault, 'unpushed.md'), 'utf8')).toBe('the only copy');
|
|
221
|
+
expect(await fs.readFile(path.join(cloneDir(DEFAULT_BRANCH), 'marker.txt'), 'utf8')).toBe('replacement');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A clone that cannot be set aside stays exactly where it is, and the boot
|
|
226
|
+
* stops saying why: deleting it to get past would be the one outcome this
|
|
227
|
+
* whole path exists to rule out.
|
|
228
|
+
*/
|
|
229
|
+
it('deletes nothing when a clone cannot be set aside, and says why', async () => {
|
|
230
|
+
await bootedOnTheOldRepository();
|
|
231
|
+
const old = cloneDir(DEFAULT_BRANCH);
|
|
232
|
+
const head = (await git(old, ['rev-parse', 'HEAD'])).trim();
|
|
233
|
+
const replacement = await replacementUpstream();
|
|
234
|
+
await fs.rm(upstream, { recursive: true, force: true });
|
|
235
|
+
const rename = vi.spyOn(fs, 'rename').mockRejectedValue(Object.assign(new Error('EXDEV'), { code: 'EXDEV' }));
|
|
236
|
+
const cp = vi.spyOn(fs, 'cp').mockRejectedValue(Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }));
|
|
237
|
+
try {
|
|
238
|
+
await expect(makeRunner([touchDefault], { kbRepoUrl: () => replacement }).runAll()).rejects.toThrow(
|
|
239
|
+
/Could not set aside the working copy.*no space left.*Nothing was deleted/s,
|
|
240
|
+
);
|
|
241
|
+
} finally {
|
|
242
|
+
rename.mockRestore();
|
|
243
|
+
cp.mockRestore();
|
|
244
|
+
}
|
|
245
|
+
expect((await git(old, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The one change the setup screen tells an admin to make: the same
|
|
250
|
+
* repository, moved. The clone is pointed at the new address and kept, and
|
|
251
|
+
* the commit that was never pushed goes on to be pushed there.
|
|
252
|
+
*/
|
|
253
|
+
it('keeps a clone of the same repository at a new address, and points it there', async () => {
|
|
254
|
+
await bootedOnTheOldRepository();
|
|
255
|
+
const repo = cloneDir(DEFAULT_BRANCH);
|
|
256
|
+
await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
|
|
257
|
+
await git(repo, ['add', '-A']);
|
|
258
|
+
await git(repo, ['commit', '-m', 'local work']);
|
|
259
|
+
const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
|
|
260
|
+
// Moved: everything the old address held is at the new one, and the old
|
|
261
|
+
// one answers nothing any more.
|
|
262
|
+
const moved = path.join(root, 'moved.git');
|
|
263
|
+
await git(root, ['clone', '--bare', upstream, moved]);
|
|
264
|
+
await fs.rm(upstream, { recursive: true, force: true });
|
|
265
|
+
|
|
266
|
+
await makeRunner([touchDefault], { kbRepoUrl: () => moved }).runAll();
|
|
267
|
+
|
|
268
|
+
expect((await git(repo, ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
|
|
269
|
+
expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
270
|
+
expect((await git(cloneDir('someone/draft'), ['config', '--get', 'remote.origin.url'])).trim()).toBe(moved);
|
|
271
|
+
expect((await git(repo, ['for-each-ref', 'refs/hexis-probe/'])).trim()).toBe('');
|
|
272
|
+
// Nothing was another repository's, so nothing was set aside.
|
|
273
|
+
await expect(fs.access(path.join(root, 'set-aside'))).rejects.toThrow();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('leaves a clone of the configured repository alone, unpushed work included', async () => {
|
|
277
|
+
await bootedOnTheOldRepository();
|
|
278
|
+
const repo = cloneDir(DEFAULT_BRANCH);
|
|
279
|
+
await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
|
|
280
|
+
await git(repo, ['add', '-A']);
|
|
281
|
+
await git(repo, ['commit', '-m', 'local work']);
|
|
282
|
+
const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
|
|
283
|
+
|
|
284
|
+
await makeRunner([touchDefault]).runAll();
|
|
285
|
+
|
|
286
|
+
expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
287
|
+
await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not take a different spelling of the same address for another repository', async () => {
|
|
291
|
+
await bootedOnTheOldRepository();
|
|
292
|
+
const repo = cloneDir(DEFAULT_BRANCH);
|
|
293
|
+
await fs.writeFile(path.join(repo, 'unpushed.md'), 'local work', 'utf8');
|
|
294
|
+
await git(repo, ['add', '-A']);
|
|
295
|
+
await git(repo, ['commit', '-m', 'local work']);
|
|
296
|
+
const head = (await git(repo, ['rev-parse', 'HEAD'])).trim();
|
|
297
|
+
|
|
298
|
+
// `upstream` ends in `.git`; the same repository without it, and with a
|
|
299
|
+
// trailing slash.
|
|
300
|
+
await makeRunner([touchDefault], { kbRepoUrl: () => `${upstream.replace(/\.git$/, '')}/` }).runAll().catch(() => {});
|
|
301
|
+
|
|
302
|
+
expect((await git(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('keeps every clone when the configured repository cannot be reached', async () => {
|
|
306
|
+
// A typo in the address must not cost the working copies: the sweep runs
|
|
307
|
+
// only once the configured remote has answered.
|
|
308
|
+
await bootedOnTheOldRepository();
|
|
309
|
+
await expect(
|
|
310
|
+
makeRunner([touchDefault], { kbRepoUrl: () => path.join(root, 'no-such-repository.git') }).runAll(),
|
|
311
|
+
).rejects.toThrow();
|
|
312
|
+
await expect(fs.access(path.join(cloneDir(DEFAULT_BRANCH), '.git'))).resolves.toBeUndefined();
|
|
313
|
+
await expect(fs.access(cloneDir('someone/draft'))).resolves.toBeUndefined();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
106
317
|
describe('KbStartupRunner', () => {
|
|
107
318
|
it('seeds an empty remote with every protected branch before any step runs', async () => {
|
|
108
319
|
const seen: string[] = [];
|