@bevel-software/platform-core-backend 0.24.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/modules/database/migrate.d.ts +87 -1
  6. package/dist/modules/database/migrate.d.ts.map +1 -1
  7. package/dist/modules/database/migrate.js +166 -48
  8. package/dist/modules/database/migrate.js.map +1 -1
  9. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  10. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  12. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  13. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  14. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  15. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  16. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  17. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  18. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  19. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  20. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  21. package/dist/modules/workspace/workspace.tools.js +58 -21
  22. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  23. package/kb-template/AGENTS.md +40 -0
  24. package/package.json +3 -3
  25. package/src/index.ts +7 -0
  26. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
  27. package/src/modules/database/migrate.ts +238 -52
  28. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  29. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  30. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  31. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  32. package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
  33. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  34. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  35. package/src/modules/workspace/workspace.tools.ts +59 -21
@@ -183,6 +183,157 @@ describe('ToolManualService', () => {
183
183
  expect(manual).toBeNull();
184
184
  });
185
185
 
186
+ test('resolves an inline tool that calls Google as a service account, and surfaces the key variable', async () => {
187
+ // The auth block is what makes the call; the key it names has to reach the
188
+ // secrets UI as a shared variable or nobody can store it.
189
+ await writeFile(
190
+ join(root, wsId, KB_DIR, 'Plugins', 'google_ads.tool'),
191
+ JSON.stringify({
192
+ id: 'google_ads',
193
+ type: 'inline',
194
+ tools: [
195
+ {
196
+ name: 'list_accessible_customers',
197
+ description: 'List the customers the service account can reach.',
198
+ inputs: { type: 'object', properties: {} },
199
+ outputs: { type: 'object', properties: {} },
200
+ tool_call_template: {
201
+ call_template_type: 'http',
202
+ http_method: 'GET',
203
+ url: 'https://googleads.googleapis.com/v22/customers:listAccessibleCustomers',
204
+ headers: { 'developer-token': '${DEVELOPER_TOKEN}' },
205
+ auth: {
206
+ auth_type: 'google_service_account',
207
+ credentials: '${GOOGLE_SA_KEY}',
208
+ scopes: 'https://www.googleapis.com/auth/adwords',
209
+ },
210
+ },
211
+ },
212
+ ],
213
+ }),
214
+ );
215
+
216
+ const manual = await svc().resolveInlineManual('user@x.eu', 'google_ads');
217
+ const tools = (manual as { tools?: { name?: string; tool_call_template?: { auth?: unknown } }[] } | null)?.tools ?? [];
218
+ expect(tools.map((t) => t.name)).toEqual(['list_accessible_customers']);
219
+ // The auth block is what makes the call, so resolving must carry it through intact.
220
+ expect(tools[0]?.tool_call_template?.auth).toEqual({
221
+ auth_type: 'google_service_account',
222
+ credentials: '${GOOGLE_SA_KEY}',
223
+ scopes: 'https://www.googleapis.com/auth/adwords',
224
+ });
225
+
226
+ const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'google_ads')!;
227
+ expect(summary.variables?.find((v) => v.name === 'GOOGLE_SA_KEY')?.scope).toBe('admin');
228
+ });
229
+
230
+ test('refuses a Google service-account auth block where no token would be sent, and says where', async () => {
231
+ // Only the `http` protocol mints the token. On any other template the
232
+ // block validates and is then sent as no credentials at all, so the file
233
+ // is refused when it is read rather than left to fail at Google.
234
+ const serviceAccount = {
235
+ auth_type: 'google_service_account',
236
+ credentials: '${GOOGLE_SA_KEY}',
237
+ scopes: 'https://www.googleapis.com/auth/adwords',
238
+ };
239
+ const inlineOver = (callTemplateType: string) =>
240
+ JSON.stringify({
241
+ type: 'inline',
242
+ tools: [
243
+ {
244
+ name: 'events',
245
+ description: 'Stream events.',
246
+ inputs: { type: 'object', properties: {} },
247
+ outputs: { type: 'object', properties: {} },
248
+ tool_call_template: { call_template_type: callTemplateType, url: 'https://api.example.com/events', auth: serviceAccount },
249
+ },
250
+ ],
251
+ });
252
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
253
+ await writeFile(join(plugins, 'over_sse.tool'), inlineOver('sse'));
254
+ await writeFile(join(plugins, 'over_streamable.tool'), inlineOver('streamable_http'));
255
+ // Not on a call template at all: a discovered manual takes only a url and headers.
256
+ await writeFile(
257
+ join(plugins, 'discovered.tool'),
258
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', auth: serviceAccount }),
259
+ );
260
+ // Looking like an `http` call template is not enough: a discovered manual
261
+ // reads neither a block at its root nor a `tools` list, whatever they say.
262
+ await writeFile(
263
+ join(plugins, 'dressed_root.tool'),
264
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', call_template_type: 'http', auth: serviceAccount }),
265
+ );
266
+ await writeFile(
267
+ join(plugins, 'unread_tools.tool'),
268
+ JSON.stringify({ ...JSON.parse(inlineOver('http')), type: 'http', url: 'https://api.example.com/utcp' }),
269
+ );
270
+ await writeFile(join(plugins, 'over_http.tool'), inlineOver('http'));
271
+
272
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
273
+
274
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
275
+ expect(reasonFor('over_sse.tool')).toContain('a `sse` call template');
276
+ expect(reasonFor('over_streamable.tool')).toContain('a `streamable_http` call template');
277
+ expect(reasonFor('discovered.tool')).toContain("not a call template's `auth`");
278
+ expect(reasonFor('dressed_root.tool')).toContain('no tool is called through');
279
+ expect(reasonFor('unread_tools.tool')).toContain('no tool is called through');
280
+ for (const file of ['over_sse.tool', 'over_streamable.tool', 'discovered.tool', 'dressed_root.tool', 'unread_tools.tool']) {
281
+ expect(reasonFor(file)).toContain('`call_template_type: http`');
282
+ }
283
+ // The one place it works is left alone.
284
+ expect(reasonFor('over_http.tool')).toBe('');
285
+ expect(catalog.tools.map((m) => m.name)).toContain('overhttp');
286
+ });
287
+
288
+ test('refuses a `.tool` that holds a service-account key itself, wherever the block sits, and never repeats it', async () => {
289
+ // A `.tool` is committed and read by everyone who can read the knowledge
290
+ // base, so the key lives in the vault and the file only names it.
291
+ const key = JSON.stringify({ type: 'service_account', client_email: 'ads@proj.iam.gserviceaccount.com', private_key: 'MIIEvQIBADANBgkq-NOT-A-REAL-KEY' });
292
+ const withCredentials = (credentials: unknown, callTemplateType = 'http') =>
293
+ JSON.stringify({
294
+ type: 'inline',
295
+ tools: [
296
+ {
297
+ name: 'list',
298
+ description: 'List.',
299
+ inputs: { type: 'object', properties: {} },
300
+ outputs: { type: 'object', properties: {} },
301
+ tool_call_template: {
302
+ call_template_type: callTemplateType,
303
+ http_method: 'GET',
304
+ url: 'https://googleads.googleapis.com/v22/customers',
305
+ auth: { auth_type: 'google_service_account', credentials, scopes: 'https://www.googleapis.com/auth/adwords' },
306
+ },
307
+ },
308
+ ],
309
+ });
310
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
311
+ await writeFile(join(plugins, 'pasted_key.tool'), withCredentials(key));
312
+ await writeFile(join(plugins, 'pasted_base64.tool'), withCredentials(Buffer.from(key).toString('base64')));
313
+ // A variable with the key, or anything else, written around it is still text in the file.
314
+ await writeFile(join(plugins, 'beside_a_variable.tool'), withCredentials('${GOOGLE_SA_KEY}' + key));
315
+ // In a block nothing acts on, the key is just as readable: that it is there is said first.
316
+ await writeFile(join(plugins, 'key_over_sse.tool'), withCredentials(key, 'sse'));
317
+ await writeFile(join(plugins, 'braced.tool'), withCredentials('${GOOGLE_SA_KEY}'));
318
+ await writeFile(join(plugins, 'bare.tool'), withCredentials(' $GOOGLE_SA_KEY '));
319
+
320
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
321
+
322
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
323
+ for (const file of ['pasted_key.tool', 'pasted_base64.tool', 'beside_a_variable.tool', 'key_over_sse.tool']) {
324
+ expect(reasonFor(file), file).toContain('something other than a vault variable as its `credentials`');
325
+ expect(reasonFor(file), file).toContain('Secrets Vault');
326
+ }
327
+ // Nothing of what was pasted comes back in the answer, in either spelling.
328
+ const answer = JSON.stringify(catalog);
329
+ expect(answer).not.toMatch(/NOT-A-REAL-KEY|iam\.gserviceaccount/);
330
+ expect(answer).not.toContain(Buffer.from(key).toString('base64').slice(0, 40));
331
+ // Either spelling of one variable is what the field is for.
332
+ expect(reasonFor('braced.tool')).toBe('');
333
+ expect(reasonFor('bare.tool')).toBe('');
334
+ expect(catalog.tools.map((m) => m.name)).toEqual(expect.arrayContaining(['braced', 'bare']));
335
+ });
336
+
186
337
  test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
