@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
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type GitFailure,
|
|
19
19
|
} from '../../../shared/git-failure.js';
|
|
20
20
|
import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
|
|
21
|
+
import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The KB startup phase: run every registered {@link OnServerStart} step, in
|
|
@@ -51,6 +52,16 @@ export interface KbStartupRunnerOptions {
|
|
|
51
52
|
gitRunner: IGitRunner;
|
|
52
53
|
kbRepoUrl: () => string;
|
|
53
54
|
workspacesRoot: string;
|
|
55
|
+
/**
|
|
56
|
+
* Where a working copy of a repository that is no longer the configured
|
|
57
|
+
* one is moved to, under a folder named for the moment it happened — see
|
|
58
|
+
* `reconcileClonesWithConfiguredRepository`. NOT under `workspacesRoot`:
|
|
59
|
+
* the workspace service's orphan sweep removes every folder there that is
|
|
60
|
+
* not a known branch's, and what is set aside has to outlive that sweep.
|
|
61
|
+
* The composition root names a folder under the backups root, which is a
|
|
62
|
+
* persistent volume of its own. Default: a sibling of `workspacesRoot`.
|
|
63
|
+
*/
|
|
64
|
+
setAsideRoot?: string;
|
|
54
65
|
kbDirName: string;
|
|
55
66
|
templateDir: string;
|
|
56
67
|
defaultBranch: () => string;
|
|
@@ -301,6 +312,10 @@ export class KbStartupRunner {
|
|
|
301
312
|
);
|
|
302
313
|
}
|
|
303
314
|
const heads = await this.ensureRemote();
|
|
315
|
+
// AFTER the remote answered: the configured address is known to be a
|
|
316
|
+
// repository we can reach, so a clone of a different one is stale, not
|
|
317
|
+
// a casualty of a typo in the address.
|
|
318
|
+
await this.reconcileClonesWithConfiguredRepository();
|
|
304
319
|
const ctx = this.buildContext(heads, handles);
|
|
305
320
|
|
|
306
321
|
for (const step of this.opts.steps) {
|
|
@@ -501,6 +516,150 @@ export class KbStartupRunner {
|
|
|
501
516
|
};
|
|
502
517
|
}
|
|
503
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Bring every working copy on disk into line with the configured
|
|
521
|
+
* repository, WITHOUT DELETING ANYTHING.
|
|
522
|
+
*
|
|
523
|
+
* A clone fetches and pushes through the address stored in its own
|
|
524
|
+
* `remote.origin.url`, which nothing updates when the configured address
|
|
525
|
+
* changes. After an operator replaced the repository (2026-09-28: the old
|
|
526
|
+
* one deleted, a new one saved on the setup screen) every surviving clone
|
|
527
|
+
* kept asking the old address: Save and Retry failed, and the restart
|
|
528
|
+
* stopped the boot on `repository … not found`, taking the setup screen
|
|
529
|
+
* with it.
|
|
530
|
+
*
|
|
531
|
+
* A clone whose address differs from the configured one is one of two
|
|
532
|
+
* things, and the histories say which:
|
|
533
|
+
*
|
|
534
|
+
* - THE SAME REPOSITORY AT A NEW ADDRESS — moved, renamed, mirrored. The
|
|
535
|
+
* one change the setup screen tells an admin to make ("only change this
|
|
536
|
+
* if the same repository was moved or renamed"). Its history is on the
|
|
537
|
+
* configured remote, so the clone is pointed at the new address and
|
|
538
|
+
* kept, unpushed commits included: they are pushed where they belonged
|
|
539
|
+
* all along.
|
|
540
|
+
* - ANOTHER REPOSITORY. Re-pointing it would push one repository's
|
|
541
|
+
* commits into the other, so it cannot stay where the workspace service
|
|
542
|
+
* would adopt it. It is MOVED to {@link KbStartupRunnerOptions.setAsideRoot}
|
|
543
|
+
* and the log says where. Never removed: when the old repository is gone
|
|
544
|
+
* — as it was that day — these clones are the last copy of the
|
|
545
|
+
* knowledge base that exists, and the unpushed work in them exists
|
|
546
|
+
* nowhere else at all.
|
|
547
|
+
*
|
|
548
|
+
* Every clone under the workspaces root, not only the branches this phase
|
|
549
|
+
* touches: the workspace service adopts whatever it finds there. A clone
|
|
550
|
+
* whose address cannot be read is left where it is — "could not tell" is
|
|
551
|
+
* no reason to move someone's work. One that cannot be set aside stops the
|
|
552
|
+
* boot with the reason: leaving it would fail the boot a step later with
|
|
553
|
+
* git's words about a repository nobody configured.
|
|
554
|
+
*/
|
|
555
|
+
private async reconcileClonesWithConfiguredRepository(): Promise<void> {
|
|
556
|
+
const configured = this.opts.kbRepoUrl();
|
|
557
|
+
let entries: import('node:fs').Dirent[];
|
|
558
|
+
try {
|
|
559
|
+
entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
|
|
560
|
+
} catch {
|
|
561
|
+
return; // no workspaces yet
|
|
562
|
+
}
|
|
563
|
+
// One folder per run, so what was set aside together stays together.
|
|
564
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
565
|
+
const setAsideRoot =
|
|
566
|
+
this.opts.setAsideRoot ?? path.resolve(this.opts.workspacesRoot, '..', 'replaced-working-copies');
|
|
567
|
+
for (const entry of entries) {
|
|
568
|
+
if (!entry.isDirectory()) continue;
|
|
569
|
+
const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
|
|
570
|
+
const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
|
|
571
|
+
if (!hasGit) continue;
|
|
572
|
+
let origin: string;
|
|
573
|
+
try {
|
|
574
|
+
origin = (await git(this.opts.gitRunner, repoDir, ['config', '--get', 'remote.origin.url'])).trim();
|
|
575
|
+
} catch {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (origin === '' || sameRepository(origin, configured)) continue;
|
|
579
|
+
// The normalized form carries no userinfo, and the scrub covers the rest.
|
|
580
|
+
const was = this.redact(normalizeRepositoryAddress(origin));
|
|
581
|
+
if (await this.sharesHistoryWithConfigured(repoDir, configured)) {
|
|
582
|
+
await git(this.opts.gitRunner, repoDir, ['remote', 'set-url', 'origin', configured]);
|
|
583
|
+
startupLog.warn(
|
|
584
|
+
`working copy "${entry.name}" was cloned from ${was}, whose history the configured repository holds: ` +
|
|
585
|
+
'the same repository at a new address. Pointed at the configured address and kept, unpushed work included.',
|
|
586
|
+
);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const kept = path.join(setAsideRoot, stamp, entry.name);
|
|
590
|
+
await this.setAside(repoDir, kept);
|
|
591
|
+
startupLog.warn(
|
|
592
|
+
`working copy "${entry.name}" is a clone of another repository (${was}). Set aside at ${kept}; nothing was ` +
|
|
593
|
+
'deleted, and it will be cloned fresh from the configured one. Work that was never pushed is in that ' +
|
|
594
|
+
'folder: `git log` there shows it, and `git push <address> <branch>` from there sends it to a repository.',
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Whether the configured repository holds history this clone has too —
|
|
601
|
+
* asked of git, not guessed from the two addresses. The configured
|
|
602
|
+
* remote's branches are fetched under a namespace of their own and the
|
|
603
|
+
* clone's HEAD is tested for a common ancestor with any of them.
|
|
604
|
+
*
|
|
605
|
+
* "Could not tell" answers false: a fetch that fails, a clone with no
|
|
606
|
+
* commit to compare. False sets the clone aside, which loses nothing, where
|
|
607
|
+
* a wrong true would push a stranger's commits into the configured
|
|
608
|
+
* repository.
|
|
609
|
+
*/
|
|
610
|
+
private async sharesHistoryWithConfigured(repoDir: string, configured: string): Promise<boolean> {
|
|
611
|
+
const probe = 'refs/hexis-probe';
|
|
612
|
+
let theirs: string[] = [];
|
|
613
|
+
try {
|
|
614
|
+
await git(this.opts.gitRunner, repoDir, ['fetch', '--no-tags', '--quiet', configured, `+refs/heads/*:${probe}/*`]);
|
|
615
|
+
theirs = (await git(this.opts.gitRunner, repoDir, ['for-each-ref', '--format=%(refname)', `${probe}/`]))
|
|
616
|
+
.split('\n')
|
|
617
|
+
.map((ref) => ref.trim())
|
|
618
|
+
.filter(Boolean);
|
|
619
|
+
if (theirs.length === 0) return false;
|
|
620
|
+
// One question for all of them: a common ancestor of HEAD and ANY of
|
|
621
|
+
// their branches is a common ancestor of HEAD and their merge. Capped,
|
|
622
|
+
// so a remote with thousands of branches cannot outgrow a command line.
|
|
623
|
+
await git(this.opts.gitRunner, repoDir, ['merge-base', 'HEAD', ...theirs.slice(0, 200)]);
|
|
624
|
+
return true;
|
|
625
|
+
} catch {
|
|
626
|
+
return false;
|
|
627
|
+
} finally {
|
|
628
|
+
// The probe leaves nothing behind in a clone that stays in use.
|
|
629
|
+
for (const ref of theirs) {
|
|
630
|
+
await git(this.opts.gitRunner, repoDir, ['update-ref', '-d', ref]).catch(() => undefined);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Move a working copy out of the workspaces root, whole. A rename where
|
|
637
|
+
* the two folders share a volume; a copy and then a removal where they do
|
|
638
|
+
* not (the backups root is a volume of its own in the shipped compose
|
|
639
|
+
* files), with the original removed only once the copy is complete.
|
|
640
|
+
*/
|
|
641
|
+
private async setAside(repoDir: string, dest: string): Promise<void> {
|
|
642
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
643
|
+
try {
|
|
644
|
+
await fs.rename(repoDir, dest);
|
|
645
|
+
return;
|
|
646
|
+
} catch {
|
|
647
|
+
// Another volume, or a handle held open on it: copy instead.
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
await fs.cp(repoDir, dest, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true });
|
|
651
|
+
} catch (err) {
|
|
652
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
|
|
653
|
+
throw new Error(
|
|
654
|
+
`Could not set aside the working copy at ${repoDir}, a clone of a repository that is no longer the ` +
|
|
655
|
+
`configured one, into ${dest}: ${err instanceof Error ? err.message : String(err)}. Nothing was deleted. ` +
|
|
656
|
+
'Make room there, or move that folder away by hand, and start again.',
|
|
657
|
+
{ cause: err },
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
661
|
+
}
|
|
662
|
+
|
|
504
663
|
/**
|
|
505
664
|
* The branch's working copy at the runtime layout
|
|
506
665
|
* (`<workspacesRoot>/<id>/<kbDirName>`), so the workspace service finds it
|
|
@@ -649,15 +649,31 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
649
649
|
}
|
|
650
650
|
this.inFlightBootstraps.set(branch, bootstrap);
|
|
651
651
|
|
|
652
|
+
// `mkdir --recursive` answers with the first path it CREATED, or undefined
|
|
653
|
+
// when the directory was already there. That is the only honest signal for
|
|
654
|
+
// what the rollback below may remove: a directory this attempt made is ours
|
|
655
|
+
// to take back, one that predates us belongs to whatever put it there.
|
|
656
|
+
let createdWorkspaceDir: string | undefined;
|
|
652
657
|
try {
|
|
653
658
|
// A clone with `-b <branch>` against a never-seeded remote fails
|
|
654
659
|
// naturally; seeding the remote is the KB startup phase's job, at boot.
|
|
655
|
-
await fs.mkdir(workspaceDir, { recursive: true });
|
|
660
|
+
createdWorkspaceDir = await fs.mkdir(workspaceDir, { recursive: true });
|
|
656
661
|
await this.cloneProcessMapForBranch(workspaceDir, branch);
|
|
657
662
|
this.registerBranchDir(branch, workspaceDir);
|
|
658
663
|
resolveBootstrap();
|
|
659
664
|
} catch (err) {
|
|
660
665
|
this.branchDirs.delete(branch);
|
|
666
|
+
// A bootstrap that failed leaves NOTHING behind. The clone rolls its own
|
|
667
|
+
// target back, but the workspace directory around it would survive as an
|
|
668
|
+
// empty shell named after the branch — and a directory named after a
|
|
669
|
+
// branch is read as the platform having known that branch: by
|
|
670
|
+
// `hasHeardOfBranch`, which would turn the next attempt's honest 404 into
|
|
671
|
+
// "no longer exists on the remote" (410), and by anyone reading the
|
|
672
|
+
// workspaces root. Best-effort: a rollback that cannot delete must not
|
|
673
|
+
// replace the real failure (the clone's) with its own.
|
|
674
|
+
if (createdWorkspaceDir !== undefined) {
|
|
675
|
+
await fs.rm(workspaceDir, { recursive: true, force: true }).catch(() => {});
|
|
676
|
+
}
|
|
661
677
|
rejectBootstrap(err);
|
|
662
678
|
throw err;
|
|
663
679
|
} finally {
|
|
@@ -18,6 +18,7 @@ import type { IRoutineWritePolicy } from './routine-write-policy.js';
|
|
|
18
18
|
import type { ToolHandlerFactory } from '../tool-helpers/tool-handler.js';
|
|
19
19
|
import { requireInternalSource, requireExternalSource } from '../tool-auth/tool-auth.middleware.js';
|
|
20
20
|
import { workspaceIdForBranch } from '../../shared/workspace-id.js';
|
|
21
|
+
import { assertBranchProvided } from '../../shared/domain-errors.js';
|
|
21
22
|
// Leaf-level shared primitive (same exception `workspace.service.ts` already
|
|
22
23
|
// relies on) — not a workflow service, so this stays inside the module boundary.
|
|
23
24
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
@@ -1171,6 +1172,15 @@ export function registerWorkspaceTools(
|
|
|
1171
1172
|
proposable?: boolean;
|
|
1172
1173
|
/** False for a tool that is not a file tool (the shell), which the content rule does not describe. */
|
|
1173
1174
|
fileTool?: boolean;
|
|
1175
|
+
/**
|
|
1176
|
+
* This tool resolves `branch` ITSELF and must not be pre-checked here.
|
|
1177
|
+
* Only `execute_command` sets it: for an internal session that leaves the
|
|
1178
|
+
* argument off, it falls back to the caller's own focused branch (from its
|
|
1179
|
+
* signed token) rather than refusing. It still answers the same
|
|
1180
|
+
* `branch-required` kind when there is nothing to fall back on — see its
|
|
1181
|
+
* handler. Every other tool takes the check below.
|
|
1182
|
+
*/
|
|
1183
|
+
resolvesBranchItself?: boolean;
|
|
1174
1184
|
handler: ToolHandler;
|
|
1175
1185
|
}): void => {
|
|
1176
1186
|
const path = `/api/agent/tools/${spec.name}`;
|
|
@@ -1178,6 +1188,12 @@ export function registerWorkspaceTools(
|
|
|
1178
1188
|
// tool the one content rule, and every tool a permission can refuse the
|
|
1179
1189
|
// proposal route — appended once here so no tool (especially the
|
|
1180
1190
|
// read-only ones a session hits first) can miss them.
|
|
1191
|
+
// Whether a call to this tool MUST name a branch, read off the tool's own
|
|
1192
|
+
// declaration rather than assumed of the family. Every tool mounted here
|
|
1193
|
+
// requires `branch` today; keying on the schema means a tool that declares
|
|
1194
|
+
// it optional (and resolves absence itself, as `list_tool_setup` does on its
|
|
1195
|
+
// own route) is not handed a refusal it never asked for.
|
|
1196
|
+
const requiresBranch = ((spec.inputs as { required?: string[] }).required ?? []).includes('branch');
|
|
1181
1197
|
const describe = (): string =>
|
|
1182
1198
|
(typeof spec.description === 'function' ? spec.description() : spec.description) +
|
|
1183
1199
|
(spec.proposable ? PROPOSAL_ROUTE_NOTE : '') +
|
|
@@ -1223,6 +1239,16 @@ export function registerWorkspaceTools(
|
|
|
1223
1239
|
// never reached the repository — the whole bug, spelled with a prefix.
|
|
1224
1240
|
toolHandler(
|
|
1225
1241
|
async (args, ctx) => {
|
|
1242
|
+
// FIRST, before the path work and before any handler: every tool
|
|
1243
|
+
// mounted here declares `branch` as a required, non-empty string, and
|
|
1244
|
+
// nothing enforced that, so a call that named none was carried down
|
|
1245
|
+
// until `workspaceIdForBranch` made a workspace directory out of the
|
|
1246
|
+
// missing value. Most of these tools would meet the same refusal one
|
|
1247
|
+
// layer down at `getFilesystem`, but not all of them do — `unzip`
|
|
1248
|
+
// hands `branch` straight to the workspace service by id — so the
|
|
1249
|
+
// check belongs on the mount every one of them shares rather than on
|
|
1250
|
+
// the resolver only some of them reach.
|
|
1251
|
+
if (requiresBranch && !spec.resolvesBranchItself) assertBranchProvided(args.branch);
|
|
1226
1252
|
// BEFORE the normaliser: see `assertToolPathsNotGitInternals`.
|
|
1227
1253
|
if (spec.fileTool !== false) await assertToolPathsNotGitInternals(args, ctx);
|
|
1228
1254
|
const normalized = normalizePathArgs(
|
|
@@ -2594,6 +2620,9 @@ export function registerWorkspaceTools(
|
|
|
2594
2620
|
ONTOLOGY_BOUNDARY_NOTE,
|
|
2595
2621
|
internalOnly: true,
|
|
2596
2622
|
fileTool: false,
|
|
2623
|
+
// The one tool the mount's branch check skips: the handler below resolves an
|
|
2624
|
+
// omitted `branch` to the internal caller's focused branch before refusing.
|
|
2625
|
+
resolvesBranchItself: true,
|
|
2597
2626
|
inputs: {
|
|
2598
2627
|
type: 'object',
|
|
2599
2628
|
properties: {
|
|
@@ -2650,6 +2679,11 @@ export function registerWorkspaceTools(
|
|
|
2650
2679
|
throw new ToolError(
|
|
2651
2680
|
'execute_command requires a `branch`: pass the branch (draft) whose workspace to run the command in — the one you are currently working on.',
|
|
2652
2681
|
400,
|
|
2682
|
+
// The same discriminator every other KB tool answers a branch-less
|
|
2683
|
+
// call with, so a client switches on one kind across the surface.
|
|
2684
|
+
// The MESSAGE stays this tool's own: it can name the focused-branch
|
|
2685
|
+
// fallback that only applies here.
|
|
2686
|
+
{ kind: 'branch-required' },
|
|
2653
2687
|
);
|
|
2654
2688
|
}
|
|
2655
2689
|
// A stringified absent value. Both are syntactically valid git branch names,
|
|
@@ -2662,6 +2696,7 @@ export function registerWorkspaceTools(
|
|
|
2662
2696
|
`execute_command got the literal string "${branch}" as \`branch\` — that is a stringified absent value, not a branch. ` +
|
|
2663
2697
|
'Pass the real branch (draft) whose workspace to run the command in.',
|
|
2664
2698
|
400,
|
|
2699
|
+
{ kind: 'branch-required' },
|
|
2665
2700
|
);
|
|
2666
2701
|
}
|
|
2667
2702
|
// Then the SHAPE, via the one canonical validator every other branch path
|
|
@@ -167,6 +167,64 @@ export class PullRebaseConflictError extends WorkflowDomainError {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The one sentence a branch-less call is answered with. Names the input and
|
|
172
|
+
* what to pass, and carries NO stringified value of what was actually
|
|
173
|
+
* received: the whole point of this refusal is that the caller sent nothing,
|
|
174
|
+
* and echoing `undefined` back at them is how the branch "undefined" got
|
|
175
|
+
* invented in the first place.
|
|
176
|
+
*/
|
|
177
|
+
export const BRANCH_REQUIRED_MESSAGE =
|
|
178
|
+
'`branch` is required: pass the branch (draft) you are working on.';
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The caller named no branch — the input is missing, empty, not a string, or
|
|
182
|
+
* one of the stringified absent values (`"undefined"` / `"null"`) that a
|
|
183
|
+
* client produces by interpolating a variable it never set.
|
|
184
|
+
*
|
|
185
|
+
* Refused, never defaulted. A knowledge-base tool's workspace is NEVER implied
|
|
186
|
+
* by the credential (identity-only) — it always comes from this argument — so
|
|
187
|
+
* falling back to the deployment's default branch would make an omitted
|
|
188
|
+
* argument silently act on the protected branch. And it is refused HERE, at
|
|
189
|
+
* the boundary, because everything downstream treats the value as a real
|
|
190
|
+
* branch name: `workspaceIdForBranch` would turn it into a workspace directory
|
|
191
|
+
* literally named `undefined`, and the clone of that "branch" would fail with
|
|
192
|
+
* a story about a branch that never existed.
|
|
193
|
+
*
|
|
194
|
+
* 400 with kind `branch-required`, so a client tells it apart from the 404
|
|
195
|
+
* (no such branch) and the 410 (the branch was deleted) without reading prose.
|
|
196
|
+
*/
|
|
197
|
+
export class BranchRequiredError extends WorkflowDomainError {
|
|
198
|
+
readonly kind = 'branch-required' as const;
|
|
199
|
+
constructor() {
|
|
200
|
+
super(BRANCH_REQUIRED_MESSAGE, 400, { kind: 'branch-required' });
|
|
201
|
+
this.name = 'BranchRequiredError';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The stringified absent values. Both are syntactically valid git branch
|
|
207
|
+
* names, so the shape validator (`assertValidBranchName`) accepts them
|
|
208
|
+
* happily — accepting one is exactly the bug this guard exists for. They are
|
|
209
|
+
* refused BY NAME, which is why this check cannot be folded into the shape
|
|
210
|
+
* check however tempting that looks.
|
|
211
|
+
*/
|
|
212
|
+
const STRINGIFIED_ABSENT_VALUES = new Set(['undefined', 'null']);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Refuse a call that names no branch, before anything downstream can turn the
|
|
216
|
+
* missing value into a directory name, a clone attempt or a log line.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately NOT a shape check: a well-formed name this platform has never
|
|
219
|
+
* seen is a 404 the caller can act on, and a malformed one is a
|
|
220
|
+
* `BranchNameError` from the canonical validator. This answers only "you sent
|
|
221
|
+
* nothing", which is the one case where naming the branch back is impossible.
|
|
222
|
+
*/
|
|
223
|
+
export function assertBranchProvided(branch: unknown): asserts branch is string {
|
|
224
|
+
if (typeof branch !== 'string' || branch.length === 0) throw new BranchRequiredError();
|
|
225
|
+
if (STRINGIFIED_ABSENT_VALUES.has(branch)) throw new BranchRequiredError();
|
|
226
|
+
}
|
|
227
|
+
|
|
170
228
|
/**
|
|
171
229
|
* The platform has never heard of this branch: nothing it has cloned, and
|
|
172
230
|
* nothing any listing of origin's branches has mentioned. Distinct from
|
|
@@ -62,6 +62,16 @@ export interface GitCredentials {
|
|
|
62
62
|
username(): string;
|
|
63
63
|
/** The token in effect, or null when the deployment has none configured. */
|
|
64
64
|
token(): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Make sure {@link token} answers with a credential that is still good,
|
|
67
|
+
* for one that is not fixed: a token a host issues for an hour has to be
|
|
68
|
+
* renewed by asking the host, which `token` cannot do, being read where
|
|
69
|
+
* nothing can wait. The runner awaits this before every call it makes. A
|
|
70
|
+
* failure here is not the call's failure: git runs with the token in
|
|
71
|
+
* hand, and the host says what it thinks of it. Absent for a credential
|
|
72
|
+
* that does not expire.
|
|
73
|
+
*/
|
|
74
|
+
prepare?(): Promise<void>;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** The username git is given when a deployment names none. */
|