@bevel-software/platform-core-backend 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +26 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -6,7 +6,7 @@ import { logger } from '../../../shared/logging.js';
|
|
|
6
6
|
// callback of its own, and a module logger of the same name would be
|
|
7
7
|
// shadowed exactly where it is meant to be the default.
|
|
8
8
|
const startupLog = logger('kb-startup');
|
|
9
|
-
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
|
+
import { branchForWorkspaceId, workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
10
10
|
import type { KbBranch, OnServerStart, ServerStartContext } from './on-server-start.js';
|
|
11
11
|
import { git, lsRemoteHeads, stampIdentity, withTempDir } from './kb-git.js';
|
|
12
12
|
import { GitRunError, type IGitRunner } from '../../../shared/git.contract.js';
|
|
@@ -20,7 +20,11 @@ import {
|
|
|
20
20
|
} from '../../../shared/git-failure.js';
|
|
21
21
|
import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
|
|
22
22
|
import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
|
|
23
|
-
import { setAsideClone, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
|
|
23
|
+
import { setAsideClone, setAsideRepository, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
|
|
24
|
+
import { workingCopyShape, type WorkingCopyShape } from '../kb-repository.js';
|
|
25
|
+
import type { FetchOptions, IKbRepository } from '../kb-repository.contract.js';
|
|
26
|
+
import { fetchInstant } from '../fetch-instant.js';
|
|
27
|
+
import { assertValidBranchName } from '../../kb-fs/branch-name.js';
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* The KB startup phase: run every registered {@link OnServerStart} step, in
|
|
@@ -129,6 +133,12 @@ export interface KbStartupRunnerOptions {
|
|
|
129
133
|
* minute; a suite passes a short one.
|
|
130
134
|
*/
|
|
131
135
|
unfinishedCloneQuietMs?: number;
|
|
136
|
+
/**
|
|
137
|
+
* The knowledge base's one repository, every branch workspace a worktree of
|
|
138
|
+
* it — THE SAME INSTANCE THE WORKSPACE SERVICE IS GIVEN (see
|
|
139
|
+
* `kb-repository.ts`). Built by the composition root, never here.
|
|
140
|
+
*/
|
|
141
|
+
repository: IKbRepository;
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
const UNFINISHED_CLONE_QUIET_MS = 60_000;
|
|
@@ -140,10 +150,16 @@ const LONGEST_CLONE_MS = 600_000;
|
|
|
140
150
|
* on every one of these is being worked on by something that will not stop.
|
|
141
151
|
*/
|
|
142
152
|
const PASSES_OVER_ONE_WORKING_COPY = 4;
|
|
153
|
+
/**
|
|
154
|
+
* How recent a fetch of the repository the phase takes as current. Every
|
|
155
|
+
* branch it visits is a worktree of the one repository, so one fetch serves
|
|
156
|
+
* them all; a boot that visits 142 branches fetches once, not 142 times.
|
|
157
|
+
*/
|
|
158
|
+
const STARTUP_FETCH_WINDOW_MS = 30_000;
|
|
143
159
|
|
|
144
|
-
/** Whether `git clone` failed because its destination already held something, having written nothing. */
|
|
160
|
+
/** Whether `git clone` or `git worktree add` failed because its destination already held something, having written nothing. */
|
|
145
161
|
function refusedAnOccupiedFolder(err: unknown): boolean {
|
|
146
|
-
return err instanceof Error && /already exists and is not an empty directory
|
|
162
|
+
return err instanceof Error && /already exists and is not an empty directory|' already exists$/m.test(err.message);
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
/**
|
|
@@ -239,7 +255,24 @@ export interface RetryOptions {
|
|
|
239
255
|
}
|
|
240
256
|
|
|
241
257
|
export class KbStartupRunner {
|
|
242
|
-
|
|
258
|
+
private readonly repository: IKbRepository;
|
|
259
|
+
/**
|
|
260
|
+
* Branches deleted on the remote after the phase listed them: the
|
|
261
|
+
* repository's fetch (`--prune`) took their `origin/<branch>` away. Their
|
|
262
|
+
* worktrees are left as they are, and the phase commits and pushes nothing
|
|
263
|
+
* to them — a push would bring a deleted branch back.
|
|
264
|
+
*/
|
|
265
|
+
private readonly goneFromRemote = new Set<string>();
|
|
266
|
+
/**
|
|
267
|
+
* When this run listed the remote's branches. A fetch that started before
|
|
268
|
+
* it may lack a deletion the listing already saw, so only a fetch since
|
|
269
|
+
* counts as current for the branches the run opens (`fetchedSince`).
|
|
270
|
+
*/
|
|
271
|
+
private listedAt = 0;
|
|
272
|
+
|
|
273
|
+
constructor(private readonly opts: KbStartupRunnerOptions) {
|
|
274
|
+
this.repository = opts.repository;
|
|
275
|
+
}
|
|
243
276
|
|
|
244
277
|
/** The run in progress, so two invokers share one rather than racing clones. */
|
|
245
278
|
private inFlight: Promise<void> | null = null;
|
|
@@ -431,11 +464,19 @@ export class KbStartupRunner {
|
|
|
431
464
|
'screen or GITHUB_TOKEN instead.',
|
|
432
465
|
);
|
|
433
466
|
}
|
|
467
|
+
// What a previous run found deleted may have been pushed again since:
|
|
468
|
+
// each run asks afresh.
|
|
469
|
+
this.goneFromRemote.clear();
|
|
470
|
+
this.listedAt = fetchInstant();
|
|
434
471
|
const heads = await this.ensureRemote();
|
|
435
472
|
// AFTER the remote answered: the configured address is known to be a
|
|
436
473
|
// repository we can reach, so a clone of a different one is stale, not
|
|
437
474
|
// a casualty of a typo in the address.
|
|
438
475
|
await this.reconcileClonesWithConfiguredRepository();
|
|
476
|
+
// Full clones from a release before worktrees become worktrees of the
|
|
477
|
+
// one repository — after the address check, so a clone of another
|
|
478
|
+
// repository is set aside rather than imported.
|
|
479
|
+
await this.convertLegacyClones();
|
|
439
480
|
const ctx = this.buildContext(heads, handles);
|
|
440
481
|
|
|
441
482
|
for (const step of this.opts.steps) {
|
|
@@ -486,6 +527,11 @@ export class KbStartupRunner {
|
|
|
486
527
|
for (const h of handles.values()) {
|
|
487
528
|
await this.finalize(h);
|
|
488
529
|
}
|
|
530
|
+
// The repository's garbage collection, once per boot, with every
|
|
531
|
+
// branch's work landed and nothing served yet: automatic collection is
|
|
532
|
+
// off on the repository, where a commit in one worktree would collect
|
|
533
|
+
// beside every other worktree's work.
|
|
534
|
+
await this.repository.maintain();
|
|
489
535
|
} catch (err) {
|
|
490
536
|
// Every exit carries a scrubbed message: the port scrubs only what the
|
|
491
537
|
// environment holds, and a token saved on the setup screen is the one
|
|
@@ -674,19 +720,32 @@ export class KbStartupRunner {
|
|
|
674
720
|
*/
|
|
675
721
|
private async reconcileClonesWithConfiguredRepository(): Promise<void> {
|
|
676
722
|
const configured = this.opts.kbRepoUrl();
|
|
723
|
+
// One folder per run, so what was set aside together stays together.
|
|
724
|
+
const stamp = setAsideStamp();
|
|
725
|
+
const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
|
|
726
|
+
// The repository first: its address is every worktree's. Even with no
|
|
727
|
+
// workspaces root — the repository lives outside it, and a worktree added
|
|
728
|
+
// later would fetch from whatever address it holds.
|
|
729
|
+
//
|
|
730
|
+
// The root is read BEFORE anything moves, and only an absent root counts
|
|
731
|
+
// as "no workspaces": one that cannot be read (a permission or I/O error)
|
|
732
|
+
// stops the phase, rather than letting the repository move without the
|
|
733
|
+
// worktrees it could not see.
|
|
677
734
|
let entries: import('node:fs').Dirent[];
|
|
678
735
|
try {
|
|
679
736
|
entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
|
|
680
|
-
} catch {
|
|
681
|
-
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
739
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw err;
|
|
740
|
+
entries = []; // no workspaces yet
|
|
682
741
|
}
|
|
683
|
-
|
|
684
|
-
const stamp = setAsideStamp();
|
|
685
|
-
const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
|
|
742
|
+
await this.reconcileRepositoryWithConfigured(configured, path.join(setAsideRoot, stamp));
|
|
686
743
|
for (const entry of entries) {
|
|
687
744
|
if (!entry.isDirectory()) continue;
|
|
688
745
|
const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
|
|
689
|
-
|
|
746
|
+
// A full clone only: a worktree reads the repository's address, and went
|
|
747
|
+
// (or stayed) with it above.
|
|
748
|
+
const hasGit = await fs.stat(path.join(repoDir, '.git')).then((s) => s.isDirectory(), () => false);
|
|
690
749
|
if (!hasGit) continue;
|
|
691
750
|
let origin: string;
|
|
692
751
|
try {
|
|
@@ -721,6 +780,272 @@ export class KbStartupRunner {
|
|
|
721
780
|
await this.announceDiscarded(entry.name);
|
|
722
781
|
}
|
|
723
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* The repository's half of {@link reconcileClonesWithConfiguredRepository}.
|
|
785
|
+
* The address is the repository's, shared by every worktree, so it is
|
|
786
|
+
* decided once: the same repository at a new address is re-pointed and
|
|
787
|
+
* kept; another repository is set aside TOGETHER WITH EVERY WORKTREE OF IT,
|
|
788
|
+
* into `dest` — a worktree's commits live in the repository, so neither is
|
|
789
|
+
* of use without the other. Nothing is deleted.
|
|
790
|
+
*/
|
|
791
|
+
private async reconcileRepositoryWithConfigured(configured: string, dest: string): Promise<void> {
|
|
792
|
+
// Read and probed as itself (reads, and a probe fetch into a throwaway
|
|
793
|
+
// ref, which git locks); the one write, the re-pointing, is made by
|
|
794
|
+
// `setOriginUrl` as the repository's administrator. The phase runs before
|
|
795
|
+
// anything is served, so nothing opens a branch between the two.
|
|
796
|
+
const verdict = await (
|
|
797
|
+
async (): Promise<{ was: string } | 'keep' | 'repointed'> => {
|
|
798
|
+
const origin = await this.repository.originUrl();
|
|
799
|
+
if (origin === null || sameRepository(origin, configured)) return 'keep';
|
|
800
|
+
const was = this.redact(normalizeRepositoryAddress(origin));
|
|
801
|
+
// Any commit of ours will do: a common ancestor of one is a common
|
|
802
|
+
// ancestor of the repository's history.
|
|
803
|
+
const ours = (
|
|
804
|
+
await git(this.opts.gitRunner, this.repository.dir, [
|
|
805
|
+
'for-each-ref', '--count=1', '--format=%(objectname)', 'refs/heads', 'refs/remotes/origin',
|
|
806
|
+
]).catch(() => '')
|
|
807
|
+
).trim();
|
|
808
|
+
if (ours !== '' && (await this.sharesHistoryWithConfigured(this.repository.dir, configured, ours))) {
|
|
809
|
+
await this.repository.setOriginUrl(configured);
|
|
810
|
+
startupLog.warn(
|
|
811
|
+
`the knowledge-base repository was fetched from ${was}, whose history the configured repository ` +
|
|
812
|
+
'holds: the same repository at a new address. Pointed at the configured address and kept, with ' +
|
|
813
|
+
'every worktree and their unpushed work.',
|
|
814
|
+
);
|
|
815
|
+
return 'repointed';
|
|
816
|
+
}
|
|
817
|
+
return { was };
|
|
818
|
+
}
|
|
819
|
+
)();
|
|
820
|
+
if (typeof verdict === 'string') return;
|
|
821
|
+
const { was } = verdict;
|
|
822
|
+
// Every worktree's work leaves first, and may stop the phase: see
|
|
823
|
+
// `beforeCloneSetAside`.
|
|
824
|
+
const worktreeIds: string[] = [];
|
|
825
|
+
for (const entry of await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true }).catch(() => [])) {
|
|
826
|
+
if (!entry.isDirectory()) continue;
|
|
827
|
+
const dir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
|
|
828
|
+
if (await fs.stat(path.join(dir, '.git')).then((s) => s.isFile(), () => false)) worktreeIds.push(entry.name);
|
|
829
|
+
}
|
|
830
|
+
for (const id of worktreeIds) await this.opts.beforeCloneSetAside?.(id);
|
|
831
|
+
const moved = await setAsideRepository(this.repository, this.opts.workspacesRoot, this.opts.kbDirName, dest);
|
|
832
|
+
startupLog.warn(
|
|
833
|
+
`the knowledge-base repository is of another repository (${was}). It and its ${moved.length} worktree(s) were ` +
|
|
834
|
+
`set aside together at ${dest}; nothing was deleted, and a fresh repository is made from the configured ` +
|
|
835
|
+
'one. Work that was never pushed is in that folder: `git log` in a worktree there shows it.',
|
|
836
|
+
);
|
|
837
|
+
for (const id of moved) await this.announceDiscarded(id);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Convert every full clone under the workspaces root — what a release before
|
|
842
|
+
* worktrees left there — into a worktree of the one repository, ONE AT A
|
|
843
|
+
* TIME, without losing a commit:
|
|
844
|
+
*
|
|
845
|
+
* 1. the clone's branch tip is imported into the repository as the branch's
|
|
846
|
+
* local ref (`git fetch <clone> +refs/heads/<b>:refs/heads/<b>`), so
|
|
847
|
+
* commits the remote lacks are in the repository before anything moves,
|
|
848
|
+
* and with it every stash entry and other local ref the clone holds,
|
|
849
|
+
* under `refs/kb-converted/<workspace id>/`;
|
|
850
|
+
* 2. the clone is moved aside, beside where it was (one rename);
|
|
851
|
+
* 3. the worktree is added at the imported tip, and checked to be there;
|
|
852
|
+
* 4. the files git ignores in the clone, in no commit, are copied into it;
|
|
853
|
+
* 5. the clone is renamed `.<kb>.converted`: the one step that says the
|
|
854
|
+
* copy is complete, taken before the worktree is used, so a clone left
|
|
855
|
+
* behind after it is never copied from again (it would overwrite edits
|
|
856
|
+
* made since); a rename that fails undoes the conversion;
|
|
857
|
+
* 6. only then is the clone removed. A removal that fails leaves the
|
|
858
|
+
* `.converted` folder, which the next boot removes and nothing reads.
|
|
859
|
+
*
|
|
860
|
+
* A clone that cannot be converted — unreadable objects, uncommitted changes
|
|
861
|
+
* that are not anybody's to discard, a branch it is not on — is put back
|
|
862
|
+
* where it was and keeps working as a clone; the branch is logged, and the
|
|
863
|
+
* next boot tries again. One clone never stops the phase.
|
|
864
|
+
*
|
|
865
|
+
* A conversion a crash cut short is finished or undone first: the moved
|
|
866
|
+
* clone's ignored files are copied and it is removed when its worktree is
|
|
867
|
+
* in place (its tip was imported before it moved, and the worktree was not
|
|
868
|
+
* used yet: the rename of step 5 comes first), and put back when it is not.
|
|
869
|
+
*/
|
|
870
|
+
private async convertLegacyClones(): Promise<void> {
|
|
871
|
+
let entries: import('node:fs').Dirent[];
|
|
872
|
+
try {
|
|
873
|
+
entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
|
|
874
|
+
} catch {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
let converted = 0;
|
|
878
|
+
const kept: string[] = [];
|
|
879
|
+
for (const entry of entries) {
|
|
880
|
+
if (!entry.isDirectory()) continue;
|
|
881
|
+
const workspaceDir = path.join(this.opts.workspacesRoot, entry.name);
|
|
882
|
+
const repoDir = path.join(workspaceDir, this.opts.kbDirName);
|
|
883
|
+
const aside = path.join(workspaceDir, `.${this.opts.kbDirName}.converting`);
|
|
884
|
+
const convertedClone = path.join(workspaceDir, `.${this.opts.kbDirName}.converted`);
|
|
885
|
+
await this.removeConvertedClone(branchForWorkspaceId(entry.name), convertedClone);
|
|
886
|
+
try {
|
|
887
|
+
await this.finishInterruptedConversion(repoDir, aside, convertedClone);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// Both folders stay as they are; the next start looks again.
|
|
890
|
+
kept.push(branchForWorkspaceId(entry.name));
|
|
891
|
+
startupLog.warn(
|
|
892
|
+
`an interrupted conversion of the "${branchForWorkspaceId(entry.name)}" working copy could not be finished (` +
|
|
893
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). The moved clone is kept as it is, and the next start tries again.`,
|
|
894
|
+
);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const branch = branchForWorkspaceId(entry.name);
|
|
898
|
+
let shape: WorkingCopyShape;
|
|
899
|
+
try {
|
|
900
|
+
shape = await workingCopyShape(repoDir);
|
|
901
|
+
} catch (err) {
|
|
902
|
+
// A `.git` that could not be read has shown nothing: left as it is,
|
|
903
|
+
// named, and looked at again by the next start.
|
|
904
|
+
kept.push(branch);
|
|
905
|
+
startupLog.warn(
|
|
906
|
+
`the "${branch}" working copy could not be inspected for conversion (` +
|
|
907
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). It is left as it is, and the next start tries again.`,
|
|
908
|
+
);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (shape !== 'clone') continue;
|
|
912
|
+
try {
|
|
913
|
+
if (workspaceIdForBranch(branch) !== entry.name) continue;
|
|
914
|
+
assertValidBranchName(branch);
|
|
915
|
+
} catch {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
// A clone with no commit on its branch is an unfinished one, which the
|
|
919
|
+
// branch's own pass sets aside: there is no tip to import.
|
|
920
|
+
const tip = await git(this.opts.gitRunner, repoDir, [
|
|
921
|
+
'for-each-ref', '--format=%(objectname)', `refs/heads/${branch}`,
|
|
922
|
+
]).catch(() => 'unreadable');
|
|
923
|
+
if (tip.trim() === '') continue;
|
|
924
|
+
try {
|
|
925
|
+
await this.convertOneClone(branch, repoDir, aside, convertedClone);
|
|
926
|
+
converted++;
|
|
927
|
+
} catch (err) {
|
|
928
|
+
kept.push(branch);
|
|
929
|
+
startupLog.warn(
|
|
930
|
+
`the "${branch}" working copy is still a full clone: it could not be converted to a worktree (` +
|
|
931
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). It keeps working as a clone, ` +
|
|
932
|
+
'nothing in it was changed, and the next start tries again.',
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (converted > 0 || kept.length > 0) {
|
|
937
|
+
startupLog.info(
|
|
938
|
+
`converted ${converted} full clone(s) to worktrees of the knowledge-base repository` +
|
|
939
|
+
(kept.length > 0 ? `; ${kept.length} left as they are, to be tried again at the next start (${kept.join(', ')})` : '') +
|
|
940
|
+
'.',
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private async convertOneClone(branch: string, repoDir: string, aside: string, converted: string): Promise<void> {
|
|
946
|
+
const onBranch = (await git(this.opts.gitRunner, repoDir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).trim();
|
|
947
|
+
if (onBranch !== branch) throw new Error(`it is on "${onBranch}", not on its own branch`);
|
|
948
|
+
// Uncommitted work is in the working tree, not in a commit the import can
|
|
949
|
+
// carry; the clone keeps it, and is converted once it is committed.
|
|
950
|
+
const dirty = (await git(this.opts.gitRunner, repoDir, ['status', '--porcelain=v1'])).trim();
|
|
951
|
+
if (dirty !== '') throw new Error('it has uncommitted changes');
|
|
952
|
+
const tip = await this.repository.importBranch(repoDir, branch);
|
|
953
|
+
await this.importLocalRefs(repoDir, branch);
|
|
954
|
+
await fs.rename(repoDir, aside);
|
|
955
|
+
try {
|
|
956
|
+
await this.repository.addWorktree(branch, repoDir, { allowLocalOnly: true, keepLocalTip: true });
|
|
957
|
+
const head = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
|
|
958
|
+
if (head !== tip) throw new Error(`the worktree came up at ${head}, not at the clone's tip ${tip}`);
|
|
959
|
+
await this.copyIgnoredFiles(aside, repoDir);
|
|
960
|
+
await fs.rename(aside, converted);
|
|
961
|
+
} catch (err) {
|
|
962
|
+
await this.undoConversion(repoDir, aside);
|
|
963
|
+
throw err;
|
|
964
|
+
}
|
|
965
|
+
await this.removeConvertedClone(branch, converted);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* The clone's stash and its other local refs, into the repository before
|
|
970
|
+
* the clone goes (see `KbRepository.importLocalRefs`), and said where.
|
|
971
|
+
*/
|
|
972
|
+
private async importLocalRefs(cloneDir: string, branch: string): Promise<void> {
|
|
973
|
+
const kept = await this.repository.importLocalRefs(cloneDir, branch, workspaceIdForBranch(branch));
|
|
974
|
+
if (kept.length === 0) return;
|
|
975
|
+
startupLog.info(
|
|
976
|
+
`the "${branch}" clone's stash and other local refs are kept in the knowledge-base repository (${kept.length}): ` +
|
|
977
|
+
`${kept.join(', ')}. \`git log <ref>\` in its worktree shows each.`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Undone: the worktree goes, the clone comes back exactly as it was. */
|
|
982
|
+
private async undoConversion(repoDir: string, aside: string): Promise<void> {
|
|
983
|
+
await fs.rm(repoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
984
|
+
await this.repository.prune().catch(() => undefined);
|
|
985
|
+
await fs.rename(aside, repoDir);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Remove a converted clone: its commits and ignored files are in the
|
|
990
|
+
* worktree already. One that cannot be removed is said and left; the next
|
|
991
|
+
* boot tries again, and nothing ever copies from it.
|
|
992
|
+
*/
|
|
993
|
+
private async removeConvertedClone(branch: string, converted: string): Promise<void> {
|
|
994
|
+
await fs.rm(converted, { recursive: true, force: true }).catch((err: unknown) => {
|
|
995
|
+
startupLog.warn(
|
|
996
|
+
`the converted clone of "${branch}" could not be removed (` +
|
|
997
|
+
`${this.redact(err instanceof Error ? err.message : String(err))}). Nothing reads it; the next start tries again.`,
|
|
998
|
+
);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Copy the files git ignores in the clone at `from` into the worktree at
|
|
1004
|
+
* `to`: they are in no commit the import carried, yet they are the
|
|
1005
|
+
* clone's. Copied, not moved, so an undo puts the clone back whole; and
|
|
1006
|
+
* over whatever is there, so a copy a crash cut short (a partial file) is
|
|
1007
|
+
* made whole again before the clone goes.
|
|
1008
|
+
*/
|
|
1009
|
+
private async copyIgnoredFiles(from: string, to: string): Promise<void> {
|
|
1010
|
+
const ignored = (
|
|
1011
|
+
await git(this.opts.gitRunner, from, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'])
|
|
1012
|
+
)
|
|
1013
|
+
.split('\0')
|
|
1014
|
+
.map((rel) => rel.replace(/\/$/, ''))
|
|
1015
|
+
.filter(Boolean);
|
|
1016
|
+
for (const rel of ignored) {
|
|
1017
|
+
await fs.mkdir(path.dirname(path.join(to, rel)), { recursive: true });
|
|
1018
|
+
await fs.cp(path.join(from, rel), path.join(to, rel), { recursive: true, force: true, verbatimSymlinks: true });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** See {@link convertLegacyClones}: a conversion a crash cut short. */
|
|
1023
|
+
private async finishInterruptedConversion(repoDir: string, aside: string, converted: string): Promise<void> {
|
|
1024
|
+
if (!(await fs.access(aside).then(() => true, () => false))) return;
|
|
1025
|
+
// Only a checkout shown to be no worktree is removed: a link that could
|
|
1026
|
+
// not be read has shown nothing, and the error leaves both folders alone.
|
|
1027
|
+
if ((await workingCopyShape(repoDir)) === 'worktree') {
|
|
1028
|
+
// Not used since it was added: the clone is still `.converting`, so
|
|
1029
|
+
// the copy is finished (over a partial one) as the conversion would
|
|
1030
|
+
// have, and undone when it cannot be.
|
|
1031
|
+
try {
|
|
1032
|
+
// Imported before the clone moved; again, in case that was by a
|
|
1033
|
+
// release that imported its branch only.
|
|
1034
|
+
await this.importLocalRefs(aside, branchForWorkspaceId(path.basename(path.dirname(repoDir))));
|
|
1035
|
+
await this.copyIgnoredFiles(aside, repoDir);
|
|
1036
|
+
await fs.rename(aside, converted);
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
await this.undoConversion(repoDir, aside);
|
|
1039
|
+
throw err;
|
|
1040
|
+
}
|
|
1041
|
+
await this.removeConvertedClone(branchForWorkspaceId(path.basename(path.dirname(repoDir))), converted);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
1045
|
+
await this.repository.prune().catch(() => undefined);
|
|
1046
|
+
await fs.rename(aside, repoDir);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
724
1049
|
|
|
725
1050
|
/**
|
|
726
1051
|
* Whether the configured repository holds history this clone has too —
|
|
@@ -733,7 +1058,7 @@ export class KbStartupRunner {
|
|
|
733
1058
|
* a wrong true would push a stranger's commits into the configured
|
|
734
1059
|
* repository.
|
|
735
1060
|
*/
|
|
736
|
-
private async sharesHistoryWithConfigured(repoDir: string, configured: string): Promise<boolean> {
|
|
1061
|
+
private async sharesHistoryWithConfigured(repoDir: string, configured: string, ours = 'HEAD'): Promise<boolean> {
|
|
737
1062
|
const probe = 'refs/hexis-probe';
|
|
738
1063
|
let theirs: string[] = [];
|
|
739
1064
|
try {
|
|
@@ -746,7 +1071,7 @@ export class KbStartupRunner {
|
|
|
746
1071
|
// One question for all of them: a common ancestor of HEAD and ANY of
|
|
747
1072
|
// their branches is a common ancestor of HEAD and their merge. Capped,
|
|
748
1073
|
// so a remote with thousands of branches cannot outgrow a command line.
|
|
749
|
-
await git(this.opts.gitRunner, repoDir, ['merge-base',
|
|
1074
|
+
await git(this.opts.gitRunner, repoDir, ['merge-base', ours, ...theirs.slice(0, 200)]);
|
|
750
1075
|
return true;
|
|
751
1076
|
} catch {
|
|
752
1077
|
return false;
|
|
@@ -800,8 +1125,13 @@ export class KbStartupRunner {
|
|
|
800
1125
|
const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
|
|
801
1126
|
const repoDir = path.join(workspaceDir, this.opts.kbDirName);
|
|
802
1127
|
for (let pass = 1; ; pass++) {
|
|
803
|
-
|
|
804
|
-
|
|
1128
|
+
// A worktree whose link to the repository is broken has a `.git` and is
|
|
1129
|
+
// no working copy: it is set aside as an unfinished clone is, below.
|
|
1130
|
+
const shape = await workingCopyShape(repoDir);
|
|
1131
|
+
const hasGit = shape !== 'none';
|
|
1132
|
+
if ((shape === 'worktree' || shape === 'clone') && (await this.hasCommit(repoDir))) {
|
|
1133
|
+
return this.updateClone(branch, repoDir, shape === 'worktree');
|
|
1134
|
+
}
|
|
805
1135
|
if (pass > PASSES_OVER_ONE_WORKING_COPY) {
|
|
806
1136
|
throw new Error('it kept changing while this start was preparing it; something else is working on it');
|
|
807
1137
|
}
|
|
@@ -862,8 +1192,9 @@ export class KbStartupRunner {
|
|
|
862
1192
|
}
|
|
863
1193
|
|
|
864
1194
|
private async isUnfinishedClone(repoDir: string): Promise<boolean> {
|
|
865
|
-
const
|
|
866
|
-
|
|
1195
|
+
const shape = await workingCopyShape(repoDir);
|
|
1196
|
+
if (shape === 'broken') return true;
|
|
1197
|
+
return shape !== 'none' && !(await this.hasCommit(repoDir));
|
|
867
1198
|
}
|
|
868
1199
|
|
|
869
1200
|
/**
|
|
@@ -882,7 +1213,11 @@ export class KbStartupRunner {
|
|
|
882
1213
|
const giveUpAt = Date.now() + Math.max(this.opts.gitRunner.defaultTimeoutMs, LONGEST_CLONE_MS) + quietMs;
|
|
883
1214
|
for (;;) {
|
|
884
1215
|
if (!(await this.isUnfinishedClone(repoDir))) return false;
|
|
885
|
-
|
|
1216
|
+
// A clone writes under its `.git` folder; a worktree being added
|
|
1217
|
+
// writes its checkout, its `.git` being a file.
|
|
1218
|
+
const dotGit = path.join(repoDir, '.git');
|
|
1219
|
+
const watched = (await fs.stat(dotGit).then((s) => s.isDirectory(), () => false)) ? dotGit : repoDir;
|
|
1220
|
+
const lastWrite = await newestChangeUnder(watched);
|
|
886
1221
|
const quietFor = Date.now() - lastWrite;
|
|
887
1222
|
if (quietFor >= quietMs) return true;
|
|
888
1223
|
if (Date.now() > giveUpAt) throw new Error('an unfinished clone at its path is still being written to');
|
|
@@ -914,16 +1249,21 @@ export class KbStartupRunner {
|
|
|
914
1249
|
|
|
915
1250
|
private async cloneFresh(branch: string, workspaceDir: string, repoDir: string): Promise<string> {
|
|
916
1251
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
917
|
-
// Only what cannot be a
|
|
918
|
-
// gained a `.git` since the caller looked is somebody's
|
|
1252
|
+
// Only what cannot be a working copy is cleared out of the way: a folder
|
|
1253
|
+
// that gained a `.git` since the caller looked is somebody's, and git
|
|
919
1254
|
// refuses it below rather than this removing it.
|
|
920
1255
|
if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
|
|
921
1256
|
await fs.rm(repoDir, { recursive: true, force: true });
|
|
922
1257
|
}
|
|
923
|
-
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
1258
|
+
// A worktree of the one repository, which is made (and fetched) by the
|
|
1259
|
+
// first branch that needs it: no history is copied per branch. The
|
|
1260
|
+
// repository carries the identity, the long-paths setting and the
|
|
1261
|
+
// credential helper for every worktree.
|
|
1262
|
+
// The phase opens every branch: the repository's one fetch in the window
|
|
1263
|
+
// brought them all, so each add reads it instead of asking the remote
|
|
1264
|
+
// for its branch again. A fetch that failed leaves the add to ask.
|
|
1265
|
+
await this.repository.fetch(this.fetchWindow());
|
|
1266
|
+
await this.repository.addWorktree(branch, repoDir, { fetchedSince: this.fetchedSince() });
|
|
927
1267
|
try {
|
|
928
1268
|
this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
|
|
929
1269
|
} catch (err) {
|
|
@@ -931,13 +1271,44 @@ export class KbStartupRunner {
|
|
|
931
1271
|
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
932
1272
|
});
|
|
933
1273
|
}
|
|
934
|
-
await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
|
|
935
|
-
await stampIdentity(this.opts.gitRunner, repoDir);
|
|
936
1274
|
return repoDir;
|
|
937
1275
|
}
|
|
938
1276
|
|
|
939
|
-
|
|
940
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* The earliest a fetch of the repository may have started and still count
|
|
1279
|
+
* for this run: never before its listing, never older than the window. An
|
|
1280
|
+
* instant, so the repository compares it when it gets the reservation and
|
|
1281
|
+
* time spent waiting for it does not age the fetch out.
|
|
1282
|
+
*/
|
|
1283
|
+
private fetchedSince(): number {
|
|
1284
|
+
return Math.max(this.listedAt, Date.now() - STARTUP_FETCH_WINDOW_MS);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private fetchWindow(): FetchOptions {
|
|
1288
|
+
return { maxAgeMs: STARTUP_FETCH_WINDOW_MS, fetchedSince: this.fetchedSince() };
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
private async updateClone(branch: string, repoDir: string, worktree = false): Promise<string> {
|
|
1292
|
+
if (worktree) {
|
|
1293
|
+
// ONE fetch for every worktree the phase visits, within the window: the
|
|
1294
|
+
// repository's. The phase is fail-closed, so a failed fetch stops it
|
|
1295
|
+
// with git's words, as the clone's own fetch did.
|
|
1296
|
+
if (!(await this.repository.fetch(this.fetchWindow()))) {
|
|
1297
|
+
const raw = `git fetch origin failed: ${String(this.repository.lastFetchFailure() ?? 'unknown failure')}`;
|
|
1298
|
+
throw new ClassifiedFailure(this.redact(raw), classifyGitFailure(raw));
|
|
1299
|
+
}
|
|
1300
|
+
// The branch was listed before the fetch and deleted on the remote
|
|
1301
|
+
// since: nothing to move the worktree to. Its commits stay where they
|
|
1302
|
+
// are, reachable from its branch, as any other branch's do.
|
|
1303
|
+
if (!(await this.hasRemoteBranch(repoDir, branch))) {
|
|
1304
|
+
this.goneFromRemote.add(branch);
|
|
1305
|
+
startupLog.warn(`branch "${branch}" was deleted on the remote during this start: its working copy is left as it is`);
|
|
1306
|
+
return repoDir;
|
|
1307
|
+
}
|
|
1308
|
+
this.goneFromRemote.delete(branch);
|
|
1309
|
+
} else {
|
|
1310
|
+
await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
|
|
1311
|
+
}
|
|
941
1312
|
const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
|
|
942
1313
|
const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
|
|
943
1314
|
if (local !== remote) {
|
|
@@ -950,6 +1321,22 @@ export class KbStartupRunner {
|
|
|
950
1321
|
return repoDir;
|
|
951
1322
|
}
|
|
952
1323
|
|
|
1324
|
+
/**
|
|
1325
|
+
* Whether the repository's remote-tracking ref for `branch` exists. False
|
|
1326
|
+
* only when git answered that it does not (`--verify --quiet`, exit 1);
|
|
1327
|
+
* any other failure is thrown.
|
|
1328
|
+
*/
|
|
1329
|
+
private async hasRemoteBranch(repoDir: string, branch: string): Promise<boolean> {
|
|
1330
|
+
try {
|
|
1331
|
+
await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', `refs/remotes/origin/${branch}^{commit}`]);
|
|
1332
|
+
return true;
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
const ran = err instanceof Error ? err.cause : undefined;
|
|
1335
|
+
if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1) return false;
|
|
1336
|
+
throw err;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
953
1340
|
/**
|
|
954
1341
|
* Whether the repository at `repoDir` has a commit checked out. False for a
|
|
955
1342
|
* clone that was cut short.
|
|
@@ -975,8 +1362,14 @@ export class KbStartupRunner {
|
|
|
975
1362
|
/** One commit per dirty branch; push; the replica carve-out on rejection. */
|
|
976
1363
|
private async finalize(h: BranchHandle): Promise<void> {
|
|
977
1364
|
if (!h.dirty) return;
|
|
1365
|
+
if (this.goneFromRemote.has(h.name)) {
|
|
1366
|
+
startupLog.warn(`${h.name}: deleted on the remote during this start; the phase's changes are not committed or pushed`);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
978
1369
|
const repoDir = await h.repoDir();
|
|
979
|
-
|
|
1370
|
+
// A worktree's identity is the repository's, stamped when it was made; a
|
|
1371
|
+
// config write from here would contend with every other worktree's.
|
|
1372
|
+
if ((await workingCopyShape(repoDir)) !== 'worktree') await stampIdentity(this.opts.gitRunner, repoDir);
|
|
980
1373
|
// Drop any PRE-EXISTING index state first (a crashed tool may have left
|
|
981
1374
|
// edits staged): `git commit` publishes the whole index, and the phase
|
|
982
1375
|
// must commit exactly its own staged set. The edits stay in the working
|
|
@@ -5,7 +5,6 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import fs from 'node:fs/promises';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { KbStartupRunner } from '../../kb-startup-runner.js';
|
|
9
8
|
import { NodeGitRunner } from '../../../../workflow/git/node-git-runner.js';
|
|
10
9
|
import type { OnServerStart, ServerStartContext, StepResult } from '../../on-server-start.js';
|
|
11
10
|
import { GroupsToPluginsStep } from '../groups-to-plugins.step.js';
|
|
@@ -21,6 +20,7 @@ import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
|
21
20
|
import { renderTemplateText } from '../template-source.js';
|
|
22
21
|
import { TOOL_PREFIX_LINE, composeAgentInstructions, platformInstructions } from '../../../../agent-instructions/index.js';
|
|
23
22
|
import { testKbContext } from '../../../../../__tests__/kb-context.js';
|
|
23
|
+
import { newKbStartupRunner } from '../../../../../__tests__/workspace-service.js';
|
|
24
24
|
|
|
25
25
|
const execFileAsync = promisify(execFile);
|
|
26
26
|
|
|
@@ -102,7 +102,7 @@ async function seedUpstream(
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function makeRunner(steps: OnServerStart[], templateDir: string = TEMPLATE_DIR) {
|
|
105
|
-
return
|
|
105
|
+
return newKbStartupRunner({
|
|
106
106
|
gitRunner: new NodeGitRunner(),
|
|
107
107
|
kbRepoUrl: () => upstream,
|
|
108
108
|
workspacesRoot,
|
|
@@ -781,7 +781,8 @@ describe('TemplateFilesStep: the guide copies earlier releases wrote', () => {
|
|
|
781
781
|
|
|
782
782
|
const dir = await checkout(DEFAULT_BRANCH);
|
|
783
783
|
expect((await fs.lstat(path.join(dir, 'AGENTS.md'))).isSymbolicLink()).toBe(true);
|
|
784
|
-
|
|
784
|
+
// Windows reads the link back with its own separator.
|
|
785
|
+
expect((await fs.readlink(path.join(dir, 'AGENTS.md'))).replace(/\\/g, '/')).toBe('conventions/ours.md');
|
|
785
786
|
expect(norm(await fs.readFile(path.join(dir, 'conventions/ours.md'), 'utf8'))).toBe(MANAGED_GUIDE_COPY);
|
|
786
787
|
});
|
|
787
788
|
});
|