@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
|
@@ -23,6 +23,28 @@
|
|
|
23
23
|
* never be keyed on a path no file verb would accept. See
|
|
24
24
|
* `canonicalFileIdentity`.
|
|
25
25
|
*
|
|
26
|
+
* Workspace identity works the same way. The lock routes pass the `:id`
|
|
27
|
+
* path param, which Express URL-decodes, so a slashed branch's workspace
|
|
28
|
+
* arrives as `alice/feature`; the deletion gate and the git status probe ask
|
|
29
|
+
* with the encoded `alice%2Ffeature`. Every method keys rows on
|
|
30
|
+
* `canonicalWorkspaceId`, so both spellings are one workspace and a held file
|
|
31
|
+
* is seen whichever spelling asks.
|
|
32
|
+
*
|
|
33
|
+
* Unlike raw path spellings (below), rows written under the decoded workspace id before this
|
|
34
|
+
* landed are still honoured until they go: every read, heartbeat and release
|
|
35
|
+
* also matches that spelling, and an acquire is refused while a live row
|
|
36
|
+
* under it holds the same file. Otherwise a lock held across the upgrade
|
|
37
|
+
* would be invisible for up to its TTL — to the deletion gate and to a second
|
|
38
|
+
* editor alike. Nothing new is ever written under it, so once the last such
|
|
39
|
+
* row has expired or been released the extra spelling matches nothing.
|
|
40
|
+
*
|
|
41
|
+
* This covers rows LEFT by the previous version, not rows it is still
|
|
42
|
+
* writing: a previous-version process never looks at a canonical row, so no
|
|
43
|
+
* check here could make the two contend while both run. They never do — one
|
|
44
|
+
* server process owns the workspaces directory (see `branchLifecycle` in the
|
|
45
|
+
* workflow service; this service's deletion gate is in-process for the same
|
|
46
|
+
* reason), and an upgrade stops the old process before the new one starts.
|
|
47
|
+
*
|
|
26
48
|
* There is no transition handling for rows written under a raw spelling
|
|
27
49
|
* before this landed: such a row is now unreachable by name and expires on
|
|
28
50
|
* its own TTL. For one deploy an in-flight edit's lock can linger up to the
|
|
@@ -35,12 +57,13 @@
|
|
|
35
57
|
* bounded by concurrent editors, not history, so lazy GC is fine.
|
|
36
58
|
*/
|
|
37
59
|
|
|
38
|
-
import { and, eq, gt, lte } from 'drizzle-orm';
|
|
60
|
+
import { and, eq, gt, inArray, lte } from 'drizzle-orm';
|
|
39
61
|
import type { Database } from '../database/connection.js';
|
|
40
62
|
import { fileLocks } from '../database/schema.js';
|
|
41
63
|
import type { AcquireLockResult, AuthUser, FileLock } from '@bevel-software/platform-shared';
|
|
42
64
|
import { WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
43
65
|
import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
|
|
66
|
+
import { branchForWorkspaceId, canonicalWorkspaceId } from '../../shared/workspace-id.js';
|
|
44
67
|
|
|
45
68
|
/**
|
|
46
69
|
* Lock lifetime without a heartbeat. The client is expected to heartbeat
|
|
@@ -51,6 +74,17 @@ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
|
|
|
51
74
|
*/
|
|
52
75
|
const LOCK_TTL_MS = 60_000;
|
|
53
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The workspace ids a lock row of canonical workspace `workspaceId` can sit
|
|
79
|
+
* under: the canonical one every write uses, then — for a slashed branch —
|
|
80
|
+
* the decoded one rows were written under before workspace ids were
|
|
81
|
+
* canonicalised. See the module comment.
|
|
82
|
+
*/
|
|
83
|
+
function workspaceSpellings(workspaceId: string): string[] {
|
|
84
|
+
const legacy = branchForWorkspaceId(workspaceId);
|
|
85
|
+
return legacy === workspaceId ? [workspaceId] : [workspaceId, legacy];
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
function rowToFileLock(row: typeof fileLocks.$inferSelect): FileLock {
|
|
55
89
|
return {
|
|
56
90
|
branch: row.branch,
|
|
@@ -125,13 +159,14 @@ export class FileLockService {
|
|
|
125
159
|
* (or vice versa).
|
|
126
160
|
*/
|
|
127
161
|
async acquire(
|
|
128
|
-
|
|
162
|
+
rawWorkspaceId: string,
|
|
129
163
|
branch: string,
|
|
130
164
|
rawPath: string,
|
|
131
165
|
user: AuthUser,
|
|
132
166
|
opts?: { coordination?: boolean },
|
|
133
167
|
): Promise<AcquireLockResult> {
|
|
134
168
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
169
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
135
170
|
if (this.closing.has(branch)) {
|
|
136
171
|
throw new WorkflowValidationError(`"${branch}" is being deleted, so "${targetPath}" cannot be held for editing.`, {
|
|
137
172
|
kind: 'branch-being-deleted',
|
|
@@ -190,6 +225,25 @@ export class FileLockService {
|
|
|
190
225
|
// `heartbeat()` (which has its own same-user UPDATE), and the
|
|
191
226
|
// editor's save flow detects "I already hold it" via `getLock` in
|
|
192
227
|
// `workspace.routes.withLock` rather than re-acquiring.
|
|
228
|
+
// A live row under the pre-canonical spelling holds this file as surely
|
|
229
|
+
// as one under the canonical id; the upsert below cannot see it.
|
|
230
|
+
const legacy = workspaceSpellings(workspaceId).slice(1);
|
|
231
|
+
if (legacy.length > 0) {
|
|
232
|
+
const [held] = await this.db
|
|
233
|
+
.select()
|
|
234
|
+
.from(fileLocks)
|
|
235
|
+
.where(
|
|
236
|
+
and(
|
|
237
|
+
inArray(fileLocks.workspaceId, legacy),
|
|
238
|
+
eq(fileLocks.branch, branch),
|
|
239
|
+
eq(fileLocks.path, targetPath),
|
|
240
|
+
gt(fileLocks.expiresAt, now),
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
.limit(1);
|
|
244
|
+
if (held) return { acquired: false, lock: rowToFileLock(held) };
|
|
245
|
+
}
|
|
246
|
+
|
|
193
247
|
const mode = opts?.coordination ? 'coordination' : 'edit';
|
|
194
248
|
const upsertResult = await this.db
|
|
195
249
|
.insert(fileLocks)
|
|
@@ -255,12 +309,13 @@ export class FileLockService {
|
|
|
255
309
|
* current holder.
|
|
256
310
|
*/
|
|
257
311
|
async heartbeat(
|
|
258
|
-
|
|
312
|
+
rawWorkspaceId: string,
|
|
259
313
|
branch: string,
|
|
260
314
|
rawPath: string,
|
|
261
315
|
user: AuthUser,
|
|
262
316
|
): Promise<FileLock> {
|
|
263
317
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
318
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
264
319
|
const now = new Date();
|
|
265
320
|
const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);
|
|
266
321
|
// The expiry guard (`expiresAt > now`) matters because an expired
|
|
@@ -279,7 +334,7 @@ export class FileLockService {
|
|
|
279
334
|
.set({ lastHeartbeatAt: now, expiresAt })
|
|
280
335
|
.where(
|
|
281
336
|
and(
|
|
282
|
-
|
|
337
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
283
338
|
eq(fileLocks.branch, branch),
|
|
284
339
|
eq(fileLocks.path, targetPath),
|
|
285
340
|
eq(fileLocks.holderUserId, user.id),
|
|
@@ -302,17 +357,18 @@ export class FileLockService {
|
|
|
302
357
|
* over by someone else — that's not an error from the caller's POV).
|
|
303
358
|
*/
|
|
304
359
|
async release(
|
|
305
|
-
|
|
360
|
+
rawWorkspaceId: string,
|
|
306
361
|
branch: string,
|
|
307
362
|
rawPath: string,
|
|
308
363
|
user: AuthUser,
|
|
309
364
|
): Promise<void> {
|
|
310
365
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
366
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
311
367
|
await this.db
|
|
312
368
|
.delete(fileLocks)
|
|
313
369
|
.where(
|
|
314
370
|
and(
|
|
315
|
-
|
|
371
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
316
372
|
eq(fileLocks.branch, branch),
|
|
317
373
|
eq(fileLocks.path, targetPath),
|
|
318
374
|
eq(fileLocks.holderUserId, user.id),
|
|
@@ -326,25 +382,30 @@ export class FileLockService {
|
|
|
326
382
|
* are purged here lazily.
|
|
327
383
|
*/
|
|
328
384
|
async get(
|
|
329
|
-
|
|
385
|
+
rawWorkspaceId: string,
|
|
330
386
|
branch: string,
|
|
331
387
|
rawPath: string,
|
|
332
388
|
): Promise<FileLock | null> {
|
|
333
389
|
const targetPath = canonicalFileIdentity(rawPath);
|
|
390
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
334
391
|
const rows = await this.db
|
|
335
392
|
.select()
|
|
336
393
|
.from(fileLocks)
|
|
337
394
|
.where(
|
|
338
395
|
and(
|
|
339
|
-
|
|
396
|
+
inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)),
|
|
340
397
|
eq(fileLocks.branch, branch),
|
|
341
398
|
eq(fileLocks.path, targetPath),
|
|
342
399
|
),
|
|
343
|
-
)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
);
|
|
401
|
+
// At most one row per spelling; the canonical one first.
|
|
402
|
+
rows.sort((x, y) => Number(x.workspaceId !== workspaceId) - Number(y.workspaceId !== workspaceId));
|
|
403
|
+
let live: (typeof rows)[number] | null = null;
|
|
404
|
+
for (const row of rows) {
|
|
405
|
+
if (row.expiresAt.getTime() > Date.now()) {
|
|
406
|
+
live ??= row;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
348
409
|
// Lazy GC — drop the stale row so the next `acquire` doesn't have
|
|
349
410
|
// to bypass it. Bind the delete to this exact row's `expiresAt` so
|
|
350
411
|
// a concurrent acquire that wrote a fresh row in the gap between
|
|
@@ -356,15 +417,14 @@ export class FileLockService {
|
|
|
356
417
|
.delete(fileLocks)
|
|
357
418
|
.where(
|
|
358
419
|
and(
|
|
359
|
-
eq(fileLocks.workspaceId, workspaceId),
|
|
420
|
+
eq(fileLocks.workspaceId, row.workspaceId),
|
|
360
421
|
eq(fileLocks.branch, branch),
|
|
361
422
|
eq(fileLocks.path, targetPath),
|
|
362
423
|
eq(fileLocks.expiresAt, row.expiresAt),
|
|
363
424
|
),
|
|
364
425
|
);
|
|
365
|
-
return null;
|
|
366
426
|
}
|
|
367
|
-
return rowToFileLock(
|
|
427
|
+
return live ? rowToFileLock(live) : null;
|
|
368
428
|
}
|
|
369
429
|
|
|
370
430
|
/**
|
|
@@ -406,11 +466,12 @@ export class FileLockService {
|
|
|
406
466
|
* the "missed lock-release commit" the loud warning is for. Expired rows
|
|
407
467
|
* don't count (their holder is gone; they explain nothing).
|
|
408
468
|
*/
|
|
409
|
-
async hasAnyActive(
|
|
469
|
+
async hasAnyActive(rawWorkspaceId: string): Promise<boolean> {
|
|
470
|
+
const workspaceId = canonicalWorkspaceId(rawWorkspaceId);
|
|
410
471
|
const rows = await this.db
|
|
411
472
|
.select({ path: fileLocks.path })
|
|
412
473
|
.from(fileLocks)
|
|
413
|
-
.where(and(
|
|
474
|
+
.where(and(inArray(fileLocks.workspaceId, workspaceSpellings(workspaceId)), gt(fileLocks.expiresAt, new Date())))
|
|
414
475
|
.limit(1);
|
|
415
476
|
return rows.length > 0;
|
|
416
477
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { testKbContext } from '../../../../__tests__/kb-context.js';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import type { WorkspaceService } from '../../../workspace/workspace.service.js';
|
|
9
|
+
import { WorkflowHooks } from '../../workflow-hooks.js';
|
|
10
|
+
import { GitService } from '../git.service.js';
|
|
11
|
+
import { WorkspaceMutex } from '../../../kb-fs/mutex.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every git process the service starts, by argv, so a test can say what a
|
|
15
|
+
* change-request read is allowed to cost. The fixture's own git runs through
|
|
16
|
+
* `execFile` and is not logged.
|
|
17
|
+
*/
|
|
18
|
+
const { spawnLog } = vi.hoisted(() => ({ spawnLog: [] as string[][] }));
|
|
19
|
+
|
|
20
|
+
vi.mock('node:child_process', async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import('node:child_process')>();
|
|
22
|
+
const spawn = ((file: string, args: string[], options: unknown) => {
|
|
23
|
+
if (file === 'git') spawnLog.push(args ?? []);
|
|
24
|
+
return (actual.spawn as unknown as (...a: unknown[]) => unknown)(file, args, options);
|
|
25
|
+
}) as unknown as typeof actual.spawn;
|
|
26
|
+
return { ...actual, spawn };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
async function runGit(cwd: string, args: string[]): Promise<void> {
|
|
32
|
+
await execFileAsync('git', args, {
|
|
33
|
+
cwd,
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
GIT_AUTHOR_NAME: 'Test',
|
|
37
|
+
GIT_AUTHOR_EMAIL: 't@x.com',
|
|
38
|
+
GIT_COMMITTER_NAME: 'Test',
|
|
39
|
+
GIT_COMMITTER_EMAIL: 't@x.com',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const spawnsOf = (subcommand: string) => spawnLog.filter((argv) => argv.includes(subcommand)).length;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a change request's changed paths cost: the tips come from the ref
|
|
48
|
+
* files and the diff is read from git ONCE per pair of commits. A listing
|
|
49
|
+
* that nothing has moved under asks git for nothing; a push moves a tip,
|
|
50
|
+
* and that request alone is diffed again.
|
|
51
|
+
*/
|
|
52
|
+
describe('a change request is diffed once per pair of commits', () => {
|
|
53
|
+
let root: string;
|
|
54
|
+
let upstream: string;
|
|
55
|
+
let repo: string;
|
|
56
|
+
let other: string;
|
|
57
|
+
let svc: GitService;
|
|
58
|
+
const workspaceId = 'ws-main';
|
|
59
|
+
const secondWorkspaceId = 'ws-two';
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-changed-memo-'));
|
|
63
|
+
upstream = path.join(root, 'upstream.git');
|
|
64
|
+
await runGit(root, ['init', '--bare', '-b', 'main', upstream]);
|
|
65
|
+
const seed = path.join(root, '.seed');
|
|
66
|
+
await fs.mkdir(seed);
|
|
67
|
+
await runGit(seed, ['init', '-b', 'main']);
|
|
68
|
+
await runGit(seed, ['remote', 'add', 'origin', upstream]);
|
|
69
|
+
await fs.writeFile(path.join(seed, 'base.md'), 'base\n');
|
|
70
|
+
await runGit(seed, ['add', '-A']);
|
|
71
|
+
await runGit(seed, ['commit', '-m', 'init']);
|
|
72
|
+
await runGit(seed, ['checkout', '-b', 'feature']);
|
|
73
|
+
await fs.writeFile(path.join(seed, 'one.md'), 'one\n');
|
|
74
|
+
await runGit(seed, ['add', '-A']);
|
|
75
|
+
await runGit(seed, ['commit', '-m', 'one']);
|
|
76
|
+
await runGit(seed, ['push', 'origin', 'main', 'feature']);
|
|
77
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
78
|
+
repo = path.join(workspaceDir, 'knowledge-base');
|
|
79
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
80
|
+
await runGit(root, ['clone', upstream, repo]);
|
|
81
|
+
other = path.join(root, 'other');
|
|
82
|
+
await runGit(root, ['clone', upstream, other]);
|
|
83
|
+
// A second workspace of the same knowledge base, with its own reservation.
|
|
84
|
+
const secondDir = path.join(root, secondWorkspaceId);
|
|
85
|
+
await fs.mkdir(secondDir, { recursive: true });
|
|
86
|
+
await runGit(root, ['clone', upstream, path.join(secondDir, 'knowledge-base')]);
|
|
87
|
+
const workspace = {
|
|
88
|
+
getWorkspacePath: async (id: string) => {
|
|
89
|
+
if (id === workspaceId) return workspaceDir;
|
|
90
|
+
if (id === secondWorkspaceId) return secondDir;
|
|
91
|
+
throw new Error(`unexpected workspace ${id}`);
|
|
92
|
+
},
|
|
93
|
+
} as unknown as WorkspaceService;
|
|
94
|
+
svc = new GitService(
|
|
95
|
+
workspace,
|
|
96
|
+
new WorkflowHooks(),
|
|
97
|
+
testKbContext({ branchModel: { defaultBranch: 'main', protectedBranches: ['main'] } }),
|
|
98
|
+
new WorkspaceMutex(),
|
|
99
|
+
);
|
|
100
|
+
spawnLog.length = 0;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('reads the tips from the ref files, diffs once, and asks git nothing the second time', async () => {
|
|
108
|
+
const first = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
|
|
109
|
+
expect(first.paths).toEqual(['one.md']);
|
|
110
|
+
expect(first.pairs).toEqual([{ path: 'one.md' }]);
|
|
111
|
+
expect(spawnsOf('diff')).toBe(1);
|
|
112
|
+
expect(spawnsOf('rev-parse')).toBe(0);
|
|
113
|
+
|
|
114
|
+
spawnLog.length = 0;
|
|
115
|
+
const second = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
|
|
116
|
+
expect(second).toEqual(first);
|
|
117
|
+
expect(spawnLog).toEqual([]);
|
|
118
|
+
|
|
119
|
+
// The access-check flavour reads the same pair: the memo answers it too.
|
|
120
|
+
spawnLog.length = 0;
|
|
121
|
+
expect(await svc.changedPathsForPr(workspaceId, 'main', 'feature', { fetch: false, forAccessCheck: true })).toEqual(['one.md']);
|
|
122
|
+
expect(spawnLog).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('diffs again, once, when a push moves a tip', async () => {
|
|
126
|
+
await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
|
|
127
|
+
// Someone else pushes to feature; the workspace fetches as a listing's
|
|
128
|
+
// whole-clone refresh does, which rewrites the ref file.
|
|
129
|
+
await runGit(other, ['checkout', 'feature']);
|
|
130
|
+
await fs.writeFile(path.join(other, 'two.md'), 'two\n');
|
|
131
|
+
await runGit(other, ['add', '-A']);
|
|
132
|
+
await runGit(other, ['commit', '-m', 'two']);
|
|
133
|
+
await runGit(other, ['push', 'origin', 'feature']);
|
|
134
|
+
await runGit(repo, ['fetch', 'origin']);
|
|
135
|
+
|
|
136
|
+
spawnLog.length = 0;
|
|
137
|
+
const after = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false });
|
|
138
|
+
expect(after.paths.sort()).toEqual(['one.md', 'two.md']);
|
|
139
|
+
expect(spawnsOf('diff')).toBe(1);
|
|
140
|
+
expect(spawnsOf('rev-parse')).toBe(0);
|
|
141
|
+
|
|
142
|
+
spawnLog.length = 0;
|
|
143
|
+
expect(await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false })).toEqual(after);
|
|
144
|
+
expect(spawnLog).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('shares one diff between concurrent reads of a pair nobody has seen, even from two workspaces', async () => {
|
|
148
|
+
// Two workspaces take two reservations, so nothing serialises the two
|
|
149
|
+
// reads but the in-flight diff itself: both answer, one `diff` runs.
|
|
150
|
+
const [a, b] = await Promise.all([
|
|
151
|
+
svc.changedPathsAndPairsForPr(workspaceId, 'main', 'feature', { fetch: false }),
|
|
152
|
+
svc.changedPathsAndPairsForPr(secondWorkspaceId, 'main', 'feature', { fetch: false }),
|
|
153
|
+
]);
|
|
154
|
+
expect(a).toEqual(b);
|
|
155
|
+
expect(a.paths).toEqual(['one.md']);
|
|
156
|
+
expect(spawnsOf('diff')).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('still asks git about a branch the ref files do not hold, and answers as before', async () => {
|
|
160
|
+
// A branch this clone has never fetched: the files say missing for both
|
|
161
|
+
// of its names, which is rev-parse's answer too — and `fetch: false`
|
|
162
|
+
// then turns into the per-request fetch the caller was promised.
|
|
163
|
+
await runGit(other, ['checkout', '-b', 'later']);
|
|
164
|
+
await fs.writeFile(path.join(other, 'three.md'), 'three\n');
|
|
165
|
+
await runGit(other, ['add', '-A']);
|
|
166
|
+
await runGit(other, ['commit', '-m', 'three']);
|
|
167
|
+
await runGit(other, ['push', 'origin', 'later']);
|
|
168
|
+
|
|
169
|
+
spawnLog.length = 0;
|
|
170
|
+
const got = await svc.changedPathsAndPairsForPr(workspaceId, 'main', 'later', { fetch: false });
|
|
171
|
+
expect(got.paths).toEqual(['three.md']);
|
|
172
|
+
expect(spawnsOf('fetch')).toBe(1);
|
|
173
|
+
expect(spawnsOf('diff')).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -100,3 +100,45 @@ describe('GitService.readFileAtRef', () => {
|
|
|
100
100
|
).rejects.toThrow();
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('GitService.commitAtRef', () => {
|
|
105
|
+
let root: string;
|
|
106
|
+
const workspaceId = 'ws-commit-at-ref-1';
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-commit-at-ref-'));
|
|
110
|
+
});
|
|
111
|
+
afterEach(async () => {
|
|
112
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function makeSvc(workspaceDir: string) {
|
|
116
|
+
return new GitService(
|
|
117
|
+
stubWorkspaceService(workspaceId, workspaceDir),
|
|
118
|
+
new WorkflowHooks(),
|
|
119
|
+
testKbContext({ kbDirName: PROCESS_MAP_DIR }),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('answers the commit a ref names, and null for a ref that names none', async () => {
|
|
124
|
+
const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
|
|
125
|
+
const svc = makeSvc(workspaceDir);
|
|
126
|
+
// Unborn HEAD: a repository with no commit yet names none.
|
|
127
|
+
expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBeNull();
|
|
128
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles: {}\n');
|
|
129
|
+
await runGit(repo, ['add', 'roles.yaml']);
|
|
130
|
+
await runGit(repo, ['commit', '-m', 'add roles']);
|
|
131
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repo });
|
|
132
|
+
expect(await svc.commitAtRef(workspaceId, 'HEAD')).toBe(stdout.trim());
|
|
133
|
+
expect(await svc.commitAtRef(workspaceId, 'origin/does-not-exist')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('THROWS on a failure that is not "names no commit", such as no repository there', async () => {
|
|
137
|
+
// The workspace folder exists; the clone inside it does not. Git fails
|
|
138
|
+
// with "not a git repository", which must not read as an empty one.
|
|
139
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
140
|
+
await fs.mkdir(path.join(workspaceDir, PROCESS_MAP_DIR), { recursive: true });
|
|
141
|
+
const svc = makeSvc(workspaceDir);
|
|
142
|
+
await expect(svc.commitAtRef(workspaceId, 'HEAD')).rejects.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|