@bevel-software/platform-core-backend 0.29.0 → 0.31.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 +49 -4
- package/agent-guide/html-views.md +76 -2
- 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.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +3 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +35 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- 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 +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +135 -17
- 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/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +1 -1
- 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/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 +3 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +3 -1
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- 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/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- 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/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/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +10 -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/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +142 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +645 -128
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- 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 +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- 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 +9 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -1
- 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 +162 -336
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -1
- 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__/lifecycle.test.ts +16 -0
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +3 -2
- package/src/core/create-core-services.ts +39 -4
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- 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 +138 -15
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- 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/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -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 +3 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- 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/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/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +10 -2
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +719 -126
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- 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 +153 -298
- 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 +23 -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 +179 -365
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/no-link-below.ts +29 -0
- package/src/test-setup.ts +5 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { Router, RequestHandler } from 'express';
|
|
2
2
|
import type { IToolRegistry, JsonSchema } from '../../tool-registry/tool.contract.js';
|
|
3
|
-
import { ToolError, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
3
|
+
import { ToolError, hasHttpStatus, type ToolContext, type ToolHandler } from '../../tool-helpers/tool.contract.js';
|
|
4
|
+
import { isCommitSha } from '../../../shared/git.contract.js';
|
|
4
5
|
import { toolDef } from '../../tool-helpers/tool-def.js';
|
|
5
6
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
6
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
9
|
+
import { assertAgentWriteAllowed, notifyAgentRead, SESSION_ID_INPUT, type AgentAccessGate } from '../../workspace/index.js';
|
|
10
|
+
import { readablePathsAt, readableAtCommits, assertReadableAtCommits } from '../read-on-branch.js';
|
|
7
11
|
import type { KbContext } from '../../../shared/kb-context.js';
|
|
8
12
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
13
|
import { assertInsideRepo, normalizePathArgs } from '../../kb-fs/repo-path.js';
|
|
@@ -176,6 +180,21 @@ export function registerWorkflowTools(
|
|
|
176
180
|
* nor links a request it answers no for.
|
|
177
181
|
*/
|
|
178
182
|
maySeeChangeRequest: (ctx: ToolContext, number: number) => Promise<boolean>,
|
|
183
|
+
/**
|
|
184
|
+
* The per-file read gate the history tools apply: a file's past is the
|
|
185
|
+
* file, so a history read takes the read the file itself takes — on the
|
|
186
|
+
* workspace for its own branch's history (`canRead`), at the commit
|
|
187
|
+
* another branch's content was served from (`canReadAtRef`, through
|
|
188
|
+
* `readableAtCommits`, the compare route's own rule), and in batches for
|
|
189
|
+
* the path lists `branch_status` shows.
|
|
190
|
+
*/
|
|
191
|
+
accessControl: Pick<IAccessControl, 'canRead' | 'canReadAtRef' | 'canReadBatch' | 'canReadBatchAtRef'>,
|
|
192
|
+
/**
|
|
193
|
+
* The deployment's blocking read hook, told of every file content a history
|
|
194
|
+
* tool serves, as `read_file` tells it — a hook that refuses the read
|
|
195
|
+
* refuses the history too.
|
|
196
|
+
*/
|
|
197
|
+
agentAccessGate: AgentAccessGate,
|
|
179
198
|
): void {
|
|
180
199
|
const { kbDirName } = kb;
|
|
181
200
|
const mount = (spec: {
|
|
@@ -748,4 +767,446 @@ export function registerWorkflowTools(
|
|
|
748
767
|
};
|
|
749
768
|
},
|
|
750
769
|
});
|
|
770
|
+
|
|
771
|
+
// ── History and syncing ────────────────────────────────────────────────────
|
|
772
|
+
//
|
|
773
|
+
// What the app's History tab, compare view and "update from target" do,
|
|
774
|
+
// as tools: the agent never runs git itself. Each reads through the
|
|
775
|
+
// workflow service's gated methods and takes the per-file read the file
|
|
776
|
+
// itself takes.
|
|
777
|
+
|
|
778
|
+
/** The branch's workspace, made when it is not on disk yet, and its id. */
|
|
779
|
+
const workspaceOf = async (ctx: ToolContext, branch: string): Promise<string> => {
|
|
780
|
+
await ctx.workspaceService.getOrCreateForBranch(branch);
|
|
781
|
+
return workspaceIdForBranch(branch);
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The per-file read gate on the workspace's own branch, and the
|
|
786
|
+
* deployment's read hook told of the read (as `read_file` tells it);
|
|
787
|
+
* answers the repository-relative path.
|
|
788
|
+
*/
|
|
789
|
+
const requireRead = async (ctx: ToolContext, branch: string, workspaceId: string, path: string): Promise<string> => {
|
|
790
|
+
const rel = stripKbDir(path, kbDirName);
|
|
791
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, path);
|
|
792
|
+
if (!(await accessControl.canRead(workspaceId, ctx.user.email, rel))) {
|
|
793
|
+
throw new ToolError(`You don't have permission to read "${rel}".`, 403, { kind: 'read-denied', path: rel });
|
|
794
|
+
}
|
|
795
|
+
return rel;
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The per-file read gate for content served from ANOTHER branch — the
|
|
800
|
+
* compare route's rule: at every commit the branch resolves to (the one
|
|
801
|
+
* the service serves it from, and its other tip when the local head and
|
|
802
|
+
* the published tip differ), all of which must grant, asked by the
|
|
803
|
+
* service inside its reservation before anything is said about the path
|
|
804
|
+
* there.
|
|
805
|
+
*/
|
|
806
|
+
const requireReadAtCommits = (ctx: ToolContext, workspaceId: string, rel: string, commits: readonly string[], branch: string): Promise<void> =>
|
|
807
|
+
assertReadableAtCommits(accessControl, workspaceId, ctx.user.email, rel, commits, branch);
|
|
808
|
+
|
|
809
|
+
/** Which of `paths` the caller may read at `origin/<base>` — the change-request reads' rule, `readablePathsAt`. */
|
|
810
|
+
const readableAt = (ctx: ToolContext, workspaceId: string, base: string, paths: string[]): Promise<Set<string>> =>
|
|
811
|
+
readablePathsAt(accessControl, workspaceId, ctx.user.email, base, paths);
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
mount({
|
|
815
|
+
name: 'list_file_changes',
|
|
816
|
+
description:
|
|
817
|
+
'The history of one file on `branch`: its commits, newest first — who made each, when, and its ' +
|
|
818
|
+
'subject — as the app\'s History tab shows them. `limit` caps the list (default 20, at most 100). ' +
|
|
819
|
+
'Read a version with `read_file_at` and its diff with `compare_file`. Takes the read the file itself ' +
|
|
820
|
+
'takes; a folder has no history of its own (ask per file).',
|
|
821
|
+
inputs: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
properties: {
|
|
824
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative (as `read_file` takes it).' },
|
|
825
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many commits at most (default 20).' },
|
|
826
|
+
sessionId: SESSION_ID_INPUT,
|
|
827
|
+
},
|
|
828
|
+
required: ['path'],
|
|
829
|
+
additionalProperties: false,
|
|
830
|
+
},
|
|
831
|
+
outputs: {
|
|
832
|
+
type: 'object',
|
|
833
|
+
properties: {
|
|
834
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
835
|
+
changes: { type: 'array', items: commitSchema, description: 'Newest first.' },
|
|
836
|
+
},
|
|
837
|
+
required: ['path', 'changes'],
|
|
838
|
+
},
|
|
839
|
+
write: false,
|
|
840
|
+
handler: async (args, ctx: ToolContext) => {
|
|
841
|
+
const branch = args.branch as string;
|
|
842
|
+
const path = args.path;
|
|
843
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
844
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
845
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
846
|
+
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
847
|
+
const changes = await ctx.workflowService.listChangesForFile(workspaceId, path, limit);
|
|
848
|
+
return { path: rel, changes };
|
|
849
|
+
},
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
mount({
|
|
853
|
+
name: 'read_file_at',
|
|
854
|
+
description:
|
|
855
|
+
'One file as it was at a point in history: `at` is a commit (from `list_file_changes`, on `branch`\'s ' +
|
|
856
|
+
'own history) or the name of another branch (its published tip). Answers `content` (null when the ' +
|
|
857
|
+
'file was not there) and `sha`, the commit it was read at; for a commit, also `before`, the file ' +
|
|
858
|
+
'just before that commit. To put an earlier version back, write it with `write_file`. Takes the read ' +
|
|
859
|
+
'the file takes at that point.',
|
|
860
|
+
inputs: {
|
|
861
|
+
type: 'object',
|
|
862
|
+
properties: {
|
|
863
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
864
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha, or a branch name.' },
|
|
865
|
+
sessionId: SESSION_ID_INPUT,
|
|
866
|
+
},
|
|
867
|
+
required: ['path', 'at'],
|
|
868
|
+
additionalProperties: false,
|
|
869
|
+
},
|
|
870
|
+
outputs: {
|
|
871
|
+
type: 'object',
|
|
872
|
+
properties: {
|
|
873
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
874
|
+
at: { type: 'string', description: 'What was asked for.' },
|
|
875
|
+
kind: { type: 'string', enum: ['commit', 'branch'] },
|
|
876
|
+
sha: { type: 'string', description: 'The commit the content was read at.' },
|
|
877
|
+
content: { type: ['string', 'null'], description: 'The file at that commit; null when it was not there.' },
|
|
878
|
+
before: { type: ['string', 'null'], description: 'For a commit: the file just before it; null when it was not there (or the commit is the first).' },
|
|
879
|
+
},
|
|
880
|
+
required: ['path', 'at', 'kind', 'sha', 'content'],
|
|
881
|
+
},
|
|
882
|
+
write: false,
|
|
883
|
+
handler: async (args, ctx: ToolContext) => {
|
|
884
|
+
const branch = args.branch as string;
|
|
885
|
+
const path = args.path;
|
|
886
|
+
const at = args.at;
|
|
887
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
888
|
+
if (typeof at !== 'string' || at.length === 0) throw new ToolError('`at` is required: a commit sha or a branch name.', 400);
|
|
889
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
890
|
+
// The hook and the workspace gate first, whichever point in history is
|
|
891
|
+
// read: the file is this workspace's file.
|
|
892
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
893
|
+
if (isCommitSha(at)) {
|
|
894
|
+
const { baseline, current } = await ctx.workflowService.fileAtChange(workspaceId, path, at);
|
|
895
|
+
return { path: rel, at, kind: 'commit', sha: at, content: current, before: baseline };
|
|
896
|
+
}
|
|
897
|
+
// Read at the branch's published tip, authorized at that very commit
|
|
898
|
+
// inside the read — before anything is said about the path there,
|
|
899
|
+
// the folder refusal included: the ref is a name a fetch may move
|
|
900
|
+
// between a verdict and a read; the commit is not.
|
|
901
|
+
const read = await ctx.workflowService.fileAtBranch(workspaceId, path, at, (commits) => requireReadAtCommits(ctx, workspaceId, rel, commits, at));
|
|
902
|
+
return { path: rel, at, kind: 'branch', sha: read.sha, content: read.content };
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
mount({
|
|
907
|
+
name: 'compare_file',
|
|
908
|
+
description:
|
|
909
|
+
'The unified diff of one file: between two branches (`from`, `to`; either may be `branch` itself), ' +
|
|
910
|
+
'or of one commit on `branch` (`at`: what that commit changed in the file). Empty when identical. ' +
|
|
911
|
+
'Takes the read the file takes on each side.',
|
|
912
|
+
inputs: {
|
|
913
|
+
type: 'object',
|
|
914
|
+
properties: {
|
|
915
|
+
path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
|
|
916
|
+
from: { type: 'string', minLength: 1, description: 'A branch: the diff\'s left side.' },
|
|
917
|
+
to: { type: 'string', minLength: 1, description: 'A branch: the diff\'s right side.' },
|
|
918
|
+
at: { type: 'string', minLength: 1, description: 'A commit sha on `branch`: the diff that commit made in the file.' },
|
|
919
|
+
sessionId: SESSION_ID_INPUT,
|
|
920
|
+
},
|
|
921
|
+
required: ['path'],
|
|
922
|
+
additionalProperties: false,
|
|
923
|
+
},
|
|
924
|
+
outputs: {
|
|
925
|
+
type: 'object',
|
|
926
|
+
properties: {
|
|
927
|
+
path: { type: 'string', description: 'The file, repository-relative.' },
|
|
928
|
+
diff: { type: 'string', description: 'A unified diff; empty when the two sides are identical.' },
|
|
929
|
+
},
|
|
930
|
+
required: ['path', 'diff'],
|
|
931
|
+
},
|
|
932
|
+
write: false,
|
|
933
|
+
handler: async (args, ctx: ToolContext) => {
|
|
934
|
+
const branch = args.branch as string;
|
|
935
|
+
const path = args.path;
|
|
936
|
+
if (typeof path !== 'string' || path.length === 0) throw new ToolError('`path` is required.', 400);
|
|
937
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
938
|
+
// As the compare route: the workspace's own gate on the path (the
|
|
939
|
+
// checked-out side can be served from the working tree), then each
|
|
940
|
+
// compared side at the commit it was served from.
|
|
941
|
+
const rel = await requireRead(ctx, branch, workspaceId, path);
|
|
942
|
+
if (typeof args.at === 'string' && args.at.length > 0) {
|
|
943
|
+
if (!isCommitSha(args.at)) throw new ToolError('`at` must be a full commit sha.', 400);
|
|
944
|
+
return { path: rel, diff: await ctx.workflowService.showFileAtChange(workspaceId, path, args.at) };
|
|
945
|
+
}
|
|
946
|
+
const from = args.from;
|
|
947
|
+
const to = args.to;
|
|
948
|
+
if (typeof from !== 'string' || from.length === 0 || typeof to !== 'string' || to.length === 0) {
|
|
949
|
+
throw new ToolError('Give `from` and `to` (two branches), or `at` (one commit).', 400);
|
|
950
|
+
}
|
|
951
|
+
const compared = await ctx.workflowService.compareFile(workspaceId, path, from, to, async (sides) => {
|
|
952
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.from, from);
|
|
953
|
+
await requireReadAtCommits(ctx, workspaceId, rel, sides.to, to);
|
|
954
|
+
});
|
|
955
|
+
return { path: rel, diff: compared.diff };
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
mount({
|
|
960
|
+
name: 'branch_status',
|
|
961
|
+
description:
|
|
962
|
+
'Where `branch` stands, before proposing or syncing a draft. Answers whether it is on the shared ' +
|
|
963
|
+
'repository, how far ahead of and behind its upstream it is, whether the shared branch has commits this ' +
|
|
964
|
+
'workspace lacks (`behindRemote`: `update_branch` brings them in), the files written but not committed ' +
|
|
965
|
+
'yet, the protected branch it was forked from (`forkBase`), and `changedPaths`: what it changes against ' +
|
|
966
|
+
'`base` (default: the fork base) — the files a change request into it would carry.',
|
|
967
|
+
inputs: {
|
|
968
|
+
type: 'object',
|
|
969
|
+
properties: {
|
|
970
|
+
base: { type: 'string', minLength: 1, description: 'The branch to compare against (default: the fork base).' },
|
|
971
|
+
sessionId: SESSION_ID_INPUT,
|
|
972
|
+
},
|
|
973
|
+
additionalProperties: false,
|
|
974
|
+
},
|
|
975
|
+
outputs: {
|
|
976
|
+
type: 'object',
|
|
977
|
+
properties: {
|
|
978
|
+
branch: { type: 'string' },
|
|
979
|
+
isProtected: { type: 'boolean' },
|
|
980
|
+
hasRemote: { type: 'boolean', description: 'True when the shared repository has the branch.' },
|
|
981
|
+
ahead: { type: ['integer', 'null'], description: 'Commits ahead of the upstream (or the fork base); null when unresolved.' },
|
|
982
|
+
behind: { type: ['integer', 'null'], description: 'Commits behind; null when unresolved.' },
|
|
983
|
+
behindRemote: { type: 'boolean', description: 'The shared branch has commits this workspace has not merged yet.' },
|
|
984
|
+
uncommittedPaths: { type: 'array', items: { type: 'string' }, description: 'Written but not committed yet (saves still landing).' },
|
|
985
|
+
forkBase: { type: ['string', 'null'], description: 'The protected branch this one was forked from; null when none can be reached.' },
|
|
986
|
+
base: { type: ['string', 'null'], description: 'What `changedPaths` is against.' },
|
|
987
|
+
changedPaths: { type: 'array', items: { type: 'string' }, description: 'Repository-relative; rename-aware; only the paths you may read. Empty when `base` is null.' },
|
|
988
|
+
withheldPaths: { type: 'integer', description: 'How many changed or uncommitted paths were left out because you may not read them.' },
|
|
989
|
+
},
|
|
990
|
+
required: ['branch', 'isProtected', 'hasRemote', 'ahead', 'behind', 'behindRemote', 'uncommittedPaths', 'forkBase', 'base', 'changedPaths', 'withheldPaths'],
|
|
991
|
+
},
|
|
992
|
+
write: false,
|
|
993
|
+
handler: async (args, ctx: ToolContext) => {
|
|
994
|
+
const branch = args.branch as string;
|
|
995
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
996
|
+
// Each read below takes the workspace's reservation on its own: this
|
|
997
|
+
// is a REPORT of independent facts, not a range measured across steps,
|
|
998
|
+
// and a save or sync landing between two of them is tolerated — the
|
|
999
|
+
// next call reports it. (The one answer that must be exact, what a
|
|
1000
|
+
// merge is of, is taken under one reservation by `update_branch`.)
|
|
1001
|
+
const [status, branches, pending, forkBase] = await Promise.all([
|
|
1002
|
+
ctx.workflowService.branchStatus(workspaceId),
|
|
1003
|
+
ctx.workflowService.listBranches(workspaceId),
|
|
1004
|
+
ctx.workflowService.listPendingChangePaths(workspaceId),
|
|
1005
|
+
ctx.workflowService.resolveForkBase(workspaceId, branch),
|
|
1006
|
+
]);
|
|
1007
|
+
const info = branches.find((b) => b.name === branch);
|
|
1008
|
+
const base = typeof args.base === 'string' && args.base.length > 0 ? args.base : forkBase;
|
|
1009
|
+
// A path's name is information too: the changed paths are shown as the
|
|
1010
|
+
// change-request reads show them (readable at `origin/<base>`), the
|
|
1011
|
+
// uncommitted ones as the workspace's own listing would.
|
|
1012
|
+
const changedAll =
|
|
1013
|
+
base && base !== branch ? await ctx.workflowService.changedPathsBetweenBranches(workspaceId, base, branch) : [];
|
|
1014
|
+
const readableChanged = base ? await readableAt(ctx, workspaceId, base, changedAll) : new Set<string>();
|
|
1015
|
+
const changedPaths = changedAll.filter((p) => readableChanged.has(p));
|
|
1016
|
+
const pendingRel = pending.map((p) => stripKbDir(p, kbDirName));
|
|
1017
|
+
const pendingVerdicts = pendingRel.length > 0 ? await accessControl.canReadBatch(workspaceId, ctx.user.email, pendingRel) : new Map<string, boolean>();
|
|
1018
|
+
const uncommittedPaths = pendingRel.filter((p) => pendingVerdicts.get(p) === true);
|
|
1019
|
+
return {
|
|
1020
|
+
branch,
|
|
1021
|
+
isProtected: info?.isProtected ?? kb.protectedBranches.has(branch),
|
|
1022
|
+
hasRemote: info?.hasRemote ?? status.hasUpstream,
|
|
1023
|
+
ahead: info?.ahead ?? null,
|
|
1024
|
+
behind: info?.behind ?? null,
|
|
1025
|
+
behindRemote: status.unmergedFromUpstream,
|
|
1026
|
+
uncommittedPaths,
|
|
1027
|
+
forkBase,
|
|
1028
|
+
base: base && base !== branch ? base : null,
|
|
1029
|
+
changedPaths,
|
|
1030
|
+
withheldPaths: changedAll.length - changedPaths.length + (pendingRel.length - uncommittedPaths.length),
|
|
1031
|
+
};
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
mount({
|
|
1036
|
+
name: 'update_branch',
|
|
1037
|
+
description:
|
|
1038
|
+
'Bring another branch into `branch` as a merge commit by you, and publish it: `from` (a draft\'s ' +
|
|
1039
|
+
'target, to refresh the draft), or left out, the branch\'s own shared state when it moved while this ' +
|
|
1040
|
+
'workspace held commits (`branch_status` says `behindRemote`). Never a protected branch. CONFLICTS: ' +
|
|
1041
|
+
'the first call answers `conflicts` naming every conflicted path with the file at the merge base, on ' +
|
|
1042
|
+
'each side (`ours` = `branch`, `theirs` = `from`) and the working copy with git\'s markers, and leaves ' +
|
|
1043
|
+
'the branch as it was. Decide each file (keep both sides\' information unless one is clearly stale; ' +
|
|
1044
|
+
'`list_file_changes` shows intent), then call again with `resolutions`, one per conflicted path: ' +
|
|
1045
|
+
'`content` (the resolved text) or `choose` (`ours`, `theirs`, `delete`). Only conflicted paths are ' +
|
|
1046
|
+
'written here; other edits go through the file tools. Answers `merged`, `nothing-to-merge`, ' +
|
|
1047
|
+
'`conflicts` again (something changed meanwhile), or `pending-commits` (writes still landing: retry).',
|
|
1048
|
+
inputs: {
|
|
1049
|
+
type: 'object',
|
|
1050
|
+
properties: {
|
|
1051
|
+
from: { type: 'string', minLength: 1, description: 'The branch to bring in (default: this branch\'s own shared state).' },
|
|
1052
|
+
sessionId: SESSION_ID_INPUT,
|
|
1053
|
+
resolutions: {
|
|
1054
|
+
type: 'array',
|
|
1055
|
+
description: 'One per conflicted path, from a previous `conflicts` answer.',
|
|
1056
|
+
items: {
|
|
1057
|
+
type: 'object',
|
|
1058
|
+
properties: {
|
|
1059
|
+
path: { type: 'string', minLength: 1, description: 'The conflicted path, as the answer named it.' },
|
|
1060
|
+
content: { type: 'string', description: 'The resolved text of the file.' },
|
|
1061
|
+
choose: { type: 'string', enum: ['ours', 'theirs', 'delete'], description: 'Keep one side whole, or drop the file.' },
|
|
1062
|
+
},
|
|
1063
|
+
required: ['path'],
|
|
1064
|
+
additionalProperties: false,
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
additionalProperties: false,
|
|
1069
|
+
},
|
|
1070
|
+
outputs: {
|
|
1071
|
+
type: 'object',
|
|
1072
|
+
properties: {
|
|
1073
|
+
kind: { type: 'string', enum: ['merged', 'nothing-to-merge', 'conflicts', 'pending-commits'] },
|
|
1074
|
+
from: { type: 'string', description: 'The branch brought in.' },
|
|
1075
|
+
sha: { type: 'string', description: '`merged`: the merge commit, now the tip of `branch`, published; `nothing-to-merge`: the unchanged tip.' },
|
|
1076
|
+
resolvedPaths: { type: 'array', items: { type: 'string' }, description: '`merged`: the conflicts the resolutions settled.' },
|
|
1077
|
+
mergeBase: { type: ['string', 'null'], description: '`conflicts`: the common ancestor; null when the histories are unrelated.' },
|
|
1078
|
+
ours: { type: 'string', description: '`conflicts`: the tip of `branch`.' },
|
|
1079
|
+
theirs: { type: 'string', description: '`conflicts`: the tip of `from`.' },
|
|
1080
|
+
files: {
|
|
1081
|
+
type: 'array',
|
|
1082
|
+
description:
|
|
1083
|
+
'`conflicts`: every conflicted path you may read, with the file at the base, on each side, and the marked-up ' +
|
|
1084
|
+
'working copy. A side is null when that side has no file — or when `truncated` names it (too large or not ' +
|
|
1085
|
+
'text): then read `ours` with `read_file`, `theirs` with `read_file_at` at `from`, `base` with `read_file_at` ' +
|
|
1086
|
+
'at `mergeBase`.',
|
|
1087
|
+
items: {
|
|
1088
|
+
type: 'object',
|
|
1089
|
+
properties: {
|
|
1090
|
+
path: { type: 'string' },
|
|
1091
|
+
base: { type: ['string', 'null'] },
|
|
1092
|
+
ours: { type: ['string', 'null'] },
|
|
1093
|
+
theirs: { type: ['string', 'null'] },
|
|
1094
|
+
merged: { type: ['string', 'null'] },
|
|
1095
|
+
truncated: { type: 'array', items: { type: 'string', enum: ['base', 'ours', 'theirs', 'merged'] } },
|
|
1096
|
+
},
|
|
1097
|
+
required: ['path', 'base', 'ours', 'theirs', 'merged'],
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
unresolved: { type: 'array', items: { type: 'string' }, description: '`conflicts`: the paths you may read that no resolution covered.' },
|
|
1101
|
+
withheldConflicts: {
|
|
1102
|
+
type: 'integer',
|
|
1103
|
+
description:
|
|
1104
|
+
'`conflicts`: how many conflicted files were left out because you may not read them on one of the two branches. ' +
|
|
1105
|
+
'They are not yours to settle: ask a person who can read them to update the draft in the app.',
|
|
1106
|
+
},
|
|
1107
|
+
branch: { type: 'string', description: '`pending-commits`: the branch whose writes are not committed yet.' },
|
|
1108
|
+
pending: { type: 'integer', description: '`pending-commits`: how many.' },
|
|
1109
|
+
needsAttention: { type: 'string', description: '`pending-commits`: a queued commit failed; the worker\'s message. Tell the user.' },
|
|
1110
|
+
message: { type: 'string', description: '`pending-commits`: what to do next.' },
|
|
1111
|
+
},
|
|
1112
|
+
required: ['kind'],
|
|
1113
|
+
},
|
|
1114
|
+
write: true,
|
|
1115
|
+
handler: async (args, ctx: ToolContext) => {
|
|
1116
|
+
const branch = args.branch as string;
|
|
1117
|
+
const from = args.from;
|
|
1118
|
+
if (from !== undefined && (typeof from !== 'string' || from.length === 0)) {
|
|
1119
|
+
throw new ToolError('`from` must be a branch name.', 400);
|
|
1120
|
+
}
|
|
1121
|
+
let resolutions: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' }[] | undefined;
|
|
1122
|
+
if (args.resolutions !== undefined) {
|
|
1123
|
+
if (!Array.isArray(args.resolutions)) throw new ToolError('`resolutions` must be a list.', 400);
|
|
1124
|
+
resolutions = args.resolutions.map((r: unknown, i: number) => {
|
|
1125
|
+
const entry = r as Record<string, unknown>;
|
|
1126
|
+
if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
1127
|
+
throw new ToolError(`resolutions[${i}]: \`path\` is required.`, 400);
|
|
1128
|
+
}
|
|
1129
|
+
const out: { path: string; content?: string; choose?: 'ours' | 'theirs' | 'delete' } = { path: entry.path };
|
|
1130
|
+
if (entry.content !== undefined) {
|
|
1131
|
+
if (typeof entry.content !== 'string') throw new ToolError(`resolutions[${i}]: \`content\` must be text.`, 400);
|
|
1132
|
+
out.content = entry.content;
|
|
1133
|
+
}
|
|
1134
|
+
if (entry.choose !== undefined) {
|
|
1135
|
+
if (entry.choose !== 'ours' && entry.choose !== 'theirs' && entry.choose !== 'delete') {
|
|
1136
|
+
throw new ToolError(`resolutions[${i}]: \`choose\` is \`ours\`, \`theirs\` or \`delete\`.`, 400);
|
|
1137
|
+
}
|
|
1138
|
+
out.choose = entry.choose;
|
|
1139
|
+
}
|
|
1140
|
+
return out;
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
const workspaceId = await workspaceOf(ctx, branch);
|
|
1144
|
+
/**
|
|
1145
|
+
* Whether the caller may read `rel` on both sides of this merge — at
|
|
1146
|
+
* the two COMMITS the answer says the merge was of, which is what its
|
|
1147
|
+
* bytes are from, never at a branch ref that may have moved since the
|
|
1148
|
+
* reservation was released — with the deployment's read hook told of
|
|
1149
|
+
* the read, as every history read tells it: a hook that refuses makes
|
|
1150
|
+
* the file one the caller may not read; a hook that fails is not an
|
|
1151
|
+
* answer and propagates.
|
|
1152
|
+
*/
|
|
1153
|
+
const mayReadBothSides = async (rel: string, at: { ours: string; theirs: string }): Promise<boolean> => {
|
|
1154
|
+
try {
|
|
1155
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, `${kbDirName}/${rel}`);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
// A hook's own refusal carries a status; anything else is a failure of the hook, not an answer.
|
|
1158
|
+
if (hasHttpStatus(err)) return false;
|
|
1159
|
+
throw err;
|
|
1160
|
+
}
|
|
1161
|
+
return readableAtCommits(accessControl, workspaceId, ctx.user.email, rel, [at.ours, at.theirs]);
|
|
1162
|
+
};
|
|
1163
|
+
// A resolution reads a file on both branches and writes it on the
|
|
1164
|
+
// draft, so the deployment's hooks are asked as they are for any read
|
|
1165
|
+
// and any write. The access model's own verdicts — may the caller read
|
|
1166
|
+
// the file on both branches, may they change it on the draft — are the
|
|
1167
|
+
// service's, asked inside the merge against the source commit as
|
|
1168
|
+
// fetched (`updateBranch`), so a rule that changed with the source
|
|
1169
|
+
// cannot be outrun by a check made before it was fetched.
|
|
1170
|
+
for (const r of resolutions ?? []) {
|
|
1171
|
+
const wsPath = `${kbDirName}/${stripKbDir(r.path, kbDirName)}`;
|
|
1172
|
+
try {
|
|
1173
|
+
await notifyAgentRead(agentAccessGate, ctx, branch, wsPath);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
if (hasHttpStatus(err)) {
|
|
1176
|
+
throw new ToolError(`You may not read "${stripKbDir(r.path, kbDirName)}" here, so it is not yours to settle.`, 403, {
|
|
1177
|
+
kind: 'read-denied',
|
|
1178
|
+
path: stripKbDir(r.path, kbDirName),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
await assertAgentWriteAllowed(agentAccessGate, ctx, branch, wsPath);
|
|
1184
|
+
}
|
|
1185
|
+
// A resolution's content lands on the draft by the merge, not by the
|
|
1186
|
+
// file tools' filesystem: it takes the same pre-disk gate that
|
|
1187
|
+
// filesystem runs (`roles.yaml` must parse, no new role, no unknown
|
|
1188
|
+
// group), handed to the service to ask before the merge.
|
|
1189
|
+
const outcome =
|
|
1190
|
+
resolutions !== undefined
|
|
1191
|
+
? await ctx.workflowService.updateBranch(workspaceId, ctx.user, {
|
|
1192
|
+
...(from !== undefined ? { from } : {}),
|
|
1193
|
+
resolutions,
|
|
1194
|
+
validateWrite: await ctx.getWriteValidator(branch),
|
|
1195
|
+
})
|
|
1196
|
+
: await ctx.workflowService.updateBranch(workspaceId, ctx.user, from !== undefined ? { from } : {});
|
|
1197
|
+
if (outcome.kind !== 'conflicts') return outcome;
|
|
1198
|
+
// A conflict answer carries the file on both branches, and a path's
|
|
1199
|
+
// name is information too: a file the caller may not read on both is
|
|
1200
|
+
// left out of `files` and `unresolved` alike and only counted.
|
|
1201
|
+
const files = [];
|
|
1202
|
+
for (const f of outcome.files) if (await mayReadBothSides(f.path, outcome)) files.push(f);
|
|
1203
|
+
const shown = new Set(files.map((f) => f.path));
|
|
1204
|
+
return {
|
|
1205
|
+
...outcome,
|
|
1206
|
+
files,
|
|
1207
|
+
unresolved: outcome.unresolved.filter((p) => shown.has(p)),
|
|
1208
|
+
withheldConflicts: outcome.files.length - files.length,
|
|
1209
|
+
};
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
751
1212
|
}
|
|
@@ -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
|
+
});
|