187
338
  root = await mkdtemp(join(tmpdir(), 'tools2-'));
188
339
  const tools = join(root, wsId, KB_DIR, 'Plugins');
@@ -16,6 +16,9 @@ import {
16
16
  DefaultVariableSubstitutor,
17
17
  type CallTemplate,
18
18
  } from '@utcp/sdk';
19
+ // Also a side effect: loading the package registers the 'google_service_account'
20
+ // auth type, so a `.tool` naming it validates here.
21
+ import { findUnservedGoogleServiceAccountAuth, holdsLiteralGoogleServiceAccountKey } from '@bevel-software/platform-mcp-core';
19
22
  import { descriptorsFromMcpJson } from './mcp-json-discovery.js';
20
23
  import type { PluginSource } from '../plugins/discovery/plugin-source.js';
21
24
  import type { WorkspaceService } from '../workspace/workspace.service.js';
@@ -1196,6 +1199,40 @@ export function normalizeToolManual(
1196
1199
  descriptor.remote = false;
1197
1200
  }
1198
1201
 
1202
+ // A service account's KEY IS NEVER WRITTEN IN A `.tool`. The file is
1203
+ // knowledge-base content: committed, and read by everyone and every agent
1204
+ // that can read the knowledge base. So `credentials` names the vault
1205
+ // variable and nothing else, and a file that holds anything else there is
1206
+ // refused before the block's placement is even looked at: a key in a block
1207
+ // nothing acts on is just as readable. The refusal does not quote the value.
1208
+ if (holdsLiteralGoogleServiceAccountKey(obj)) {
1209
+ throw new Error(
1210
+ '`auth_type: google_service_account` has something other than a vault variable as its `credentials`. ' +
1211
+ 'Write `credentials: ${GOOGLE_SA_KEY}` and store the key in the Secrets Vault under that name: a `.tool` is ' +
1212
+ 'read by everyone who can read the knowledge base. If a real key was saved in this file, treat it as ' +
1213
+ 'exposed and replace it at Google; removing it here does not take it out of the history.',
1214
+ );
1215
+ }
1216
+
1217
+ // A Google service-account `auth` block works on one thing: an inline tool's
1218
+ // `http` call template. Anywhere else it validates and is then sent as no
1219
+ // credentials at all, so the tool would save cleanly and call Google
1220
+ // unauthenticated, with Google's 401 the only hint. Refused here instead,
1221
+ // naming where the block was found. The templates below are the only ones
1222
+ // this file's tools are called through: a `tools` list on a file that is not
1223
+ // `inline` is never read, so nothing in it counts.
1224
+ const toolCallTemplates =
1225
+ type === 'inline' && Array.isArray(obj.tools)
1226
+ ? obj.tools.map((tool: unknown) => (tool && typeof tool === 'object' ? (tool as Record<string, unknown>).tool_call_template : undefined))
1227
+ : [];
1228
+ const unserved = findUnservedGoogleServiceAccountAuth(obj, toolCallTemplates);
1229
+ if (unserved) {
1230
+ throw new Error(
1231
+ `\`auth_type: google_service_account\` is on ${unserved}, where no token would be sent. ` +
1232
+ "It works only as the `auth` of an inline tool's `tool_call_template` with `call_template_type: http`.",
1233
+ );
1234
+ }
1235
+
1199
1236
  if (type === 'inline') {
1200
1237
  const tools = Array.isArray(obj.tools) ? obj.tools : undefined;
1201
1238
  if (!tools) throw new Error('inline `.tool` must have a `tools` array');
@@ -183,6 +183,25 @@ async function start(
183
183
  await check?.();
184
184
  return plainWriteFile(path, content, options as never);
185
185
  };
186
+ // `LockingFilesystem.rewriteFile`: read, compute and write with the path
187
+ // "locked". The other writer runs first — where the real filesystem would be
188
+ // acquiring the lock — so `rewrite` reads what that writer left.
189
+ (fs as unknown as Record<string, unknown>).rewriteFile = async (
190
+ path: string,
191
+ rewrite: (current: Buffer | null) => string | Promise<string>,
192
+ ) => {
193
+ await runRaceHook();
194
+ // Absent is null; any other read failure is a failure, as in the real one.
195
+ const current = await fs.readFile(path).then(
196
+ (c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c), 'utf8')),
197
+ (err: unknown) => {
198
+ const code = (err as NodeJS.ErrnoException).code;
199
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
200
+ throw err;
201
+ },
202
+ );
203
+ return plainWriteFile(path, await rewrite(current));
204
+ };
186
205
  (fs as unknown as Record<string, unknown>).writeFiles = async (
187
206
  writes: { path: string; content: string }[],
188
207
  _summary: string,
@@ -363,6 +382,98 @@ describe('workspace file primitives', () => {
363
382
  expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
364
383
  });
365
384
 
385
+ it('edit_file writes new_string exactly as sent, `$` patterns included', async () => {
386
+ const base = await start();
387
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: "cost: $& $1 $' $$" });
388
+ expect(res.status).toBe(200);
389
+ expect(String(await fs.readFile(`${KB_DIR}/a.md`))).toBe("hello\ncost: $& $1 $' $$\n");
390
+ });
391
+
392
+ /**
393
+ * An edit is a promise about the text it replaces, so that text has to be
394
+ * found in the file as it is when the write lands — read with the path's
395
+ * lock held, not at a preflight anybody may invalidate. `raceHook` is the
396
+ * other writer, running exactly where the real filesystem acquires the lock.
397
+ */
398
+ describe('edit_file looks for old_string in the file the write actually lands on', () => {
399
+ const EMPTY_OWNER = '# Assignee\n\n# Log';
400
+ const claim = (base: string, who: string) =>
401
+ post(`${base}/api/agent/tools/edit_file`, {
402
+ path: `${KB_DIR}/ticket.md`,
403
+ old_string: EMPTY_OWNER,
404
+ new_string: `# Assignee\n${who}\n\n# Log`,
405
+ });
406
+
407
+ it('refuses when another writer replaced that text after the preflight, and keeps their write', async () => {
408
+ const base = await start();
409
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
410
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\ncoder1\n\n# Log\n- filed\n'); };
411
+ const res = await claim(base, 'coder2');
412
+ expect(res.status).toBe(400);
413
+ expect(((await res.json()) as { error: string }).error).toContain('old_string not found');
414
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder1\n\n# Log\n- filed\n');
415
+ });
416
+
417
+ it('applies the edit to what the other writer left when the text is still there', async () => {
418
+ const base = await start();
419
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
420
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n- a line added meanwhile\n'); };
421
+ const res = await claim(base, 'coder2');
422
+ expect(res.status).toBe(200);
423
+ // Their line is kept: the new content was computed from the file as it was under the lock.
424
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder2\n\n# Log\n- filed\n- a line added meanwhile\n');
425
+ });
426
+
427
+ it('refuses when the text became ambiguous after the preflight', async () => {
428
+ const base = await start();
429
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
430
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, `${EMPTY_OWNER}\n${EMPTY_OWNER}\n`); };
431
+ const res = await claim(base, 'coder2');
432
+ expect(res.status).toBe(400);
433
+ expect(((await res.json()) as { error: string }).error).toContain('appears 2 times');
434
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe(`${EMPTY_OWNER}\n${EMPTY_OWNER}\n`);
435
+ });
436
+
437
+ it('answers not found when the file was deleted after the preflight, and does not recreate it', async () => {
438
+ const base = await start();
439
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
440
+ raceHook = async () => { await fs.deleteFile(`${KB_DIR}/ticket.md`); };
441
+ const res = await claim(base, 'coder2');
442
+ expect(res.status).toBe(404);
443
+ await expect(fs.readFile(`${KB_DIR}/ticket.md`)).rejects.toThrow();
444
+ });
445
+
446
+ // Every question the tool asks of the file is asked of the bytes it is
447
+ // about to replace. An extensionless file may hold anything, and the app's
448
+ // own upload can swap it for a binary between the preflight and the lock.
449
+ // The binary still CONTAINS `old_string` here, so nothing but the
450
+ // "may these bytes be edited as text" question can refuse it.
451
+ it('refuses when the file became binary after the preflight, and leaves those bytes alone', async () => {
452
+ const base = await start();
453
+ await fs.writeFile(`${KB_DIR}/TICKET`, '# Assignee\n\n# Log\n- filed\n');
454
+ const binary = Buffer.concat([Buffer.from([0x00, 0xff, 0xfe, 0x00]), Buffer.from(`${EMPTY_OWNER}\n`, 'utf8')]);
455
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/TICKET`, binary); };
456
+ const res = await post(`${base}/api/agent/tools/edit_file`, {
457
+ path: `${KB_DIR}/TICKET`,
458
+ old_string: EMPTY_OWNER,
459
+ new_string: '# Assignee\ncoder2\n\n# Log',
460
+ });
461
+ expect(res.status).toBe(415);
462
+ expect(await res.json()).toMatchObject({ kind: 'binary_not_writable' });
463
+ const after = await fs.readFile(`${KB_DIR}/TICKET`);
464
+ expect(Buffer.from(after as Buffer).equals(binary)).toBe(true);
465
+ });
466
+
467
+ it('counts replace_all over the file as it is under the lock', async () => {
468
+ const base = await start();
469
+ await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x\n');
470
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x x\n'); };
471
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/ticket.md`, old_string: 'x', new_string: 'y', replace_all: true });
472
+ expect(await res.json()).toMatchObject({ replaced: 3 });
473
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('y y y\n');
474
+ });
475
+ });
476
+
366
477
  it('list_files + file_stat', async () => {
367
478
  const base = await start();
368
479
  const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
@@ -3445,7 +3556,7 @@ describe('a write refused for permissions says whether and how to propose it', (
3445
3556
  throw err;
3446
3557
  };
3447
3558
  const target = fs as unknown as Record<string, unknown>;
3448
- for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3559
+ for (const m of ['writeFile', 'rewriteFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3449
3560
  target.copyFile = async (_src: string, dest: string) => refuse(dest);
3450
3561
  // `delete_folder` lands through the same batch with NO writes and the
3451
3562
  // folder's files as `deletes` — in production the refusal comes from the
@@ -10,7 +10,7 @@ import { KbStartupRunner } from '../kb-startup-runner.js';
10
10
  import { WorkspaceService } from '../../workspace.service.js';
11
11
  import { NodeGitRunner } from '../../../workflow/git/node-git-runner.js';
12
12
  import { ClassifiedFailure, classifyGitFailure, failureOf } from '../../../../shared/git-failure.js';
13
- import { gitCredentials } from '../../../../shared/git.contract.js';
13
+ import { GitRunError, gitCredentials } from '../../../../shared/git.contract.js';
14
14
  import type { OnServerStart, ServerStartContext, StepResult } from '../on-server-start.js';
15
15
 
16
16
  /**
@@ -85,6 +85,8 @@ function runnerOpts(steps: OnServerStart[], overrides: Record<string, unknown> =
85
85
  defaultBranch: () => DEFAULT_BRANCH,
86
86
  protectedBranches: () => PROTECTED,
87
87
  seedAdminEmails: ['admin@example.com'],
88
+ // An unfinished clone made by a suite is abandoned the moment it exists.
89
+ unfinishedCloneQuietMs: 0,
88
90
  steps,
89
91
  buildSeedTree: async (dir: string) => {
90
92
  await fs.writeFile(path.join(dir, 'seeded.md'), 'from template', 'utf8');
@@ -784,6 +786,234 @@ describe('KbStartupRunner', () => {
784
786
  });
785
787
  });
786
788
 
789
+ /**
790
+ * A clone that was cut short leaves a `.git` folder and no commit. Every
791
+ * start found it again, could do nothing with a branch that has no commit,
792
+ * and stopped: one such folder kept a whole deployment from starting.
793
+ */
794
+ describe('KbStartupRunner — a working copy whose clone was never finished', () => {
795
+ const touchDefault = step('touch', async (ctx) => {
796
+ await (await ctx.defaultBranch()).repoDir();
797
+ return { outcome: 'ok' };
798
+ });
799
+ const local = () => path.join(workspacesRoot, DEFAULT_BRANCH, 'knowledge-base');
800
+
801
+ /** What an interrupted clone leaves: the repository made, its remote set, nothing checked out. */
802
+ async function halfMadeClone(): Promise<void> {
803
+ await fs.mkdir(local(), { recursive: true });
804
+ await git(local(), ['init', '-b', DEFAULT_BRANCH]);
805
+ await git(local(), ['remote', 'add', 'origin', upstream]);
806
+ await fs.writeFile(path.join(local(), 'left-here.txt'), 'somebody put this here', 'utf8');
807
+ }
808
+
809
+ it('is set aside, files and all, and cloned again, so the start goes through', async () => {
810
+ await populatedUpstream();
811
+ await halfMadeClone();
812
+ const discarded: string[] = [];
813
+
814
+ await makeRunner([touchDefault], { onCloneDiscarded: (id: string) => void discarded.push(id) }).runAll();
815
+
816
+ // A real clone stands in its place.
817
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
818
+ expect((await git(local(), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()).toBe(DEFAULT_BRANCH);
819
+ // Nothing was deleted: what was in the folder is where set-aside copies go.
820
+ const runs = await fs.readdir(path.join(root, 'set-aside'));
821
+ expect(runs).toHaveLength(1);
822
+ const kept = path.join(root, 'set-aside', runs[0]!, DEFAULT_BRANCH);
823
+ expect(await fs.readFile(path.join(kept, 'left-here.txt'), 'utf8')).toBe('somebody put this here');
824
+ expect(discarded).toEqual([DEFAULT_BRANCH]);
825
+
826
+ // And the next start finds an ordinary clone: nothing more is set aside.
827
+ await makeRunner([touchDefault]).runAll();
828
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
829
+ });
830
+
831
+ it('leaves a working copy that has a commit exactly where it is', async () => {
832
+ await populatedUpstream();
833
+ await makeRunner([touchDefault]).runAll();
834
+ await fs.writeFile(path.join(local(), 'unpushed.md'), 'precious', 'utf8');
835
+ await git(local(), ['add', '-A']);
836
+ await git(local(), ['commit', '-m', 'committed but unpushed']);
837
+
838
+ await makeRunner([touchDefault]).runAll();
839
+
840
+ expect(await fs.readFile(path.join(local(), 'unpushed.md'), 'utf8')).toBe('precious');
841
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
842
+ });
843
+
844
+ it('leaves a working copy alone when git could not say whether it has a commit', async () => {
845
+ await populatedUpstream();
846
+ await makeRunner([touchDefault]).runAll();
847
+ await fs.writeFile(path.join(local(), 'unpushed.md'), 'precious', 'utf8');
848
+ await git(local(), ['add', '-A']);
849
+ await git(local(), ['commit', '-m', 'committed but unpushed']);
850
+ // Git gives no answer to the one question: the deadline passes, as it
851
+ // does on a host under load. Everything else runs for real.
852
+ const real = new NodeGitRunner(undefined, credentials);
853
+ const gitRunner = {
854
+ defaultTimeoutMs: real.defaultTimeoutMs,
855
+ credentials: real.credentials,
856
+ run: (cwd: string, args: string[], opts?: object) =>
857
+ args.includes('--verify')
858
+ ? Promise.reject(new GitRunError('git rev-parse timed out', { timedOut: true }))
859
+ : real.run(cwd, args, opts),
860
+ };
861
+
862
+ const err = await makeRunner([touchDefault], { gitRunner })
863
+ .runAll()
864
+ .then(
865
+ () => null,
866
+ (e: unknown) => e as Error,
867
+ );
868
+
869
+ // "Could not tell" is not "has no commit": the start stops, the branch is
870
+ // named, and the work is where it was.
871
+ expect(err?.message).toContain(`the working copy of branch "${DEFAULT_BRANCH}" could not be prepared`);
872
+ expect(await fs.readFile(path.join(local(), 'unpushed.md'), 'utf8')).toBe('precious');
873
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
874
+ });
875
+
876
+ // On a redeploy two processes share the volume for a few seconds. What the
877
+ // other one did while this one waited is what decides.
878
+ it('does not move a clone another process put in its place while this one waited', async () => {
879
+ await populatedUpstream();
880
+ await halfMadeClone();
881
+ const beforeCloneSetAside = async () => {
882
+ await fs.rm(local(), { recursive: true, force: true });
883
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, local()]);
884
+ await fs.writeFile(path.join(local(), 'theirs.txt'), 'the other process wrote this', 'utf8');
885
+ };
886
+
887
+ await makeRunner([touchDefault], { beforeCloneSetAside }).runAll();
888
+
889
+ expect(await fs.readFile(path.join(local(), 'theirs.txt'), 'utf8')).toBe('the other process wrote this');
890
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
891
+ });
892
+
893
+ it('clones when another process moved the half-made clone away while this one waited', async () => {
894
+ await populatedUpstream();
895
+ await halfMadeClone();
896
+ const beforeCloneSetAside = () => fs.rm(local(), { recursive: true, force: true });
897
+
898
+ await makeRunner([touchDefault], { beforeCloneSetAside }).runAll();
899
+
900
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
901
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
902
+ });
903
+
904
+ it('carries on when the half-made clone is taken away in the instant before the move', async () => {
905
+ await populatedUpstream();
906
+ await halfMadeClone();
907
+ // The folder goes right after git has answered, for the last time before
908
+ // the move, that it holds no commit.
909
+ const real = new NodeGitRunner(undefined, credentials);
910
+ let asked = 0;
911
+ const gitRunner = {
912
+ defaultTimeoutMs: real.defaultTimeoutMs,
913
+ credentials: real.credentials,
914
+ run: async (cwd: string, args: string[], opts?: object) => {
915
+ if (!args.includes('--verify') || ++asked !== 3) return real.run(cwd, args, opts);
916
+ try {
917
+ return await real.run(cwd, args, opts);
918
+ } finally {
919
+ await fs.rm(local(), { recursive: true, force: true });
920
+ }
921
+ },
922
+ };
923
+ const discarded: string[] = [];
924
+
925
+ await makeRunner([touchDefault], { gitRunner, onCloneDiscarded: (id: string) => void discarded.push(id) }).runAll();
926
+
927
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
928
+ // This start moved nothing, so it says nothing was moved.
929
+ expect(discarded).toEqual([]);
930
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toEqual([]);
931
+ });
932
+
933
+ it('tells the listener a second time when the first telling fails', async () => {
934
+ await populatedUpstream();
935
+ await halfMadeClone();
936
+ let told = 0;
937
+ const onCloneDiscarded = async () => {
938
+ if (++told === 1) throw new Error('the queue could not be reached');
939
+ };
940
+
941
+ await makeRunner([touchDefault], { onCloneDiscarded }).runAll();
942
+
943
+ expect(told).toBe(2);
944
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
945
+ });
946
+
947
+ it('does not remove a clone somebody started at the path once it was announced empty', async () => {
948
+ await populatedUpstream();
949
+ await halfMadeClone();
950
+ // Told the copy is gone, the rest of the process may clone there at once.
951
+ const onCloneDiscarded = async () => {
952
+ await git(root, ['clone', '-b', DEFAULT_BRANCH, upstream, local()]);
953
+ await fs.writeFile(path.join(local(), 'theirs.txt'), 'a branch being opened wrote this', 'utf8');
954
+ };
955
+
956
+ await makeRunner([touchDefault], { onCloneDiscarded }).runAll();
957
+
958
+ expect(await fs.readFile(path.join(local(), 'theirs.txt'), 'utf8')).toBe('a branch being opened wrote this');
959
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
960
+ });
961
+
962
+ it('leaves a clone that is still being written, and uses it once it is finished', async () => {
963
+ await populatedUpstream();
964
+ await halfMadeClone();
965
+ // Somebody is still cloning: it finishes while this start waits for quiet.
966
+ const finishing = (async () => {
967
+ await new Promise((resolve) => setTimeout(resolve, 300));
968
+ await git(local(), ['fetch', 'origin']);
969
+ await git(local(), ['checkout', DEFAULT_BRANCH]);
970
+ })();
971
+
972
+ await makeRunner([touchDefault], { unfinishedCloneQuietMs: 5_000 }).runAll();
973
+ await finishing;
974
+
975
+ // Theirs, where it was, with what was in it: nothing was set aside.
976
+ expect(await fs.readFile(path.join(local(), 'left-here.txt'), 'utf8')).toBe('somebody put this here');
977
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
978
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
979
+ });
980
+
981
+ it('sets an unfinished clone aside only once nothing has written to it for the quiet time', async () => {
982
+ await populatedUpstream();
983
+ await halfMadeClone();
984
+ const started = Date.now();
985
+
986
+ await makeRunner([touchDefault], { unfinishedCloneQuietMs: 1_500 }).runAll();
987
+
988
+ expect(Date.now() - started).toBeGreaterThanOrEqual(1_350);
989
+ expect(await fs.readFile(path.join(local(), 'marker.txt'), 'utf8')).toBe('seeded');
990
+ expect(await fs.readdir(path.join(root, 'set-aside'))).toHaveLength(1);
991
+ });
992
+
993
+ it('names the branch when a working copy cannot be prepared', async () => {
994
+ await populatedUpstream();
995
+ await makeRunner([touchDefault]).runAll();
996
+ // The clone is sound and has a commit, but git cannot read the branch it
997
+ // is to follow: the ref the fetch updates is damaged. Not a state this
998
+ // phase repairs, so the start stops, and says which working copy.
999
+ const refs = path.join(local(), '.git', 'refs', 'remotes', 'origin');
1000
+ await fs.rm(path.join(local(), '.git', 'packed-refs'), { force: true });
1001
+ await fs.mkdir(refs, { recursive: true });
1002
+ await fs.writeFile(path.join(refs, DEFAULT_BRANCH), 'not a commit id\n', 'utf8');
1003
+
1004
+ const err = await makeRunner([touchDefault])
1005
+ .runAll()
1006
+ .then(
1007
+ () => null,
1008
+ (e: unknown) => e as Error,
1009
+ );
1010
+ expect(err).toBeInstanceOf(ClassifiedFailure);
1011
+ expect(err?.message).toContain(`the working copy of branch "${DEFAULT_BRANCH}" could not be prepared`);
1012
+ // Nothing was set aside for it: it has a commit, so it is somebody's work.
1013
+ expect(await fs.access(path.join(root, 'set-aside')).then(() => true, () => false)).toBe(false);
1014
+ });
1015
+ });
1016
+
787
1017
  describe('KbStartupRunner — what a failed phase throws', () => {
788
1018
  it('a scrubbed message (tokens, a presigned query) that still carries what git said', async () => {
789
1019
  const prev = process.env.GITHUB_TOKEN;