@bevel-software/platform-core-backend 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +11 -1
- 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 +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +30 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +112 -23
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +34 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +89 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +0 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +11 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- 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 +13 -15
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +176 -7
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +41 -24
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +35 -27
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +4 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +69 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +153 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +55 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +111 -4
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +92 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +115 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +255 -25
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +2 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +309 -115
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +0 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +170 -1
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +32 -14
- package/src/core/create-core-services.ts +129 -23
- package/src/core/lifecycle.ts +98 -13
- package/src/core-config.ts +13 -14
- package/src/index.ts +12 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access.routes.ts +13 -15
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +179 -6
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +4 -2
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/git.service.ts +168 -0
- package/src/modules/workflow/pending-commits.service.ts +59 -1
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +121 -4
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +256 -24
- package/src/modules/workspace/workspace.tools.ts +338 -119
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
- package/src/tenancy/static-tenant-source.ts +0 -3
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -305,6 +305,37 @@ export class FileLockService {
|
|
|
305
305
|
return rowToFileLock(row);
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Drop every lock held on `branch`, whoever holds it and whichever
|
|
310
|
+
* workspace it sits in, and answer how many rows went.
|
|
311
|
+
*
|
|
312
|
+
* The one caller is a change request being closed because the
|
|
313
|
+
* knowledge-base repository was REPLACED: the branch the lock names belongs
|
|
314
|
+
* to a repository this deployment no longer has, so the holder can neither
|
|
315
|
+
* publish their bytes nor release the lock themselves — and the row would
|
|
316
|
+
* otherwise sit there refusing the path to everyone until its TTL ran out
|
|
317
|
+
* on every heartbeat a still-connected client keeps sending.
|
|
318
|
+
*
|
|
319
|
+
* Expired rows go too: they are already nobody's lock, and leaving them for
|
|
320
|
+
* the lazy sweep would only make this answer harder to read.
|
|
321
|
+
*
|
|
322
|
+
* Deliberately NOT a release in the ordinary sense — nothing is committed
|
|
323
|
+
* and nothing is enqueued. The bytes belonged to a repository that is gone.
|
|
324
|
+
*
|
|
325
|
+
* What an ORDINARY release already enqueued is a different matter, and not
|
|
326
|
+
* this method's to fix: a release queues the bytes and only then drops its
|
|
327
|
+
* lock, so rows can be waiting for a branch whose locks this deletes. The
|
|
328
|
+
* caller takes those out of the worker's reach in the same breath — see
|
|
329
|
+
* `PendingCommitsService.markNeedsAttentionOnBranch`.
|
|
330
|
+
*/
|
|
331
|
+
async releaseAllOnBranch(branch: string): Promise<number> {
|
|
332
|
+
const gone = await this.db
|
|
333
|
+
.delete(fileLocks)
|
|
334
|
+
.where(eq(fileLocks.branch, branch))
|
|
335
|
+
.returning({ path: fileLocks.path });
|
|
336
|
+
return gone.length;
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
/**
|
|
309
340
|
* Is ANY unexpired lock held anywhere in this workspace? Feeds the git
|
|
310
341
|
* status sanity check: a held lock means a mutation is mid-flight — the
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { testKbContext } from '../../../../__tests__/kb-context.js';
|
|
6
|
+
import { GitService } from '../git.service.js';
|
|
7
|
+
import {
|
|
8
|
+
VersionNotOnBranchError,
|
|
9
|
+
VERSION_NOT_ON_BRANCH_MESSAGE,
|
|
10
|
+
WorkflowValidationError,
|
|
11
|
+
} from '../../../../shared/domain-errors.js';
|
|
12
|
+
import { GitRunError, assertOneSpecPerLine } from '../../../../shared/git.contract.js';
|
|
13
|
+
import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A past version has to come back BYTE FOR BYTE, and only for saves on the
|
|
17
|
+
* branch being viewed.
|
|
18
|
+
*
|
|
19
|
+
* `readFileAtRef` decodes git's stdout as UTF-8, which is why a binary could
|
|
20
|
+
* not be served through it at all: every byte sequence that is not valid UTF-8
|
|
21
|
+
* comes back as U+FFFD, so a PNG downloaded from history would not open. The
|
|
22
|
+
* suite pins the round trip on bytes that would not survive that (everything
|
|
23
|
+
* from 0x00 to 0xff, NUL included), the two sides of a save, and the branch
|
|
24
|
+
* rule that decides who may ask at all.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Every byte value, so any decode-and-re-encode step is visible as a failure. */
|
|
28
|
+
const ALL_BYTES = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
29
|
+
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
30
|
+
const V1 = Buffer.concat([PNG_HEADER, ALL_BYTES, Buffer.from('v1')]);
|
|
31
|
+
const V2 = Buffer.concat([PNG_HEADER, ALL_BYTES.subarray(0, 128), Buffer.from('v2\0tail')]);
|
|
32
|
+
|
|
33
|
+
describe('GitService.fileBytesAtCommit', () => {
|
|
34
|
+
let root: string;
|
|
35
|
+
let repo: string;
|
|
36
|
+
let svc: GitService;
|
|
37
|
+
const workspaceId = 'target-company-state';
|
|
38
|
+
/** The save that ADDED logo.png (V1), the save that REPLACED it (V2). */
|
|
39
|
+
let addSha: string;
|
|
40
|
+
let replaceSha: string;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-bytes-at-ref-'));
|
|
44
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
45
|
+
repo = path.join(workspaceDir, 'knowledge-base');
|
|
46
|
+
await fs.mkdir(repo, { recursive: true });
|
|
47
|
+
await runGit(root, ['init', '-b', 'target-company-state', repo]);
|
|
48
|
+
await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
|
|
49
|
+
await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
|
|
50
|
+
|
|
51
|
+
await fs.writeFile(path.join(repo, 'logo.png'), V1);
|
|
52
|
+
await fs.mkdir(path.join(repo, 'docs'));
|
|
53
|
+
await fs.writeFile(path.join(repo, 'docs', 'child.md'), 'child\n');
|
|
54
|
+
await runGit(repo, ['add', '.']);
|
|
55
|
+
await runGit(repo, ['commit', '-m', 'add logo']);
|
|
56
|
+
addSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
57
|
+
|
|
58
|
+
await fs.writeFile(path.join(repo, 'logo.png'), V2);
|
|
59
|
+
await runGit(repo, ['add', '.']);
|
|
60
|
+
await runGit(repo, ['commit', '-m', 'replace logo']);
|
|
61
|
+
replaceSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
62
|
+
|
|
63
|
+
svc = new GitService(
|
|
64
|
+
stubWorkspaceService({ [workspaceId]: workspaceDir }),
|
|
65
|
+
stubWorkflowHooks(),
|
|
66
|
+
testKbContext(),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns the exact bytes of each save, on both sides of it', async () => {
|
|
75
|
+
const atAdd = await svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', addSha, 'after');
|
|
76
|
+
expect(atAdd).not.toBeNull();
|
|
77
|
+
expect(atAdd!.bytes.equals(V1)).toBe(true);
|
|
78
|
+
expect(atAdd!.blobId).toMatch(/^[0-9a-f]{40}$/);
|
|
79
|
+
|
|
80
|
+
const atReplace = await svc.fileBytesAtCommit(
|
|
81
|
+
workspaceId,
|
|
82
|
+
'knowledge-base/logo.png',
|
|
83
|
+
replaceSha,
|
|
84
|
+
'after',
|
|
85
|
+
);
|
|
86
|
+
expect(atReplace!.bytes.equals(V2)).toBe(true);
|
|
87
|
+
|
|
88
|
+
// The `before` side of the replacing save IS the original upload — which
|
|
89
|
+
// is what makes "download the version this save replaced" possible.
|
|
90
|
+
const before = await svc.fileBytesAtCommit(
|
|
91
|
+
workspaceId,
|
|
92
|
+
'knowledge-base/logo.png',
|
|
93
|
+
replaceSha,
|
|
94
|
+
'before',
|
|
95
|
+
);
|
|
96
|
+
expect(before!.bytes.equals(V1)).toBe(true);
|
|
97
|
+
// Different content, therefore a different ETag: the blob id IS the hash.
|
|
98
|
+
expect(atReplace!.blobId).not.toBe(before!.blobId);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('accepts the repo-relative spelling of the path too', async () => {
|
|
102
|
+
const at = await svc.fileBytesAtCommit(workspaceId, 'logo.png', addSha, 'after');
|
|
103
|
+
expect(at!.bytes.equals(V1)).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('serves the version just before the save that DELETED the file', async () => {
|
|
107
|
+
await fs.rm(path.join(repo, 'logo.png'));
|
|
108
|
+
await runGit(repo, ['add', '-A']);
|
|
109
|
+
await runGit(repo, ['commit', '-m', 'remove logo']);
|
|
110
|
+
const delSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
111
|
+
|
|
112
|
+
// Nothing at the path after the save…
|
|
113
|
+
expect(
|
|
114
|
+
await svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', delSha, 'after'),
|
|
115
|
+
).toBeNull();
|
|
116
|
+
// …and the bytes as they were just before it.
|
|
117
|
+
const before = await svc.fileBytesAtCommit(
|
|
118
|
+
workspaceId,
|
|
119
|
+
'knowledge-base/logo.png',
|
|
120
|
+
delSha,
|
|
121
|
+
'before',
|
|
122
|
+
);
|
|
123
|
+
expect(before!.bytes.equals(V2)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns null for a path the save did not have', async () => {
|
|
127
|
+
expect(
|
|
128
|
+
await svc.fileBytesAtCommit(workspaceId, 'knowledge-base/never.png', addSha, 'after'),
|
|
129
|
+
).toBeNull();
|
|
130
|
+
// The root commit has no parent, so it has no `before` side either.
|
|
131
|
+
expect(
|
|
132
|
+
await svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', addSha, 'before'),
|
|
133
|
+
).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses a directory at the ref — history is served per file', async () => {
|
|
137
|
+
await expect(
|
|
138
|
+
svc.fileBytesAtCommit(workspaceId, 'knowledge-base/docs', addSha, 'after'),
|
|
139
|
+
).rejects.toThrow(/history is served per file/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('refuses a path carrying a LINE BREAK, which git would read as two object names', async () => {
|
|
143
|
+
// `cat-file --batch` takes one `<ref>:<path>` spec per LINE. Before the
|
|
144
|
+
// path validator refused control characters, `docs/child.md\nzzz` reached
|
|
145
|
+
// it as two specs: git answered about `docs/child.md` and those bytes came
|
|
146
|
+
// back as this path's — under the access gates of a name that exists
|
|
147
|
+
// nowhere, so the file's own rules were never the ones consulted. Verified
|
|
148
|
+
// against real git here, not a stub: the call must fail, and nothing of
|
|
149
|
+
// `child.md` may come back under the longer name.
|
|
150
|
+
for (const injected of [
|
|
151
|
+
'knowledge-base/docs/child.md\nzzz',
|
|
152
|
+
'knowledge-base/docs/child.md\rzzz',
|
|
153
|
+
'knowledge-base/docs/child.md\nknowledge-base/logo.png',
|
|
154
|
+
]) {
|
|
155
|
+
// Both the TYPE and the sentence. Two different validators are called
|
|
156
|
+
// `assertValidRelativePath` — the shared one in
|
|
157
|
+
// `packages/shared/src/workspace/filename.ts`, which throws a plain
|
|
158
|
+
// `Error('Invalid path: …')`, and the kb-fs one in
|
|
159
|
+
// `modules/kb-fs/branch-name.ts`, which throws `WorkflowValidationError`
|
|
160
|
+
// (status 400). `GitService` imports the kb-fs one, and the 400 is what
|
|
161
|
+
// makes the route answer 400 rather than 500, so the type is asserted
|
|
162
|
+
// here deliberately and the message pins which of the two ran.
|
|
163
|
+
await expect(
|
|
164
|
+
svc.fileBytesAtCommit(workspaceId, injected, addSha, 'after'),
|
|
165
|
+
).rejects.toThrow(WorkflowValidationError);
|
|
166
|
+
await expect(
|
|
167
|
+
svc.fileBytesAtCommit(workspaceId, injected, addSha, 'after'),
|
|
168
|
+
).rejects.toThrow(/control character/);
|
|
169
|
+
await expect(
|
|
170
|
+
svc.fileBytesAtCommit(workspaceId, injected, addSha, 'after'),
|
|
171
|
+
).rejects.toMatchObject({ status: 400 });
|
|
172
|
+
}
|
|
173
|
+
// The refusal is the PATH's, decided before the sha is resolved or the
|
|
174
|
+
// workspace's git turn is taken — so a caller cannot reach git with one.
|
|
175
|
+
await expect(
|
|
176
|
+
svc.fileBytesAtCommit(workspaceId, 'knowledge-base/docs/child.md\nzzz', 'HEAD', 'after'),
|
|
177
|
+
).rejects.toThrow(/control character/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('fences the batch protocol itself against a spec that is not one line', () => {
|
|
181
|
+
// The fence under `blobAtRef` and under the access resolver's batch read,
|
|
182
|
+
// for a spec assembled from somewhere the path validator does not cover.
|
|
183
|
+
expect(() => assertOneSpecPerLine([`${addSha}:logo.png`])).not.toThrow();
|
|
184
|
+
expect(() => assertOneSpecPerLine([`${addSha}:docs/child.md\nzzz`])).toThrow(/line break/);
|
|
185
|
+
expect(() => assertOneSpecPerLine(['a:b', 'c:d\re'])).toThrow(/line break/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('refuses a sha that is not a sha', async () => {
|
|
189
|
+
await expect(
|
|
190
|
+
svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', 'HEAD', 'after'),
|
|
191
|
+
).rejects.toThrow(WorkflowValidationError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('refuses a save that is not in this branch\'s history, and a made-up one, alike', async () => {
|
|
195
|
+
// A save that exists — on another branch this workspace is not viewing.
|
|
196
|
+
await runGit(repo, ['checkout', '-b', 'someone-else']);
|
|
197
|
+
await fs.writeFile(path.join(repo, 'logo.png'), Buffer.from('elsewhere'));
|
|
198
|
+
await runGit(repo, ['add', '-A']);
|
|
199
|
+
await runGit(repo, ['commit', '-m', 'elsewhere']);
|
|
200
|
+
const otherSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
201
|
+
await runGit(repo, ['checkout', 'target-company-state']);
|
|
202
|
+
|
|
203
|
+
for (const sha of [otherSha, 'a'.repeat(40)]) {
|
|
204
|
+
const read = svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', sha, 'after');
|
|
205
|
+
await expect(read).rejects.toThrow(VersionNotOnBranchError);
|
|
206
|
+
await expect(read).rejects.toThrow(VERSION_NOT_ON_BRANCH_MESSAGE);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// …and every save the panel LISTS is accepted, which is the other half of
|
|
210
|
+
// the rule: `logForFile` walks HEAD, so anything it returns is an ancestor.
|
|
211
|
+
// Asserted through the read itself rather than a boolean helper, so what is
|
|
212
|
+
// pinned is the thing the route calls.
|
|
213
|
+
for (const listed of await svc.logForFile(workspaceId, 'knowledge-base/logo.png')) {
|
|
214
|
+
await expect(
|
|
215
|
+
svc.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', listed.sha, 'after'),
|
|
216
|
+
).resolves.not.toBeNull();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('propagates a repository failure rather than calling it a version off the branch', async () => {
|
|
221
|
+
// An UNBORN HEAD: the sha resolves perfectly well and git's complaint is
|
|
222
|
+
// about the other argument ('ambiguous argument HEAD'). Folded into
|
|
223
|
+
// `VersionNotOnBranchError` it would tell a reader the save they can see
|
|
224
|
+
// listed is not in their history — a broken workspace dressed up as an
|
|
225
|
+
// answer about access, and one 'Try again' can never get past.
|
|
226
|
+
await runGit(repo, ['checkout', '--orphan', 'nothing-committed-yet']);
|
|
227
|
+
const err: unknown = await svc
|
|
228
|
+
.fileBytesAtCommit(workspaceId, 'knowledge-base/logo.png', addSha, 'after')
|
|
229
|
+
.catch((e: unknown) => e);
|
|
230
|
+
// `GitRunError` specifically, not just `Error`: the route reads the error's
|
|
231
|
+
// TYPE to decide its status, so a future re-wrap into
|
|
232
|
+
// `WorkflowValidationError` would turn this into a 400 about the caller's
|
|
233
|
+
// request rather than the logged 500 a broken repository earns, and a bare
|
|
234
|
+
// `toBeInstanceOf(Error)` would not notice.
|
|
235
|
+
expect(err).toBeInstanceOf(GitRunError);
|
|
236
|
+
expect(err).not.toBeInstanceOf(VersionNotOnBranchError);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('applies the same branch rule to the existing text-history reads', async () => {
|
|
240
|
+
await runGit(repo, ['checkout', '-b', 'someone-else']);
|
|
241
|
+
await fs.writeFile(path.join(repo, 'logo.png'), Buffer.from('elsewhere'));
|
|
242
|
+
await runGit(repo, ['add', '-A']);
|
|
243
|
+
await runGit(repo, ['commit', '-m', 'elsewhere']);
|
|
244
|
+
const otherSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
245
|
+
await runGit(repo, ['checkout', 'target-company-state']);
|
|
246
|
+
|
|
247
|
+
// Otherwise the bytes route's rule could simply be read around: the patch
|
|
248
|
+
// and the full before/after text are past versions too.
|
|
249
|
+
await expect(
|
|
250
|
+
svc.diffFileAtCommit(workspaceId, 'knowledge-base/logo.png', otherSha),
|
|
251
|
+
).rejects.toThrow(VERSION_NOT_ON_BRANCH_MESSAGE);
|
|
252
|
+
await expect(
|
|
253
|
+
svc.fileContentsAtCommit(workspaceId, 'knowledge-base/logo.png', otherSha),
|
|
254
|
+
).rejects.toThrow(VERSION_NOT_ON_BRANCH_MESSAGE);
|
|
255
|
+
// The on-branch saves still work.
|
|
256
|
+
expect(await svc.diffFileAtCommit(workspaceId, 'knowledge-base/logo.png', replaceSha)).toContain(
|
|
257
|
+
'logo.png',
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -35,10 +35,12 @@ import {
|
|
|
35
35
|
ProtectedBranchError,
|
|
36
36
|
PullRebaseConflictError,
|
|
37
37
|
RemoteBranchGoneError,
|
|
38
|
+
VersionNotOnBranchError,
|
|
38
39
|
isMissingRemoteBranchFailure,
|
|
39
40
|
} from '../../../shared/domain-errors.js';
|
|
40
41
|
import {
|
|
41
42
|
GitRunError,
|
|
43
|
+
assertOneSpecPerLine,
|
|
42
44
|
isGitTimeout,
|
|
43
45
|
redactGitToken,
|
|
44
46
|
type GitCredentials,
|
|
@@ -2331,6 +2333,10 @@ export class GitService implements IGitService {
|
|
|
2331
2333
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
2332
2334
|
return this.mutex.run(workspaceId, async () => {
|
|
2333
2335
|
const cwd = await this.repoDir(workspaceId);
|
|
2336
|
+
// Branch-scoped, like the bytes route: a patch is a past version too,
|
|
2337
|
+
// and leaving this route open would let a sha refused there be read
|
|
2338
|
+
// here instead.
|
|
2339
|
+
await this.assertOnBranchHistory(cwd, sha);
|
|
2334
2340
|
// BOTH sides: at a commit that DELETES a directory the path is absent
|
|
2335
2341
|
// at `sha` but a tree at `sha^` — and the diff of that deletion is
|
|
2336
2342
|
// every child's content, exactly what the per-file gate never checked.
|
|
@@ -2382,6 +2388,10 @@ export class GitService implements IGitService {
|
|
|
2382
2388
|
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
2383
2389
|
return this.mutex.run(workspaceId, async () => {
|
|
2384
2390
|
const cwd = await this.repoDir(workspaceId);
|
|
2391
|
+
// Same branch rule as the patch and the bytes: this route serves the
|
|
2392
|
+
// FULL text of both sides, so it is the most generous of the three and
|
|
2393
|
+
// the least safe one to leave ungated.
|
|
2394
|
+
await this.assertOnBranchHistory(cwd, sha);
|
|
2385
2395
|
await this.assertNotTreeAtRef(cwd, sha, repoRelativePath, relativePath);
|
|
2386
2396
|
await this.assertNotTreeAtRef(cwd, `${sha}^`, repoRelativePath, relativePath);
|
|
2387
2397
|
const current = await this.readFileAtRef(workspaceId, sha, repoRelativePath);
|
|
@@ -3269,6 +3279,143 @@ export class GitService implements IGitService {
|
|
|
3269
3279
|
}
|
|
3270
3280
|
}
|
|
3271
3281
|
|
|
3282
|
+
/**
|
|
3283
|
+
* A repo-relative file's BYTES at an arbitrary git ref — the binary sibling
|
|
3284
|
+
* of {@link readFileAtRef}, with the blob's object id alongside them.
|
|
3285
|
+
*
|
|
3286
|
+
* `readFileAtRef` decodes stdout as UTF-8, which for a PNG or a PDF is not a
|
|
3287
|
+
* lossless round trip: every byte sequence that is not valid UTF-8 comes
|
|
3288
|
+
* back as U+FFFD and the file is ruined. A past version has to be delivered
|
|
3289
|
+
* byte for byte, so this reads through `cat-file --batch`, whose framing is
|
|
3290
|
+
* in BYTES (`<oid> <type> <size>\n<size bytes>\n`) rather than characters.
|
|
3291
|
+
*
|
|
3292
|
+
* One invocation, and absence is a PROTOCOL answer (`<spec> missing`) rather
|
|
3293
|
+
* than a parsed error message — which also covers `<sha>^` of a root commit,
|
|
3294
|
+
* where there is no "before" side at all. `null` therefore means exactly
|
|
3295
|
+
* "nothing is at that path on that side".
|
|
3296
|
+
*
|
|
3297
|
+
* The `oid` is the content's own hash, so it is what the HTTP route uses as
|
|
3298
|
+
* an ETag: cheaper than hashing the body again, and stable for a version
|
|
3299
|
+
* that by definition can never change.
|
|
3300
|
+
*/
|
|
3301
|
+
private async blobAtRef(
|
|
3302
|
+
cwd: string,
|
|
3303
|
+
ref: string,
|
|
3304
|
+
repoRelativePath: string,
|
|
3305
|
+
): Promise<{ bytes: Buffer; blobId: string } | null> {
|
|
3306
|
+
// The spec has to be ONE line: the path is refused a control character
|
|
3307
|
+
// twice over before it gets here (at the route's normaliser and again at
|
|
3308
|
+
// `assertValidRelativePath`), and this is the fence at the protocol —
|
|
3309
|
+
// `--batch` would otherwise read `<sha>:Docs/x.md\nzzz` as two object
|
|
3310
|
+
// names and answer about the first, under the gates of the second.
|
|
3311
|
+
const spec = `${ref}:${repoRelativePath}`;
|
|
3312
|
+
assertOneSpecPerLine([spec]);
|
|
3313
|
+
const { stdout } = await this.gitBytes(cwd, ['cat-file', '--batch'], {
|
|
3314
|
+
input: `${spec}\n`,
|
|
3315
|
+
});
|
|
3316
|
+
const nl = stdout.indexOf(0x0a);
|
|
3317
|
+
if (nl < 0) throw new Error('unexpected end of git cat-file output');
|
|
3318
|
+
// The spec may itself contain spaces (a KB file routinely does), hence the
|
|
3319
|
+
// endsWith checks rather than a split — same protocol reading as the
|
|
3320
|
+
// access resolver's `catFileBatch`.
|
|
3321
|
+
const header = stdout.subarray(0, nl).toString('utf-8');
|
|
3322
|
+
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) return null;
|
|
3323
|
+
const [blobId, type, rawSize] = header.split(' ');
|
|
3324
|
+
// A tree here would be the per-file gate bypassed, and `assertNotTreeAtRef`
|
|
3325
|
+
// is what refuses it before this runs; anything else non-blob (a tag, a
|
|
3326
|
+
// commit) is not a file either. Nothing to serve in either case.
|
|
3327
|
+
if (type !== 'blob' || blobId === undefined) return null;
|
|
3328
|
+
const size = Number(rawSize);
|
|
3329
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
3330
|
+
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
3331
|
+
}
|
|
3332
|
+
return { bytes: stdout.subarray(nl + 1, nl + 1 + size), blobId };
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
/**
|
|
3336
|
+
* Refuse a sha that is not in the history of the branch this workspace has
|
|
3337
|
+
* checked out.
|
|
3338
|
+
*
|
|
3339
|
+
* A file's past is served per BRANCH: the history panel lists what `git log`
|
|
3340
|
+
* on this workspace's own branch touched, and every read of a past save has
|
|
3341
|
+
* to stay inside that list. Without this, a sha lifted from another branch —
|
|
3342
|
+
* or from a change request nobody approved — would be readable through the
|
|
3343
|
+
* workspace of a branch whose history never contained it, which is the
|
|
3344
|
+
* access rules of the viewed branch bypassed by a query parameter.
|
|
3345
|
+
*
|
|
3346
|
+
* `merge-base --is-ancestor` is the exact question, and a commit is its own
|
|
3347
|
+
* ancestor, so the branch head passes. Exit 1 is git's answer for "resolved,
|
|
3348
|
+
* and not an ancestor". An unresolvable object name — a made-up id — gets
|
|
3349
|
+
* the SAME refusal on purpose (see {@link VersionNotOnBranchError}), but only
|
|
3350
|
+
* when git's complaint NAMES that id; every other failure propagates, a
|
|
3351
|
+
* broken repository included. A guard that fails open on its own errors is
|
|
3352
|
+
* not a guard, and one that reports them as a refusal is worse than no
|
|
3353
|
+
* message at all.
|
|
3354
|
+
*/
|
|
3355
|
+
private async assertOnBranchHistory(cwd: string, sha: string): Promise<void> {
|
|
3356
|
+
try {
|
|
3357
|
+
await this.git(cwd, ['merge-base', '--is-ancestor', sha, 'HEAD']);
|
|
3358
|
+
} catch (err) {
|
|
3359
|
+
if (err instanceof GitRunError && !err.timedOut) {
|
|
3360
|
+
if (err.exitCode === 1) throw new VersionNotOnBranchError();
|
|
3361
|
+
const stderr = err.stderr ?? err.message;
|
|
3362
|
+
if (
|
|
3363
|
+
// The complaint has to be about THE SHA WE ASKED FOR. `HEAD` is the
|
|
3364
|
+
// other argument, and git spells its own failures the same way: a
|
|
3365
|
+
// repository with no commits answers "ambiguous argument 'HEAD'",
|
|
3366
|
+
// which is a broken workspace, not a version off the branch.
|
|
3367
|
+
// Refusing it as "not in this file's history" would hide an
|
|
3368
|
+
// infrastructure failure behind a sentence about access.
|
|
3369
|
+
stderr.toLowerCase().includes(sha.toLowerCase()) &&
|
|
3370
|
+
// git's wording for "I cannot resolve that" varies by subcommand and
|
|
3371
|
+
// by version — `merge-base` says "Not a valid commit name", others
|
|
3372
|
+
// "not a valid object name" / "unknown revision" — so every spelling
|
|
3373
|
+
// that means the same thing is listed rather than one guessed at.
|
|
3374
|
+
/not a valid (object|commit) name|no such commit|bad object|unknown revision|malformed object name|ambiguous argument/i.test(
|
|
3375
|
+
stderr,
|
|
3376
|
+
)
|
|
3377
|
+
) {
|
|
3378
|
+
throw new VersionNotOnBranchError();
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
throw err;
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
/**
|
|
3386
|
+
* One file's BYTES at a save, exactly as they were — the `after` side of
|
|
3387
|
+
* `sha`, or the `before` side (`<sha>^`) for the save that deleted it.
|
|
3388
|
+
*
|
|
3389
|
+
* Every guard the text history surfaces apply, in the same order: the sha is
|
|
3390
|
+
* a sha, the save is on this branch, and the pathspec is not a directory at
|
|
3391
|
+
* that ref (a per-file gate upstream authorized one path, and a tree read
|
|
3392
|
+
* would hand over every child). `null` means the file was not there on the
|
|
3393
|
+
* side asked for.
|
|
3394
|
+
*
|
|
3395
|
+
* Runs inside the workspace's git turn — one turn for the whole read, so the
|
|
3396
|
+
* ancestry check and the bytes it authorizes cannot be separated by another
|
|
3397
|
+
* caller's checkout.
|
|
3398
|
+
*/
|
|
3399
|
+
async fileBytesAtCommit(
|
|
3400
|
+
workspaceId: string,
|
|
3401
|
+
relativePath: string,
|
|
3402
|
+
sha: string,
|
|
3403
|
+
side: 'after' | 'before',
|
|
3404
|
+
): Promise<{ bytes: Buffer; blobId: string } | null> {
|
|
3405
|
+
assertValidRelativePath(relativePath);
|
|
3406
|
+
if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
|
|
3407
|
+
throw new WorkflowValidationError('invalid commit sha');
|
|
3408
|
+
}
|
|
3409
|
+
const repoRelativePath = this.stripRepoPrefix(relativePath);
|
|
3410
|
+
return this.mutex.run(workspaceId, async () => {
|
|
3411
|
+
const cwd = await this.repoDir(workspaceId);
|
|
3412
|
+
await this.assertOnBranchHistory(cwd, sha);
|
|
3413
|
+
const ref = side === 'before' ? `${sha}^` : sha;
|
|
3414
|
+
await this.assertNotTreeAtRef(cwd, ref, repoRelativePath, relativePath);
|
|
3415
|
+
return this.blobAtRef(cwd, ref, repoRelativePath);
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3272
3419
|
/**
|
|
3273
3420
|
* The commits on `ref` that touched `repoRelativePath`, newest first, at
|
|
3274
3421
|
* most `limit` of them. What a skill's `version` is looked up through: each
|
|
@@ -3377,6 +3524,27 @@ export class GitService implements IGitService {
|
|
|
3377
3524
|
env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
|
|
3378
3525
|
});
|
|
3379
3526
|
}
|
|
3527
|
+
|
|
3528
|
+
/**
|
|
3529
|
+
* {@link git}, with stdout as raw BYTES.
|
|
3530
|
+
*
|
|
3531
|
+
* A sibling rather than an `encoding` option on `git()` itself: every other
|
|
3532
|
+
* caller in this service reads text, and the two return types (`string` vs
|
|
3533
|
+
* `Buffer`) are not interchangeable at any call site. Same
|
|
3534
|
+
* `GIT_LITERAL_PATHSPECS` and same runner deadline — the only difference is
|
|
3535
|
+
* that the bytes are not decoded.
|
|
3536
|
+
*/
|
|
3537
|
+
private gitBytes(
|
|
3538
|
+
cwd: string,
|
|
3539
|
+
args: string[],
|
|
3540
|
+
opts?: Omit<GitRunOptions, 'encoding'>,
|
|
3541
|
+
): Promise<GitRunResult<Buffer>> {
|
|
3542
|
+
return this.gitRunner.run(cwd, args, {
|
|
3543
|
+
...opts,
|
|
3544
|
+
encoding: 'buffer',
|
|
3545
|
+
env: { GIT_LITERAL_PATHSPECS: '1', ...opts?.env },
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3380
3548
|
}
|
|
3381
3549
|
|
|
3382
3550
|
/** Map a git status letter (`git diff --name-status`) to the DTO's `PrFileStatus`. */
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* (status='needs_attention')
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import { and, eq, inArray, isNull, min, or, sql } from 'drizzle-orm';
|
|
37
|
+
import { and, eq, inArray, isNull, min, or, sql, type SQL } from 'drizzle-orm';
|
|
38
38
|
import { logger } from '../../shared/logging.js';
|
|
39
39
|
|
|
40
40
|
const log = logger('pending-commits');
|
|
@@ -384,6 +384,64 @@ export class PendingCommitsService {
|
|
|
384
384
|
.where(eq(pendingCommits.id, id));
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Take every commit still queued for `branch` out of the worker's reach and
|
|
389
|
+
* onto the admin surface, and answer how many moved.
|
|
390
|
+
*
|
|
391
|
+
* The one caller is the knowledge-base repository being REPLACED. A release
|
|
392
|
+
* ENQUEUES the bytes and only then drops its lock, so the moment the locks
|
|
393
|
+
* on a branch are dropped there may be rows already waiting — bytes that
|
|
394
|
+
* were written against a working copy of the repository that is gone. Left
|
|
395
|
+
* `pending`, the worker would pick them up after the re-clone and commit
|
|
396
|
+
* them onto a same-named branch of a DIFFERENT repository: someone else's
|
|
397
|
+
* file, overwritten by a change nobody made to it.
|
|
398
|
+
*
|
|
399
|
+
* `needs_attention`, not deleted — the same rule as everything else about a
|
|
400
|
+
* replacement. The bytes are still in the row, the reason is on it, and they
|
|
401
|
+
* show up on the pending-commit admin surface for a person to decide about.
|
|
402
|
+
* `running` rows go too: the worker checks the row back in when it finishes,
|
|
403
|
+
* and a row it is mid-flight with is exactly one whose destination just
|
|
404
|
+
* changed under it.
|
|
405
|
+
*/
|
|
406
|
+
async markNeedsAttentionOnBranch(branch: string, error: string): Promise<number> {
|
|
407
|
+
return this.escalateQueued(eq(pendingCommits.branch, branch), error);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Every commit still queued that `which` selects goes to a person, with the reason. Answers how many. */
|
|
411
|
+
private async escalateQueued(which: SQL, error: string): Promise<number> {
|
|
412
|
+
const moved = await this.db
|
|
413
|
+
.update(pendingCommits)
|
|
414
|
+
.set({
|
|
415
|
+
status: 'needs_attention',
|
|
416
|
+
lastError: error,
|
|
417
|
+
lastAttemptedAt: new Date(),
|
|
418
|
+
})
|
|
419
|
+
.where(and(which, inArray(pendingCommits.status, ['pending', 'running'])))
|
|
420
|
+
.returning({ id: pendingCommits.id });
|
|
421
|
+
return moved.length;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The same, for every commit still queued against ONE WORKING COPY, and
|
|
426
|
+
* answer how many moved.
|
|
427
|
+
*
|
|
428
|
+
* The caller is the startup phase setting a working copy aside: a clone of
|
|
429
|
+
* a repository the configured one shares no history with. Whatever was
|
|
430
|
+
* queued for it was written against that clone, on any branch, the default
|
|
431
|
+
* one included, and the directory the worker would write to is now a fresh
|
|
432
|
+
* clone of another repository. Closing change requests covers the branches
|
|
433
|
+
* of the requests that were closed and nothing else, which is why this is
|
|
434
|
+
* keyed on the working copy: it is the thing that was replaced.
|
|
435
|
+
*
|
|
436
|
+
* A working copy that was only pointed at a new address (the same
|
|
437
|
+
* repository, moved) keeps its queue, and is never passed here.
|
|
438
|
+
*/
|
|
439
|
+
async markNeedsAttentionInWorkspace(workspaceId: string, error: string): Promise<number> {
|
|
440
|
+
// The canonical form `enqueue` stores (see `canonicalWorkspaceId`), so the
|
|
441
|
+
// caller's spelling of the id cannot make this match nothing.
|
|
442
|
+
return this.escalateQueued(eq(pendingCommits.workspaceId, canonicalWorkspaceId(workspaceId)), error);
|
|
443
|
+
}
|
|
444
|
+
|
|
387
445
|
/**
|
|
388
446
|
* When the oldest commit still waiting to land was queued, or null when
|
|
389
447
|
* nothing waits — the readiness answer's one number. `pending` and
|