@bevel-software/platform-core-backend 0.1.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/LICENSE +202 -0
- package/dist/assets.d.ts +5 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +23 -0
- package/dist/assets.js.map +1 -0
- package/dist/core/core-ports.d.ts +83 -0
- package/dist/core/core-ports.d.ts.map +1 -0
- package/dist/core/core-ports.js +12 -0
- package/dist/core/core-ports.js.map +1 -0
- package/dist/core/create-core-server.d.ts +62 -0
- package/dist/core/create-core-server.d.ts.map +1 -0
- package/dist/core/create-core-server.js +204 -0
- package/dist/core/create-core-server.js.map +1 -0
- package/dist/core/create-core-services.d.ts +95 -0
- package/dist/core/create-core-services.d.ts.map +1 -0
- package/dist/core/create-core-services.js +352 -0
- package/dist/core/create-core-services.js.map +1 -0
- package/dist/core-config.d.ts +119 -0
- package/dist/core-config.d.ts.map +1 -0
- package/dist/core-config.js +240 -0
- package/dist/core-config.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/access/access-control.interface.d.ts +348 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -0
- package/dist/modules/access/access-control.interface.js +2 -0
- package/dist/modules/access/access-control.interface.js.map +1 -0
- package/dist/modules/access/access-control.service.d.ts +323 -0
- package/dist/modules/access/access-control.service.d.ts.map +1 -0
- package/dist/modules/access/access-control.service.js +1431 -0
- package/dist/modules/access/access-control.service.js.map +1 -0
- package/dist/modules/access/access-errors.d.ts +34 -0
- package/dist/modules/access/access-errors.d.ts.map +1 -0
- package/dist/modules/access/access-errors.js +40 -0
- package/dist/modules/access/access-errors.js.map +1 -0
- package/dist/modules/access/access-mutation.service.d.ts +176 -0
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
- package/dist/modules/access/access-mutation.service.js +307 -0
- package/dist/modules/access/access-mutation.service.js.map +1 -0
- package/dist/modules/access/access-splice.d.ts +98 -0
- package/dist/modules/access/access-splice.d.ts.map +1 -0
- package/dist/modules/access/access-splice.js +346 -0
- package/dist/modules/access/access-splice.js.map +1 -0
- package/dist/modules/access/access.routes.d.ts +10 -0
- package/dist/modules/access/access.routes.d.ts.map +1 -0
- package/dist/modules/access/access.routes.js +757 -0
- package/dist/modules/access/access.routes.js.map +1 -0
- package/dist/modules/access/creator-access.d.ts +113 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -0
- package/dist/modules/access/creator-access.js +212 -0
- package/dist/modules/access/creator-access.js.map +1 -0
- package/dist/modules/access/kb-read-filter.d.ts +41 -0
- package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
- package/dist/modules/access/kb-read-filter.js +60 -0
- package/dist/modules/access/kb-read-filter.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +240 -0
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
- package/dist/modules/access/roles-admin.service.js +630 -0
- package/dist/modules/access/roles-admin.service.js.map +1 -0
- package/dist/modules/access/roles-edit.d.ts +91 -0
- package/dist/modules/access/roles-edit.d.ts.map +1 -0
- package/dist/modules/access/roles-edit.js +232 -0
- package/dist/modules/access/roles-edit.js.map +1 -0
- package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
- package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
- package/dist/modules/access/roles-yaml-guard.js +79 -0
- package/dist/modules/access/roles-yaml-guard.js.map +1 -0
- package/dist/modules/admin/admin-access.routes.d.ts +18 -0
- package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.routes.js +26 -0
- package/dist/modules/admin/admin-access.routes.js.map +1 -0
- package/dist/modules/admin/admin-access.service.d.ts +24 -0
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
- package/dist/modules/admin/admin-access.service.js +41 -0
- package/dist/modules/admin/admin-access.service.js.map +1 -0
- package/dist/modules/admin/admin.interface.d.ts +11 -0
- package/dist/modules/admin/admin.interface.d.ts.map +1 -0
- package/dist/modules/admin/admin.interface.js +2 -0
- package/dist/modules/admin/admin.interface.js.map +1 -0
- package/dist/modules/auth/account-erasure.service.d.ts +80 -0
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
- package/dist/modules/auth/account-erasure.service.js +91 -0
- package/dist/modules/auth/account-erasure.service.js.map +1 -0
- package/dist/modules/auth/auth.middleware.d.ts +23 -0
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
- package/dist/modules/auth/auth.middleware.js +68 -0
- package/dist/modules/auth/auth.middleware.js.map +1 -0
- package/dist/modules/auth/auth.routes.d.ts +25 -0
- package/dist/modules/auth/auth.routes.d.ts.map +1 -0
- package/dist/modules/auth/auth.routes.js +72 -0
- package/dist/modules/auth/auth.routes.js.map +1 -0
- package/dist/modules/auth/auth.service.d.ts +76 -0
- package/dist/modules/auth/auth.service.d.ts.map +1 -0
- package/dist/modules/auth/auth.service.js +168 -0
- package/dist/modules/auth/auth.service.js.map +1 -0
- package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode-names.js +30 -0
- package/dist/modules/code-mode/code-mode-names.js.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
- package/dist/modules/code-mode/code-mode.tool.js +112 -0
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
- package/dist/modules/code-mode/index.d.ts +3 -0
- package/dist/modules/code-mode/index.d.ts.map +1 -0
- package/dist/modules/code-mode/index.js +3 -0
- package/dist/modules/code-mode/index.js.map +1 -0
- package/dist/modules/database/connection.d.ts +6 -0
- package/dist/modules/database/connection.d.ts.map +1 -0
- package/dist/modules/database/connection.js +12 -0
- package/dist/modules/database/connection.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +2273 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -0
- package/dist/modules/database/core-schema.js +413 -0
- package/dist/modules/database/core-schema.js.map +1 -0
- package/dist/modules/database/migrate.d.ts +10 -0
- package/dist/modules/database/migrate.d.ts.map +1 -0
- package/dist/modules/database/migrate.js +40 -0
- package/dist/modules/database/migrate.js.map +1 -0
- package/dist/modules/database/schema.d.ts +12 -0
- package/dist/modules/database/schema.d.ts.map +1 -0
- package/dist/modules/database/schema.js +12 -0
- package/dist/modules/database/schema.js.map +1 -0
- package/dist/modules/diff/diff-paths.d.ts +2 -0
- package/dist/modules/diff/diff-paths.d.ts.map +1 -0
- package/dist/modules/diff/diff-paths.js +9 -0
- package/dist/modules/diff/diff-paths.js.map +1 -0
- package/dist/modules/diff/diff.config.d.ts +8 -0
- package/dist/modules/diff/diff.config.d.ts.map +1 -0
- package/dist/modules/diff/diff.config.js +13 -0
- package/dist/modules/diff/diff.config.js.map +1 -0
- package/dist/modules/diff/diff.interface.d.ts +73 -0
- package/dist/modules/diff/diff.interface.d.ts.map +1 -0
- package/dist/modules/diff/diff.interface.js +2 -0
- package/dist/modules/diff/diff.interface.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +35 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -0
- package/dist/modules/diff/diff.routes.js +228 -0
- package/dist/modules/diff/diff.routes.js.map +1 -0
- package/dist/modules/diff/diff.service.d.ts +51 -0
- package/dist/modules/diff/diff.service.d.ts.map +1 -0
- package/dist/modules/diff/diff.service.js +489 -0
- package/dist/modules/diff/diff.service.js.map +1 -0
- package/dist/modules/diff/line-diff.d.ts +16 -0
- package/dist/modules/diff/line-diff.d.ts.map +1 -0
- package/dist/modules/diff/line-diff.js +67 -0
- package/dist/modules/diff/line-diff.js.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
- package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
- package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
- package/dist/modules/mcp/mcp-session-store.js +131 -0
- package/dist/modules/mcp/mcp-session-store.js.map +1 -0
- package/dist/modules/mcp/mcp.routes.d.ts +24 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.routes.js +229 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -0
- package/dist/modules/mcp/mcp.service.d.ts +198 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
- package/dist/modules/mcp/mcp.service.js +840 -0
- package/dist/modules/mcp/mcp.service.js.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
- package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
- package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
- package/dist/modules/mcp/oauth/oauth-state.js +34 -0
- package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
- package/dist/modules/secrets-vault/index.d.ts +7 -0
- package/dist/modules/secrets-vault/index.d.ts.map +1 -0
- package/dist/modules/secrets-vault/index.js +6 -0
- package/dist/modules/secrets-vault/index.js.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
- package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +5 -0
- package/dist/modules/skills/index.d.ts.map +1 -0
- package/dist/modules/skills/index.js +4 -0
- package/dist/modules/skills/index.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +58 -0
- package/dist/modules/skills/skills.contract.d.ts.map +1 -0
- package/dist/modules/skills/skills.contract.js +10 -0
- package/dist/modules/skills/skills.contract.js.map +1 -0
- package/dist/modules/skills/skills.routes.d.ts +15 -0
- package/dist/modules/skills/skills.routes.d.ts.map +1 -0
- package/dist/modules/skills/skills.routes.js +34 -0
- package/dist/modules/skills/skills.routes.js.map +1 -0
- package/dist/modules/skills/skills.service.d.ts +21 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -0
- package/dist/modules/skills/skills.service.js +202 -0
- package/dist/modules/skills/skills.service.js.map +1 -0
- package/dist/modules/skills/skills.tools.d.ts +15 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -0
- package/dist/modules/skills/skills.tools.js +96 -0
- package/dist/modules/skills/skills.tools.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
- package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
- package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
- package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
- package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/external-api-key.service.js +164 -0
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
- package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
- package/dist/modules/tool-auth/internal-token.service.js +85 -0
- package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
- package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
- package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
- package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +19 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -0
- package/dist/modules/tool-helpers/index.js +18 -0
- package/dist/modules/tool-helpers/index.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-context.js +66 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -0
- package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
- package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-def.js +57 -0
- package/dist/modules/tool-helpers/tool-def.js.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool-handler.js +84 -0
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-helpers/tool.contract.js +24 -0
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
- package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
- package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
- package/dist/modules/tool-helpers/validate-token.js +20 -0
- package/dist/modules/tool-helpers/validate-token.js.map +1 -0
- package/dist/modules/tool-manuals/index.d.ts +5 -0
- package/dist/modules/tool-manuals/index.d.ts.map +1 -0
- package/dist/modules/tool-manuals/index.js +5 -0
- package/dist/modules/tool-manuals/index.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
- package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
- package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
- package/dist/modules/tool-registry/manual.routes.js +76 -0
- package/dist/modules/tool-registry/manual.routes.js.map +1 -0
- package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
- package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool-registry.js +46 -0
- package/dist/modules/tool-registry/tool-registry.js.map +1 -0
- package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
- package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
- package/dist/modules/tool-registry/tool.contract.js +2 -0
- package/dist/modules/tool-registry/tool.contract.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
- package/dist/modules/workflow/event-bus.d.ts +107 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -0
- package/dist/modules/workflow/event-bus.js +224 -0
- package/dist/modules/workflow/event-bus.js.map +1 -0
- package/dist/modules/workflow/events.routes.d.ts +80 -0
- package/dist/modules/workflow/events.routes.d.ts.map +1 -0
- package/dist/modules/workflow/events.routes.js +216 -0
- package/dist/modules/workflow/events.routes.js.map +1 -0
- package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
- package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
- package/dist/modules/workflow/file-change-notifier.js +22 -0
- package/dist/modules/workflow/file-change-notifier.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +57 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
- package/dist/modules/workflow/file-lock.service.js +202 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -0
- package/dist/modules/workflow/git/branch-name.d.ts +10 -0
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
- package/dist/modules/workflow/git/branch-name.js +76 -0
- package/dist/modules/workflow/git/branch-name.js.map +1 -0
- package/dist/modules/workflow/git/clone-config.d.ts +61 -0
- package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
- package/dist/modules/workflow/git/clone-config.js +69 -0
- package/dist/modules/workflow/git/clone-config.js.map +1 -0
- package/dist/modules/workflow/git/git-version.d.ts +34 -0
- package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
- package/dist/modules/workflow/git/git-version.js +63 -0
- package/dist/modules/workflow/git/git-version.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +427 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/git.service.js +1987 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -0
- package/dist/modules/workflow/git/mutex.d.ts +11 -0
- package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
- package/dist/modules/workflow/git/mutex.js +23 -0
- package/dist/modules/workflow/git/mutex.js.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
- package/dist/modules/workflow/git/pull-request.service.js +328 -0
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
- package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/locking-filesystem.js +400 -0
- package/dist/modules/workflow/locking-filesystem.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.service.js +364 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
- package/dist/modules/workflow/pending-commits.worker.js +245 -0
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
- package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
- package/dist/modules/workflow/read-only-filesystem.js +39 -0
- package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
- package/dist/modules/workflow/recovery-bot.d.ts +31 -0
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
- package/dist/modules/workflow/recovery-bot.js +64 -0
- package/dist/modules/workflow/recovery-bot.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
- package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
- package/dist/modules/workflow/sanitize-error.d.ts +29 -0
- package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
- package/dist/modules/workflow/sanitize-error.js +60 -0
- package/dist/modules/workflow/sanitize-error.js.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.policy.js +62 -0
- package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
- package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
- package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
- package/dist/modules/workflow/session-ontology.service.js +143 -0
- package/dist/modules/workflow/session-ontology.service.js.map +1 -0
- package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
- package/dist/modules/workflow/workflow-hooks.js +35 -0
- package/dist/modules/workflow/workflow-hooks.js.map +1 -0
- package/dist/modules/workflow/workflow.errors.d.ts +171 -0
- package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.errors.js +260 -0
- package/dist/modules/workflow/workflow.errors.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts +26 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.routes.js +804 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -0
- package/dist/modules/workflow/workflow.service.d.ts +385 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
- package/dist/modules/workflow/workflow.service.js +1155 -0
- package/dist/modules/workflow/workflow.service.js.map +1 -0
- package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
- package/dist/modules/workspace/bevel-ignore.js +57 -0
- package/dist/modules/workspace/bevel-ignore.js.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
- package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.interface.js +2 -0
- package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
- package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
- package/dist/modules/workspace/kb-seed.service.js +325 -0
- package/dist/modules/workspace/kb-seed.service.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
- package/dist/modules/workspace/routine-write-policy.js +46 -0
- package/dist/modules/workspace/routine-write-policy.js.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
- package/dist/modules/workspace/session-ontology.gate.js +161 -0
- package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
- package/dist/modules/workspace/session-sink.d.ts +25 -0
- package/dist/modules/workspace/session-sink.d.ts.map +1 -0
- package/dist/modules/workspace/session-sink.js +8 -0
- package/dist/modules/workspace/session-sink.js.map +1 -0
- package/dist/modules/workspace/spill-store.d.ts +24 -0
- package/dist/modules/workspace/spill-store.d.ts.map +1 -0
- package/dist/modules/workspace/spill-store.js +87 -0
- package/dist/modules/workspace/spill-store.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts +25 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +1046 -0
- package/dist/modules/workspace/workspace.routes.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +339 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.service.js +1221 -0
- package/dist/modules/workspace/workspace.service.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts +19 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
- package/dist/modules/workspace/workspace.tools.js +767 -0
- package/dist/modules/workspace/workspace.tools.js.map +1 -0
- package/dist/shared/frontmatter-id.d.ts +35 -0
- package/dist/shared/frontmatter-id.d.ts.map +1 -0
- package/dist/shared/frontmatter-id.js +68 -0
- package/dist/shared/frontmatter-id.js.map +1 -0
- package/dist/shared/fs-walk.d.ts +9 -0
- package/dist/shared/fs-walk.d.ts.map +1 -0
- package/dist/shared/fs-walk.js +34 -0
- package/dist/shared/fs-walk.js.map +1 -0
- package/dist/shared/hash-email.d.ts +11 -0
- package/dist/shared/hash-email.d.ts.map +1 -0
- package/dist/shared/hash-email.js +14 -0
- package/dist/shared/hash-email.js.map +1 -0
- package/dist/shared/kb-layout.d.ts +38 -0
- package/dist/shared/kb-layout.d.ts.map +1 -0
- package/dist/shared/kb-layout.js +102 -0
- package/dist/shared/kb-layout.js.map +1 -0
- package/dist/shared/kb-layout.test.d.ts +2 -0
- package/dist/shared/kb-layout.test.d.ts.map +1 -0
- package/dist/shared/kb-layout.test.js +67 -0
- package/dist/shared/kb-layout.test.js.map +1 -0
- package/dist/shared/ssrf.d.ts +20 -0
- package/dist/shared/ssrf.d.ts.map +1 -0
- package/dist/shared/ssrf.js +89 -0
- package/dist/shared/ssrf.js.map +1 -0
- package/dist/shared/token-crypto.d.ts +16 -0
- package/dist/shared/token-crypto.d.ts.map +1 -0
- package/dist/shared/token-crypto.js +49 -0
- package/dist/shared/token-crypto.js.map +1 -0
- package/dist/shared/ttl-cache.d.ts +16 -0
- package/dist/shared/ttl-cache.d.ts.map +1 -0
- package/dist/shared/ttl-cache.js +27 -0
- package/dist/shared/ttl-cache.js.map +1 -0
- package/dist/shared/utcp-namespace.d.ts +46 -0
- package/dist/shared/utcp-namespace.d.ts.map +1 -0
- package/dist/shared/utcp-namespace.js +82 -0
- package/dist/shared/utcp-namespace.js.map +1 -0
- package/dist/version.d.ts +17 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/kb-template/.bevelignore +21 -0
- package/kb-template/Agents/.gitkeep +0 -0
- package/kb-template/Agents/README.md +38 -0
- package/kb-template/CLAUDE.md +419 -0
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +39 -0
- package/kb-template/KnowledgeBase/.gitkeep +0 -0
- package/kb-template/KnowledgeBase/README.md +37 -0
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +38 -0
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +32 -0
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +42 -0
- package/kb-template/access.md +36 -0
- package/migrations/0000_core_init.sql +243 -0
- package/migrations/meta/0000_snapshot.json +1480 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +80 -0
- package/src/assets.ts +25 -0
- package/src/core/core-ports.ts +91 -0
- package/src/core/create-core-server.ts +338 -0
- package/src/core/create-core-services.ts +501 -0
- package/src/core-config.ts +257 -0
- package/src/index.ts +69 -0
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
- package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
- package/src/modules/access/__tests__/access-splice.test.ts +247 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
- package/src/modules/access/__tests__/creator-access.test.ts +217 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
- package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
- package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
- package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
- package/src/modules/access/access-control.interface.ts +383 -0
- package/src/modules/access/access-control.service.ts +1794 -0
- package/src/modules/access/access-errors.ts +60 -0
- package/src/modules/access/access-mutation.service.ts +369 -0
- package/src/modules/access/access-splice.ts +413 -0
- package/src/modules/access/access.routes.ts +894 -0
- package/src/modules/access/creator-access.ts +270 -0
- package/src/modules/access/kb-read-filter.ts +76 -0
- package/src/modules/access/roles-admin.service.ts +741 -0
- package/src/modules/access/roles-edit.ts +248 -0
- package/src/modules/access/roles-yaml-guard.ts +89 -0
- package/src/modules/admin/admin-access.routes.ts +29 -0
- package/src/modules/admin/admin-access.service.ts +38 -0
- package/src/modules/admin/admin.interface.ts +10 -0
- package/src/modules/auth/account-erasure.service.ts +183 -0
- package/src/modules/auth/auth.middleware.ts +78 -0
- package/src/modules/auth/auth.routes.ts +97 -0
- package/src/modules/auth/auth.service.ts +199 -0
- package/src/modules/code-mode/code-mode-names.ts +38 -0
- package/src/modules/code-mode/code-mode.tool.ts +117 -0
- package/src/modules/code-mode/index.ts +6 -0
- package/src/modules/database/connection.ts +15 -0
- package/src/modules/database/core-schema.ts +431 -0
- package/src/modules/database/migrate.ts +43 -0
- package/src/modules/database/schema.ts +11 -0
- package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
- package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
- package/src/modules/diff/diff-paths.ts +9 -0
- package/src/modules/diff/diff.config.ts +12 -0
- package/src/modules/diff/diff.interface.ts +78 -0
- package/src/modules/diff/diff.routes.ts +277 -0
- package/src/modules/diff/diff.service.ts +508 -0
- package/src/modules/diff/line-diff.ts +79 -0
- package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
- package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
- package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
- package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
- package/src/modules/mcp/mcp-session-store.ts +162 -0
- package/src/modules/mcp/mcp.routes.ts +263 -0
- package/src/modules/mcp/mcp.service.ts +973 -0
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
- package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
- package/src/modules/mcp/oauth/oauth-state.ts +61 -0
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
- package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
- package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
- package/src/modules/secrets-vault/index.ts +25 -0
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
- package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
- package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
- package/src/modules/skills/index.ts +10 -0
- package/src/modules/skills/skills.contract.ts +54 -0
- package/src/modules/skills/skills.routes.ts +38 -0
- package/src/modules/skills/skills.service.ts +245 -0
- package/src/modules/skills/skills.tools.ts +121 -0
- package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
- package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
- package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
- package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
- package/src/modules/tool-auth/external-api-key.service.ts +209 -0
- package/src/modules/tool-auth/internal-token.service.ts +124 -0
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
- package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
- package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
- package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
- package/src/modules/tool-helpers/index.ts +18 -0
- package/src/modules/tool-helpers/tool-context.ts +98 -0
- package/src/modules/tool-helpers/tool-def.ts +73 -0
- package/src/modules/tool-helpers/tool-handler.ts +91 -0
- package/src/modules/tool-helpers/tool.contract.ts +85 -0
- package/src/modules/tool-helpers/validate-token.ts +50 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
- package/src/modules/tool-manuals/index.ts +12 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
- package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
- package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
- package/src/modules/tool-registry/manual.routes.ts +87 -0
- package/src/modules/tool-registry/tool-registry.ts +55 -0
- package/src/modules/tool-registry/tool.contract.ts +52 -0
- package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
- package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
- package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
- package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
- package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
- package/src/modules/workflow/event-bus.ts +262 -0
- package/src/modules/workflow/events.routes.ts +266 -0
- package/src/modules/workflow/file-change-notifier.ts +54 -0
- package/src/modules/workflow/file-lock.service.ts +269 -0
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
- package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
- package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
- package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
- package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
- package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
- package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
- package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
- package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
- package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
- package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
- package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
- package/src/modules/workflow/git/branch-name.ts +70 -0
- package/src/modules/workflow/git/clone-config.ts +71 -0
- package/src/modules/workflow/git/git-version.ts +70 -0
- package/src/modules/workflow/git/git.service.ts +2248 -0
- package/src/modules/workflow/git/mutex.ts +22 -0
- package/src/modules/workflow/git/pull-request.service.ts +447 -0
- package/src/modules/workflow/locking-filesystem.ts +489 -0
- package/src/modules/workflow/pending-commits.service.ts +444 -0
- package/src/modules/workflow/pending-commits.worker.ts +361 -0
- package/src/modules/workflow/read-only-filesystem.ts +54 -0
- package/src/modules/workflow/recovery-bot.ts +73 -0
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
- package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
- package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
- package/src/modules/workflow/sanitize-error.ts +62 -0
- package/src/modules/workflow/session-ontology.policy.ts +70 -0
- package/src/modules/workflow/session-ontology.service.ts +178 -0
- package/src/modules/workflow/workflow-hooks.ts +101 -0
- package/src/modules/workflow/workflow.errors.ts +285 -0
- package/src/modules/workflow/workflow.routes.ts +858 -0
- package/src/modules/workflow/workflow.service.ts +1555 -0
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
- package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
- package/src/modules/workspace/bevel-ignore.ts +62 -0
- package/src/modules/workspace/kb-seed.interface.ts +36 -0
- package/src/modules/workspace/kb-seed.service.ts +361 -0
- package/src/modules/workspace/routine-write-policy.ts +79 -0
- package/src/modules/workspace/session-ontology.gate.ts +189 -0
- package/src/modules/workspace/session-sink.ts +25 -0
- package/src/modules/workspace/spill-store.ts +87 -0
- package/src/modules/workspace/workspace.routes.ts +1114 -0
- package/src/modules/workspace/workspace.service.ts +1343 -0
- package/src/modules/workspace/workspace.tools.ts +885 -0
- package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
- package/src/shared/__tests__/fs-walk.test.ts +42 -0
- package/src/shared/__tests__/ssrf.test.ts +53 -0
- package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
- package/src/shared/frontmatter-id.ts +72 -0
- package/src/shared/fs-walk.ts +30 -0
- package/src/shared/hash-email.ts +14 -0
- package/src/shared/kb-layout.test.ts +88 -0
- package/src/shared/kb-layout.ts +101 -0
- package/src/shared/ssrf.ts +78 -0
- package/src/shared/token-crypto.ts +54 -0
- package/src/shared/ttl-cache.ts +27 -0
- package/src/shared/utcp-namespace.ts +88 -0
- package/src/version.ts +19 -0
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow facade — the only thing app callers (routes, agent tools, tests)
|
|
3
|
+
* should reach for. Today it delegates to the existing git / PR /
|
|
4
|
+
* review-workflow services; over PLAN steps 2–5 the implementations migrate
|
|
5
|
+
* here directly and the underlying services either disappear or move
|
|
6
|
+
* underneath this module.
|
|
7
|
+
*
|
|
8
|
+
* The facade does no caching, no validation, no policy of its own — it
|
|
9
|
+
* exists to:
|
|
10
|
+
* 1. Lock in the workflow vocabulary on the consumer side.
|
|
11
|
+
* 2. Concentrate every call into git/PR/review-workflow into one place
|
|
12
|
+
* so the agent prompt can stop knowing about `git` / `gh`.
|
|
13
|
+
*
|
|
14
|
+
* Methods backed by services that don't exist yet (locks, openChangeRequest,
|
|
15
|
+
* updateFromTarget) throw `NotImplementedWorkflowError`. The interface
|
|
16
|
+
* defines them so frontend / agent code can wire to the final surface while
|
|
17
|
+
* those backings land.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { promises as fs } from 'node:fs';
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { promisify } from 'node:util';
|
|
24
|
+
import type {
|
|
25
|
+
AcquireLockResult,
|
|
26
|
+
AuthUser,
|
|
27
|
+
Branch,
|
|
28
|
+
BranchWorkspaceStatus,
|
|
29
|
+
CancelChangeRequestResult,
|
|
30
|
+
Change,
|
|
31
|
+
ChangeInput,
|
|
32
|
+
ChangeRequest,
|
|
33
|
+
ChangeRequestComment,
|
|
34
|
+
ChangeRequestDetail,
|
|
35
|
+
ChangeRequestState,
|
|
36
|
+
ChangedFile,
|
|
37
|
+
FileApproval,
|
|
38
|
+
FileLock,
|
|
39
|
+
IWorkflowService,
|
|
40
|
+
MergeChangeRequestOutcome,
|
|
41
|
+
OpenChangeRequestInput,
|
|
42
|
+
PostChangeRequestCommentInput,
|
|
43
|
+
} from '@bevel-software/platform-shared';
|
|
44
|
+
import { isProtectedBranch } from '@bevel-software/platform-shared';
|
|
45
|
+
import { and, eq } from 'drizzle-orm';
|
|
46
|
+
import type { Database } from '../database/connection.js';
|
|
47
|
+
import { changeRequests } from '../database/schema.js';
|
|
48
|
+
import type { GitService } from './git/git.service.js';
|
|
49
|
+
import type { PullRequestService } from './git/pull-request.service.js';
|
|
50
|
+
import type { IReviewWorkflowService } from './review-workflow/review-workflow.interface.js';
|
|
51
|
+
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
52
|
+
import type { IAccessControl } from '../access/access-control.interface.js';
|
|
53
|
+
import { FileLockService } from './file-lock.service.js';
|
|
54
|
+
import { PendingCommitsService } from './pending-commits.service.js';
|
|
55
|
+
import type { WorkflowEventBus } from './event-bus.js';
|
|
56
|
+
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
57
|
+
import { WorkflowHooks } from './workflow-hooks.js';
|
|
58
|
+
import { hashEmail } from '../../shared/hash-email.js';
|
|
59
|
+
import {
|
|
60
|
+
ChangeRequestConflictsError,
|
|
61
|
+
DuplicateChangeRequestError,
|
|
62
|
+
RolesYamlPreservationError,
|
|
63
|
+
PushNeedsAgentResolutionError,
|
|
64
|
+
WorkflowValidationError,
|
|
65
|
+
} from './workflow.errors.js';
|
|
66
|
+
import { AccessDeniedError } from '../access/access-errors.js';
|
|
67
|
+
|
|
68
|
+
const execFileAsync = promisify(execFile);
|
|
69
|
+
|
|
70
|
+
/** The partial unique index that enforces one OPEN change request per (source, target) pair. */
|
|
71
|
+
const OPEN_PAIR_CONSTRAINT = 'change_requests_open_pair_unq';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True only for a Postgres unique violation (23505) on the open-CR-per-pair
|
|
75
|
+
* index. `change_requests` also carries `change_requests_number_unq` on its
|
|
76
|
+
* identity column, so we match the constraint NAME — a violation on any other
|
|
77
|
+
* unique index is a real integrity problem that must not be reported as a
|
|
78
|
+
* duplicate/open-close race.
|
|
79
|
+
*/
|
|
80
|
+
function isOpenPairViolation(err: unknown): boolean {
|
|
81
|
+
// node-postgres puts `code` + `constraint` on the driver error; Drizzle
|
|
82
|
+
// sometimes wraps that error in `.cause`, so unwrap one level and check both.
|
|
83
|
+
const fieldOf = (e: unknown, key: 'code' | 'constraint' | 'message'): unknown =>
|
|
84
|
+
typeof e === 'object' && e !== null ? (e as Record<string, unknown>)[key] : undefined;
|
|
85
|
+
const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
|
|
86
|
+
const is23505 = fieldOf(err, 'code') === '23505' || fieldOf(cause, 'code') === '23505';
|
|
87
|
+
if (!is23505) return false;
|
|
88
|
+
// Prefer the structured `constraint` name; some pg drivers omit it, so fall
|
|
89
|
+
// back to sniffing the named index out of the message (same approach as
|
|
90
|
+
// db-routine.service's isUniqueViolation).
|
|
91
|
+
const constraint = fieldOf(err, 'constraint') ?? fieldOf(cause, 'constraint');
|
|
92
|
+
if (constraint === OPEN_PAIR_CONSTRAINT) return true;
|
|
93
|
+
const message = fieldOf(err, 'message') ?? fieldOf(cause, 'message');
|
|
94
|
+
return typeof message === 'string' && message.includes(OPEN_PAIR_CONSTRAINT);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Redact the shared GitHub token from any error string before surfacing it. */
|
|
98
|
+
function redactTokens(msg: string): string {
|
|
99
|
+
const tokens = [process.env.GITHUB_TOKEN, process.env.GH_TOKEN].filter(
|
|
100
|
+
(t): t is string => !!t && t.length > 0,
|
|
101
|
+
);
|
|
102
|
+
return tokens.reduce((m, t) => m.replaceAll(t, '***'), msg);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Cap on how many changed paths the `## Affected owners` CR-body block lists
|
|
107
|
+
* individually. The block is passed to `gh pr create --body`, an argv string,
|
|
108
|
+
* so an uncapped per-path list on a large changeset (hundreds/thousands of
|
|
109
|
+
* files) makes the whole command exceed the OS arg limit and fail with E2BIG.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_AFFECTED_PATHS_LISTED = 50;
|
|
112
|
+
|
|
113
|
+
type EligibleWriters = Map<
|
|
114
|
+
string,
|
|
115
|
+
{ roles: string[]; users: { name: string; email: string }[] }
|
|
116
|
+
>;
|
|
117
|
+
|
|
118
|
+
const formatWriter = (u: { name: string; email: string }): string =>
|
|
119
|
+
u.name ? `${u.name} <${u.email}>` : u.email;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render the `## Affected owners` block for the CR body: eligible approvers per
|
|
123
|
+
* changed path, but only the first `maxListed` paths are listed individually;
|
|
124
|
+
* the overflow is collapsed into a single deduped summary of every eligible
|
|
125
|
+
* approver across the change. This keeps the body bounded regardless of how
|
|
126
|
+
* many files the change touches (see {@link MAX_AFFECTED_PATHS_LISTED}).
|
|
127
|
+
* Returns '' when no path has resolvable eligibility.
|
|
128
|
+
*/
|
|
129
|
+
export function formatAffectedOwnersBlock(
|
|
130
|
+
paths: string[],
|
|
131
|
+
resolved: EligibleWriters,
|
|
132
|
+
maxListed: number = MAX_AFFECTED_PATHS_LISTED,
|
|
133
|
+
): string {
|
|
134
|
+
const listed: string[] = [];
|
|
135
|
+
const allRoles = new Set<string>();
|
|
136
|
+
const allUsers = new Map<string, string>(); // email -> display, deduped
|
|
137
|
+
let eligible = 0;
|
|
138
|
+
for (const p of paths) {
|
|
139
|
+
const info = resolved.get(p);
|
|
140
|
+
if (!info) continue;
|
|
141
|
+
const parts: string[] = [];
|
|
142
|
+
if (info.roles.length > 0) {
|
|
143
|
+
info.roles.forEach((r) => allRoles.add(r));
|
|
144
|
+
parts.push(info.roles.join(', '));
|
|
145
|
+
}
|
|
146
|
+
if (info.users.length > 0) {
|
|
147
|
+
info.users.forEach((u) => allUsers.set(u.email, formatWriter(u)));
|
|
148
|
+
parts.push(info.users.map(formatWriter).join(', '));
|
|
149
|
+
}
|
|
150
|
+
if (parts.length === 0) continue;
|
|
151
|
+
eligible++;
|
|
152
|
+
if (listed.length < maxListed) listed.push(`- \`${p}\` — ${parts.join('; ')}`);
|
|
153
|
+
}
|
|
154
|
+
if (eligible === 0) return '';
|
|
155
|
+
|
|
156
|
+
const out = ['## Affected owners', '', ...listed];
|
|
157
|
+
if (eligible > listed.length) {
|
|
158
|
+
const summary = [
|
|
159
|
+
allRoles.size > 0 ? [...allRoles].sort().join(', ') : '',
|
|
160
|
+
allUsers.size > 0 ? [...allUsers.values()].sort().join(', ') : '',
|
|
161
|
+
].filter((s) => s.length > 0);
|
|
162
|
+
out.push(
|
|
163
|
+
'',
|
|
164
|
+
`…and ${eligible - listed.length} more changed path(s). ` +
|
|
165
|
+
`All eligible approvers across this change: ${summary.join('; ')}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return out.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class WorkflowService implements IWorkflowService {
|
|
172
|
+
/**
|
|
173
|
+
* Concrete `PullRequestService` (not the interface) so the facade can call
|
|
174
|
+
* `invalidateDetailCache` after a mutation lands — the legacy routes do
|
|
175
|
+
* this inline, and the workflow facade absorbs that responsibility so its
|
|
176
|
+
* own routes stay free of cache plumbing. Replaceable later when the PR
|
|
177
|
+
* detail cache moves into the workflow module proper (PLAN step 5).
|
|
178
|
+
*
|
|
179
|
+
* `db` + `workspaceService` + `kbDirName` back `openChangeRequest`, which
|
|
180
|
+
* inserts a change_requests row and runs the local auto-merge — no provider
|
|
181
|
+
* PR API, so the KB can live on any git host.
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* Concrete `GitService` (not the interface) so the facade can reach
|
|
185
|
+
* `mergeFromOrigin` — the local auto-merge primitive the workflow needs
|
|
186
|
+
* for `openChangeRequest` + `updateFromTarget` (PLAN §1). The interface
|
|
187
|
+
* intentionally doesn't surface that method because it's an internal
|
|
188
|
+
* implementation detail of the workflow auto-merge story; nothing outside
|
|
189
|
+
* the facade should call it directly.
|
|
190
|
+
*/
|
|
191
|
+
constructor(
|
|
192
|
+
private readonly db: Database,
|
|
193
|
+
private readonly git: GitService,
|
|
194
|
+
private readonly prs: PullRequestService,
|
|
195
|
+
private readonly reviewWorkflow: IReviewWorkflowService,
|
|
196
|
+
private readonly workspaceService: WorkspaceService,
|
|
197
|
+
private readonly accessControl: IAccessControl,
|
|
198
|
+
private readonly fileLocks: FileLockService,
|
|
199
|
+
/**
|
|
200
|
+
* Background commit queue. `releaseLock` enqueues a row instead of
|
|
201
|
+
* committing inline; the `PendingCommitsWorker` drains the queue and
|
|
202
|
+
* runs commit + push out of band. This keeps git's latency and
|
|
203
|
+
* failure modes entirely off the user-facing lock release path.
|
|
204
|
+
* See `lock-decoupling-plan.md` for the full design.
|
|
205
|
+
*/
|
|
206
|
+
private readonly pendingCommits: PendingCommitsService,
|
|
207
|
+
private readonly kbDirName: string,
|
|
208
|
+
/**
|
|
209
|
+
* Event bus that fans state changes out to connected SSE sessions.
|
|
210
|
+
* Optional so test harnesses / minimal boots can omit it — emits
|
|
211
|
+
* become no-ops in that case (we null-guard each call). In a real
|
|
212
|
+
* boot the composition root always wires one.
|
|
213
|
+
*/
|
|
214
|
+
private readonly events?: WorkflowEventBus,
|
|
215
|
+
/**
|
|
216
|
+
* In-process post-commit hook (distinct from the SSE `events` bus): fires
|
|
217
|
+
* after a file's change is committed + pushed so backend services (catalog
|
|
218
|
+
* invalidation, id-repair) react without the per-file TTL wait. Optional,
|
|
219
|
+
* like `events`.
|
|
220
|
+
*/
|
|
221
|
+
private readonly fileChanges?: FileChangeNotifier,
|
|
222
|
+
/**
|
|
223
|
+
* Workflow lifecycle-hook registry (see `workflow-hooks.ts`). PUBLIC so
|
|
224
|
+
* the composition root registers module-owned behavior against it
|
|
225
|
+
* (`core.workflowService.hooks.onCommitValidation(…)` /
|
|
226
|
+
* `.onPreWrite(…)`) after `createCoreServices` returns. The composition
|
|
227
|
+
* root passes the SAME instance `GitService` and the session-ontology
|
|
228
|
+
* gate consult; the default keeps test constructions (which don't
|
|
229
|
+
* exercise hooks) unchanged.
|
|
230
|
+
*/
|
|
231
|
+
public readonly hooks: WorkflowHooks = new WorkflowHooks(),
|
|
232
|
+
) {}
|
|
233
|
+
|
|
234
|
+
// ── Branches ──────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One-shot guard so the orphan-workspace sweep fires once per process,
|
|
238
|
+
* not on every listBranches call. The sweep runs in the background
|
|
239
|
+
* after the first list returns — failures are swallowed and logged.
|
|
240
|
+
*/
|
|
241
|
+
private orphanSweepStarted = false;
|
|
242
|
+
|
|
243
|
+
async listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]> {
|
|
244
|
+
const branches = await this.git.listBranches(workspaceId, opts);
|
|
245
|
+
if (!this.orphanSweepStarted) {
|
|
246
|
+
this.orphanSweepStarted = true;
|
|
247
|
+
// Fire-and-forget. The sweep is best-effort cleanup of dirs whose
|
|
248
|
+
// branches no longer exist on origin; nothing else depends on it
|
|
249
|
+
// completing inside the listBranches window.
|
|
250
|
+
this.workspaceService
|
|
251
|
+
.sweepOrphanedWorkspaces(branches.map((b) => b.name))
|
|
252
|
+
.then(({ removed }) => {
|
|
253
|
+
if (removed.length > 0) {
|
|
254
|
+
console.log(`[workflow] swept ${removed.length} orphaned workspace(s):`, removed);
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
console.warn('[workflow] orphan workspace sweep failed:', err);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return branches;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch> {
|
|
265
|
+
return this.git.createBranch(workspaceId, name, fromBase);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
deleteBranch(
|
|
269
|
+
workspaceId: string,
|
|
270
|
+
name: string,
|
|
271
|
+
user: AuthUser,
|
|
272
|
+
opts?: { onlyIfNoRemote?: boolean },
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
return this.git.deleteBranch(workspaceId, name, user, opts);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// No `switchBranch` here on purpose. Under the per-branch workspace model,
|
|
278
|
+
// switching branches is a frontend navigation that triggers a workspace
|
|
279
|
+
// bootstrap (cloning the new branch into its own dir). The backend no
|
|
280
|
+
// longer exposes a "checkout this branch on the source workspace's clone"
|
|
281
|
+
// operation, which was the only place the legacy DirtyWorkingTreeError
|
|
282
|
+
// could surface as a user-facing 409.
|
|
283
|
+
|
|
284
|
+
// `forkCurrentToDraft` removed (see IWorkflowService): per-branch workspaces
|
|
285
|
+
// make the dirty-tree-carry-along escape hatch impossible by construction.
|
|
286
|
+
|
|
287
|
+
branchStatus(workspaceId: string): Promise<BranchWorkspaceStatus> {
|
|
288
|
+
return this.git.status(workspaceId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// `discardWorkingChanges` removed: under save=share there's nothing dirty
|
|
292
|
+
// to discard.
|
|
293
|
+
|
|
294
|
+
shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void> {
|
|
295
|
+
return this.git.push(workspaceId, user);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
refreshRemotes(workspaceId: string): Promise<void> {
|
|
299
|
+
return this.git.fetch(workspaceId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
updateFromRemote(workspaceId: string): Promise<void> {
|
|
303
|
+
return this.git.pull(workspaceId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
resetToRemote(workspaceId: string, branch: string): Promise<void> {
|
|
307
|
+
return this.git.resetToRemote(workspaceId, branch);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
resolveForkBase(workspaceId: string, branch: string): Promise<string | null> {
|
|
311
|
+
return this.git.resolveForkBase(workspaceId, branch);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
countCommitsAhead(workspaceId: string, branch: string, baseBranch: string): Promise<number> {
|
|
315
|
+
return this.git.countCommitsAhead(workspaceId, branch, baseBranch);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
listPendingChangePaths(workspaceId: string): Promise<string[]> {
|
|
319
|
+
return this.git.pendingChanges(workspaceId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// `listWorkingChanges` + `diffFileInWorking` removed: working-tree dirty
|
|
323
|
+
// state isn't a meaningful surface under save=share.
|
|
324
|
+
|
|
325
|
+
// ── Changes ───────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
commitChange(workspaceId: string, user: AuthUser, input: ChangeInput): Promise<Change> {
|
|
328
|
+
return this.git.commit(workspaceId, user, input);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Atomic multi-file commit + push — commits whatever the caller has already
|
|
333
|
+
* written to the working tree as ONE change attributed to `user`, then pushes
|
|
334
|
+
* so collaborators on the branch see it (same commit-then-push shape as the
|
|
335
|
+
* autosave path). Returns null if the tree is clean (no-op). Behind the bulk
|
|
336
|
+
* node-upload flow and the role-rename rewrite (via `LockingFilesystem`) — both
|
|
337
|
+
* write their files first, then call this. Does NOT write content itself; the
|
|
338
|
+
* caller owns the disk write (and any content validation).
|
|
339
|
+
*/
|
|
340
|
+
async commitChanges(
|
|
341
|
+
workspaceId: string,
|
|
342
|
+
user: AuthUser,
|
|
343
|
+
summary: string,
|
|
344
|
+
): Promise<Change | null> {
|
|
345
|
+
const change = await this.git.commitChanges(workspaceId, user, summary);
|
|
346
|
+
// Same recovery as the per-file queue: a bare push here could strand the
|
|
347
|
+
// just-made local commit (committed but never shared — breaking save=share)
|
|
348
|
+
// on any non-fast-forward race. Pull-rebase + retry recovers the common
|
|
349
|
+
// case; a genuine failure still throws the typed agent-handoff error, with
|
|
350
|
+
// the commit intact for the recovery flow — exactly like `runPendingCommit`.
|
|
351
|
+
// Workspace id IS the encoded branch name (see workspace.service.ts).
|
|
352
|
+
if (change) {
|
|
353
|
+
await this.pushWithRecovery(workspaceId, decodeURIComponent(workspaceId), '(batch)', user);
|
|
354
|
+
}
|
|
355
|
+
return change;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
listChangesForFile(workspaceId: string, path: string, limit?: number): Promise<Change[]> {
|
|
359
|
+
return this.git.logForFile(workspaceId, path, limit);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change> {
|
|
363
|
+
return this.git.revertCommit(workspaceId, user, sha);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
compareFile(
|
|
367
|
+
workspaceId: string,
|
|
368
|
+
path: string,
|
|
369
|
+
fromBranch: string,
|
|
370
|
+
toBranch: string,
|
|
371
|
+
): Promise<string> {
|
|
372
|
+
return this.git.diffFileBetweenBranches(workspaceId, path, fromBranch, toBranch);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
showFileAtChange(workspaceId: string, path: string, sha: string): Promise<string> {
|
|
376
|
+
return this.git.diffFileAtCommit(workspaceId, path, sha);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Resolve write permission for `targetPath` against the access tree at
|
|
381
|
+
* `HEAD` of `branch`. Throws `AccessDeniedError` on denial with the
|
|
382
|
+
* eligible-writers payload attached. Bootstrap-friendly: when no rules are
|
|
383
|
+
* in force at the ref yet, treats the call as allow (matches the legacy
|
|
384
|
+
* commit-time gate's behavior).
|
|
385
|
+
*
|
|
386
|
+
* Always reads at-ref, never at the working tree, so a user can't broaden
|
|
387
|
+
* their own access by editing `roles.yaml` / `access.md` in the same
|
|
388
|
+
* session.
|
|
389
|
+
*/
|
|
390
|
+
private async assertCanWriteAtPath(
|
|
391
|
+
workspaceId: string,
|
|
392
|
+
branch: string,
|
|
393
|
+
userEmail: string,
|
|
394
|
+
targetPath: string,
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
// The lock route passes workspace-relative paths
|
|
397
|
+
// (`knowledge-base/GTM/.../Foo.md`), but the access model is keyed by
|
|
398
|
+
// *repo-relative* paths — `git ls-tree` runs inside the inner repo dir,
|
|
399
|
+
// so its access.md entries are at `GTM/.../access.md`, not
|
|
400
|
+
// `knowledge-base/GTM/.../access.md`. Without stripping the kbDirName
|
|
401
|
+
// prefix here, the chain walk only ever matches root access.md, which
|
|
402
|
+
// typically only grants Admin → non-admins get 403'd on files their
|
|
403
|
+
// role's nested access.md actually permits. Mirrors the frontend's
|
|
404
|
+
// `useFileAccess` prefix-strip on the way INTO the API.
|
|
405
|
+
const repoRelative = targetPath.startsWith(`${this.kbDirName}/`)
|
|
406
|
+
? targetPath.slice(this.kbDirName.length + 1)
|
|
407
|
+
: targetPath;
|
|
408
|
+
const result = await this.accessControl.canWriteBatchAtRef(
|
|
409
|
+
workspaceId,
|
|
410
|
+
`HEAD`,
|
|
411
|
+
userEmail,
|
|
412
|
+
[repoRelative],
|
|
413
|
+
);
|
|
414
|
+
if (!result) return; // no config at ref → default-allow (bootstrap)
|
|
415
|
+
if (result.get(repoRelative)) return;
|
|
416
|
+
const eligible = await this.accessControl.eligibleWritersAtRef(
|
|
417
|
+
workspaceId,
|
|
418
|
+
`HEAD`,
|
|
419
|
+
repoRelative,
|
|
420
|
+
);
|
|
421
|
+
throw new AccessDeniedError({
|
|
422
|
+
path: targetPath,
|
|
423
|
+
eligibleRoles: eligible?.roles ?? [],
|
|
424
|
+
eligibleUsers: eligible?.users ?? [],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── File locks ────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
async acquireLock(
|
|
431
|
+
workspaceId: string,
|
|
432
|
+
branch: string,
|
|
433
|
+
targetPath: string,
|
|
434
|
+
user: AuthUser,
|
|
435
|
+
): Promise<AcquireLockResult> {
|
|
436
|
+
// **Permission check at lock acquisition, not at commit time.** Under the
|
|
437
|
+
// "disk is the source of truth" rule, once a write has landed on disk we
|
|
438
|
+
// must never reject the commit that publishes it — otherwise we'd be
|
|
439
|
+
// destroying the user's work to satisfy a permission rule that should
|
|
440
|
+
// have been enforced earlier. The earliest correct point is HERE, before
|
|
441
|
+
// the user starts editing: if they can't write the file, they don't get
|
|
442
|
+
// the lock and the editor never opens. Once the lock is in hand, the
|
|
443
|
+
// commit + push pipeline does not re-check (see `commitFile`).
|
|
444
|
+
//
|
|
445
|
+
// **Protected branches only** (mirrors the legacy commit-time gate). On
|
|
446
|
+
// feature/draft branches anyone can write; canonical state changes go
|
|
447
|
+
// through change-request approval, which is where the real security
|
|
448
|
+
// boundary lives. Checking at HEAD (not at the working tree) so a user
|
|
449
|
+
// can't grant themselves access by editing `roles.yaml` in the same
|
|
450
|
+
// session.
|
|
451
|
+
if (isProtectedBranch(branch)) {
|
|
452
|
+
// `assertCanWriteAtPath` throws AccessDeniedError on denial, with the
|
|
453
|
+
// eligible-writers payload so the frontend can render a useful refusal.
|
|
454
|
+
await this.assertCanWriteAtPath(workspaceId, branch, user.email, targetPath);
|
|
455
|
+
}
|
|
456
|
+
const result = await this.fileLocks.acquire(workspaceId, branch, targetPath, user);
|
|
457
|
+
if (result.acquired) {
|
|
458
|
+
console.log(
|
|
459
|
+
`[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → acquired`,
|
|
460
|
+
);
|
|
461
|
+
// Only fire on successful acquisition — a contention "false" return
|
|
462
|
+
// means nothing observable changed for other users (someone else
|
|
463
|
+
// already held the lock and still does). Stale-lock takeover is also
|
|
464
|
+
// an acquisition from the perspective of the rest of the UI.
|
|
465
|
+
this.events?.emit({
|
|
466
|
+
kind: 'lock-acquired',
|
|
467
|
+
workspaceId,
|
|
468
|
+
branch,
|
|
469
|
+
path: targetPath,
|
|
470
|
+
holderUserId: user.id,
|
|
471
|
+
holderName: user.name,
|
|
472
|
+
});
|
|
473
|
+
} else {
|
|
474
|
+
console.log(
|
|
475
|
+
`[lock] ACQUIRE ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → contended, held by ${result.lock.holderName} (${result.lock.holderUserId})`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async heartbeatLock(
|
|
482
|
+
workspaceId: string,
|
|
483
|
+
branch: string,
|
|
484
|
+
targetPath: string,
|
|
485
|
+
user: AuthUser,
|
|
486
|
+
): Promise<FileLock> {
|
|
487
|
+
try {
|
|
488
|
+
const lock = await this.fileLocks.heartbeat(workspaceId, branch, targetPath, user);
|
|
489
|
+
console.log(
|
|
490
|
+
`[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ok (expires ${lock.expiresAt})`,
|
|
491
|
+
);
|
|
492
|
+
return lock;
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.warn(
|
|
495
|
+
`[lock] HEARTBEAT ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → FAIL ${err instanceof Error ? err.message : err}`,
|
|
496
|
+
);
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Commit the lock-held file as a change *without* releasing the lock.
|
|
503
|
+
* The autosave checkpoint timer drives this — periodic commits land
|
|
504
|
+
* while the user keeps editing. Refuses when the caller doesn't hold
|
|
505
|
+
* the lock so a stale client can't sneak commits through after their
|
|
506
|
+
* lock expired and another user took over.
|
|
507
|
+
*/
|
|
508
|
+
async commitFileWhileLocked(
|
|
509
|
+
workspaceId: string,
|
|
510
|
+
branch: string,
|
|
511
|
+
targetPath: string,
|
|
512
|
+
user: AuthUser,
|
|
513
|
+
summary?: string,
|
|
514
|
+
): Promise<Change | null> {
|
|
515
|
+
const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
|
|
516
|
+
if (!lock || lock.holderUserId !== user.id) {
|
|
517
|
+
throw new WorkflowValidationError(
|
|
518
|
+
`Cannot commit "${targetPath}": you don't hold the lock.`,
|
|
519
|
+
{ kind: 'lock-not-held', branch, path: targetPath },
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
const change = await this.git.commitFile(workspaceId, user, targetPath, summary);
|
|
523
|
+
// Push the autosave commit so other users on the branch see it
|
|
524
|
+
// without waiting for the eventual lock release. Best-effort, same
|
|
525
|
+
// semantics as the push in `releaseLock`.
|
|
526
|
+
if (change) {
|
|
527
|
+
try {
|
|
528
|
+
await this.git.push(workspaceId, user);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
// Non-fast-forward recovery on the autosave path: try the
|
|
531
|
+
// cooperative `pull --rebase` + retry once. Autosave is
|
|
532
|
+
// best-effort and runs every minute while the lock is held —
|
|
533
|
+
// if cooperative recovery fails (the divergence conflicts with
|
|
534
|
+
// our edit), we DON'T auto-force-push (would destroy teammate
|
|
535
|
+
// commits) and we DON'T surface to the agent yet (the user is
|
|
536
|
+
// still typing — agent involvement would be premature). The
|
|
537
|
+
// eventual `releaseLock` push will hit the same condition and
|
|
538
|
+
// surface a structured `PushNeedsAgentResolutionError` at the
|
|
539
|
+
// moment the user actually closes their edit, which is when
|
|
540
|
+
// the agent should step in.
|
|
541
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
542
|
+
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(detail);
|
|
543
|
+
let recovered = false;
|
|
544
|
+
if (looksLikeNonFastForward) {
|
|
545
|
+
try {
|
|
546
|
+
await this.git.pull(workspaceId);
|
|
547
|
+
await this.git.push(workspaceId, user);
|
|
548
|
+
recovered = true;
|
|
549
|
+
} catch (recoveryErr) {
|
|
550
|
+
console.warn(
|
|
551
|
+
'[workflow] autosave cooperative recovery (pull-rebase) failed; leaving the unpushed commit for the next save / releaseLock to surface:',
|
|
552
|
+
recoveryErr instanceof Error ? recoveryErr.message : recoveryErr,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!recovered) {
|
|
557
|
+
console.warn(
|
|
558
|
+
'[workflow] push after autosave commit failed (commit landed locally):',
|
|
559
|
+
detail,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
this.events?.emit({
|
|
564
|
+
kind: 'file-changed',
|
|
565
|
+
workspaceId,
|
|
566
|
+
branch,
|
|
567
|
+
path: targetPath,
|
|
568
|
+
newSha: change.sha,
|
|
569
|
+
byUserId: user.id,
|
|
570
|
+
byUserName: user.name,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return change;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Release the lock AND commit the file's pending edits as one atomic
|
|
578
|
+
* change. The lock guarantees the file is the only one this caller has
|
|
579
|
+
* been editing, so we use the path-scoped `commitFile` rather than the
|
|
580
|
+
* one-file-per-change-enforcing `commit` — other dirty files in the
|
|
581
|
+
* workspace (e.g. concurrent agent edits on a separate flow) are left
|
|
582
|
+
* alone. Returns `null` when the file had no pending edits, so opening
|
|
583
|
+
* a lock + closing without typing is a clean no-op.
|
|
584
|
+
*/
|
|
585
|
+
/**
|
|
586
|
+
* Release a lock the caller holds. **Does NOT commit or push** — those
|
|
587
|
+
* happen out of band in the `PendingCommitsWorker` after this method
|
|
588
|
+
* enqueues a row. The user-visible contract is:
|
|
589
|
+
*
|
|
590
|
+
* 1. Bytes the caller wrote inside the lock are on disk in the shared
|
|
591
|
+
* per-branch workspace.
|
|
592
|
+
* 2. All same-workspace SSE subscribers see the new bytes via the
|
|
593
|
+
* `fs-tree-changed` event the upload routes already emit.
|
|
594
|
+
* 3. Git history catches up later, asynchronously.
|
|
595
|
+
*
|
|
596
|
+
* This is the structural fix for the bug class that stranded files on
|
|
597
|
+
* disk whenever the inline commit failed (transient git error, process
|
|
598
|
+
* SIGTERM mid-commit, etc.). See `lock-decoupling-plan.md`.
|
|
599
|
+
*
|
|
600
|
+
* Signature change from the previous shape: no more `summary` /
|
|
601
|
+
* `skipValidator` / `skipPush` / return value. Summary is computed by
|
|
602
|
+
* the worker (`buildDefaultCommitSummary(path)`); validator runs in the
|
|
603
|
+
* worker; push is the worker's job; there's no synchronous `Change`
|
|
604
|
+
* object to return because the commit hasn't happened yet.
|
|
605
|
+
*/
|
|
606
|
+
async releaseLock(
|
|
607
|
+
workspaceId: string,
|
|
608
|
+
branch: string,
|
|
609
|
+
targetPath: string,
|
|
610
|
+
user: AuthUser,
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
// Ownership check — the lock service's `release` is idempotent and
|
|
613
|
+
// would silently no-op for a non-holder, but we'd still enqueue a
|
|
614
|
+
// commit attributed to whoever called us. The guard rejects callers
|
|
615
|
+
// who don't actually hold the row so impersonation can't enqueue
|
|
616
|
+
// commits as a third party.
|
|
617
|
+
console.log(
|
|
618
|
+
`[lock] RELEASE start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
|
|
619
|
+
);
|
|
620
|
+
const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
|
|
621
|
+
if (!lock || lock.holderUserId !== user.id) {
|
|
622
|
+
console.warn(
|
|
623
|
+
`[lock] RELEASE refused ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → ${lock ? `held by ${lock.holderName} (${lock.holderUserId})` : 'no lock row'}`,
|
|
624
|
+
);
|
|
625
|
+
throw new WorkflowValidationError(
|
|
626
|
+
`Cannot release lock on "${targetPath}": not held by you (or no longer exists).`,
|
|
627
|
+
{ kind: 'lock-not-held', branch, path: targetPath },
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
// Order matters here:
|
|
631
|
+
// 1. Drop the lock row. Other sessions can re-acquire immediately.
|
|
632
|
+
// 2. Enqueue the commit. Worker will pick it up on next sweep.
|
|
633
|
+
// 3. Emit `lock-released` SSE.
|
|
634
|
+
//
|
|
635
|
+
// We swap (1) and (2) — enqueue first, drop second — so a process
|
|
636
|
+
// crash between the two leaves the lock held (TTL will release it)
|
|
637
|
+
// rather than dropping the lock without queueing the commit (which
|
|
638
|
+
// would re-introduce the orphan-files class of bug this refactor
|
|
639
|
+
// exists to fix). Worst case: brief over-lock that resolves on TTL.
|
|
640
|
+
await this.pendingCommits.enqueue({
|
|
641
|
+
workspaceId,
|
|
642
|
+
branch,
|
|
643
|
+
path: targetPath,
|
|
644
|
+
authorEmail: user.email,
|
|
645
|
+
authorName: user.name,
|
|
646
|
+
});
|
|
647
|
+
await this.fileLocks.release(workspaceId, branch, targetPath, user);
|
|
648
|
+
console.log(
|
|
649
|
+
`[lock] RELEASE done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} (commit queued for background worker)`,
|
|
650
|
+
);
|
|
651
|
+
// The `file-changed` SSE event used to fire here after the synchronous
|
|
652
|
+
// commit landed, carrying the new sha. Under the queue model the
|
|
653
|
+
// commit hasn't happened yet — the worker emits `file-changed` itself
|
|
654
|
+
// (with the resolved sha) when its commit lands. Same downstream
|
|
655
|
+
// semantics for the frontend, just deferred by the queue's drain
|
|
656
|
+
// latency (typically < 1s).
|
|
657
|
+
this.events?.emit({
|
|
658
|
+
kind: 'lock-released',
|
|
659
|
+
workspaceId,
|
|
660
|
+
branch,
|
|
661
|
+
path: targetPath,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Worker entry point: commit whatever's currently on disk for `path`,
|
|
667
|
+
* then push the branch. This is the body that used to live inline in
|
|
668
|
+
* `releaseLock` — extracted so the background queue runs the same
|
|
669
|
+
* commit + cooperative-push pipeline a synchronous save once did.
|
|
670
|
+
*
|
|
671
|
+
* Throws on commit / push failure so the worker can decide whether to
|
|
672
|
+
* retry, hand off to the recovery agent, or escalate. Emits
|
|
673
|
+
* `file-changed` on success so frontends see the new sha.
|
|
674
|
+
*/
|
|
675
|
+
async runPendingCommit(
|
|
676
|
+
workspaceId: string,
|
|
677
|
+
branch: string,
|
|
678
|
+
targetPath: string,
|
|
679
|
+
user: AuthUser,
|
|
680
|
+
): Promise<void> {
|
|
681
|
+
const change = await this.git.commitFile(workspaceId, user, targetPath);
|
|
682
|
+
if (!change) {
|
|
683
|
+
// No-op commit (path was already clean — typical for a double-enqueue
|
|
684
|
+
// or a save of bytes identical to HEAD). Nothing to push; nothing to
|
|
685
|
+
// emit. The worker treats this as success and drops the row.
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
await this.pushWithRecovery(workspaceId, branch, targetPath, user);
|
|
689
|
+
this.events?.emit({
|
|
690
|
+
kind: 'file-changed',
|
|
691
|
+
workspaceId,
|
|
692
|
+
branch,
|
|
693
|
+
path: targetPath,
|
|
694
|
+
newSha: change.sha,
|
|
695
|
+
byUserId: user.id,
|
|
696
|
+
byUserName: user.name,
|
|
697
|
+
});
|
|
698
|
+
// Post-commit, post-lock-release: the one chokepoint every single-file write
|
|
699
|
+
// converges on. Backend reactors (catalog invalidation, id-repair) hook here.
|
|
700
|
+
this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Shared implementation of "push, and on non-fast-forward try a
|
|
705
|
+
* cooperative pull-rebase + retry, then hand off to the agent if that
|
|
706
|
+
* still fails." The full rationale lives at the call site in
|
|
707
|
+
* `releaseLock`; this method is the de-duplicated body.
|
|
708
|
+
*/
|
|
709
|
+
private async pushWithRecovery(
|
|
710
|
+
workspaceId: string,
|
|
711
|
+
branch: string,
|
|
712
|
+
targetPath: string,
|
|
713
|
+
user: AuthUser,
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
try {
|
|
716
|
+
await this.git.push(workspaceId, user);
|
|
717
|
+
} catch (firstPushErr) {
|
|
718
|
+
const firstDetail = firstPushErr instanceof Error ? firstPushErr.message : String(firstPushErr);
|
|
719
|
+
const looksLikeNonFastForward = /non-fast-forward|rejected|fetch first|updates were rejected/i.test(firstDetail);
|
|
720
|
+
let recovered = false;
|
|
721
|
+
let recoveryDetail = '(cooperative path not attempted)';
|
|
722
|
+
if (looksLikeNonFastForward) {
|
|
723
|
+
try {
|
|
724
|
+
await this.git.pull(workspaceId);
|
|
725
|
+
await this.git.push(workspaceId, user);
|
|
726
|
+
recovered = true;
|
|
727
|
+
console.log(
|
|
728
|
+
`[workflow] non-fast-forward push recovered via pull --rebase for workspace=${workspaceId} branch=${branch} path=${targetPath}`,
|
|
729
|
+
);
|
|
730
|
+
} catch (recoveryErr) {
|
|
731
|
+
recoveryDetail = recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr);
|
|
732
|
+
console.warn(
|
|
733
|
+
`[workflow] cooperative recovery (pull-rebase) failed for workspace=${workspaceId} user=${user.id}; handing off to agent:`,
|
|
734
|
+
recoveryDetail,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (!recovered) {
|
|
739
|
+
console.warn(
|
|
740
|
+
`[workflow] push failed for workspace=${workspaceId} user=${user.id}; throwing PushNeedsAgentResolutionError so the frontend can hand off to the agent:`,
|
|
741
|
+
firstDetail,
|
|
742
|
+
);
|
|
743
|
+
throw new PushNeedsAgentResolutionError(branch, targetPath, firstDetail, recoveryDetail);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Failure-path counterpart to `releaseLock`. Skips `commitFile` + push
|
|
750
|
+
* entirely so a half-finished write doesn't accidentally persist its
|
|
751
|
+
* partial state as a committed change. The lock row goes away (so the
|
|
752
|
+
* next caller can edit the file); whatever's on disk stays — the next
|
|
753
|
+
* acquirer will see it. `LockingFilesystem.withLock` calls this from
|
|
754
|
+
* its catch arm so a thrown op (path traversal, validator 422,
|
|
755
|
+
* permission denied) doesn't end up committing.
|
|
756
|
+
*/
|
|
757
|
+
async releaseLockNoCommit(
|
|
758
|
+
workspaceId: string,
|
|
759
|
+
branch: string,
|
|
760
|
+
targetPath: string,
|
|
761
|
+
user: AuthUser,
|
|
762
|
+
): Promise<void> {
|
|
763
|
+
// Verify ownership first so the emit only fires when something
|
|
764
|
+
// observable actually changed. `fileLocks.release` silently no-ops
|
|
765
|
+
// when the caller doesn't hold the row (idempotent-by-design), so
|
|
766
|
+
// without this guard a non-holder calling this method would emit
|
|
767
|
+
// `lock-released` and trick every other client into clearing their
|
|
768
|
+
// "Locked by X" banner even though the lock is still held.
|
|
769
|
+
console.log(
|
|
770
|
+
`[lock] RELEASE-NO-COMMIT start ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id}`,
|
|
771
|
+
);
|
|
772
|
+
const lock = await this.fileLocks.get(workspaceId, branch, targetPath);
|
|
773
|
+
if (!lock || lock.holderUserId !== user.id) {
|
|
774
|
+
console.log(
|
|
775
|
+
`[lock] RELEASE-NO-COMMIT no-op ws=${workspaceId} path=${targetPath} → ${lock ? `held by ${lock.holderName}` : 'no lock row'}`,
|
|
776
|
+
);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
// **Discard the file's working-tree changes before dropping the lock.**
|
|
780
|
+
// The whole point of this method is "release without committing" —
|
|
781
|
+
// historically that meant "leave partial bytes on disk", but the user-
|
|
782
|
+
// visible consequence was a chronically-dirty working tree that
|
|
783
|
+
// blocked branch switches and could be silently committed by the next
|
|
784
|
+
// acquirer under the wrong author. The system's invariant is now:
|
|
785
|
+
// every release leaves the working tree clean. If we're not
|
|
786
|
+
// committing the bytes, we're throwing them away.
|
|
787
|
+
try {
|
|
788
|
+
await this.git.discardPath(workspaceId, targetPath);
|
|
789
|
+
} catch (err) {
|
|
790
|
+
// Best-effort: a discard failure is logged but doesn't block the
|
|
791
|
+
// lock release. Worst case the working tree stays dirty for one
|
|
792
|
+
// path until the next save on it cleans up.
|
|
793
|
+
console.warn(
|
|
794
|
+
`[workflow] discardPath failed for workspace=${workspaceId} branch=${branch} path=${targetPath}:`,
|
|
795
|
+
err instanceof Error ? err.message : err,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
await this.fileLocks.release(workspaceId, branch, targetPath, user);
|
|
799
|
+
console.log(
|
|
800
|
+
`[lock] RELEASE-NO-COMMIT done ws=${workspaceId} branch=${branch} path=${targetPath} user=${user.id} → discarded + released`,
|
|
801
|
+
);
|
|
802
|
+
this.events?.emit({
|
|
803
|
+
kind: 'lock-released',
|
|
804
|
+
workspaceId,
|
|
805
|
+
branch,
|
|
806
|
+
path: targetPath,
|
|
807
|
+
});
|
|
808
|
+
// The disk just changed (revert / removal). Tell anyone watching so
|
|
809
|
+
// their open tabs refresh to the post-discard state.
|
|
810
|
+
this.events?.emit({
|
|
811
|
+
kind: 'file-changed',
|
|
812
|
+
workspaceId,
|
|
813
|
+
branch,
|
|
814
|
+
path: targetPath,
|
|
815
|
+
newSha: null,
|
|
816
|
+
byUserId: user.id,
|
|
817
|
+
byUserName: user.name,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
getLock(
|
|
822
|
+
workspaceId: string,
|
|
823
|
+
branch: string,
|
|
824
|
+
targetPath: string,
|
|
825
|
+
): Promise<FileLock | null> {
|
|
826
|
+
return this.fileLocks.get(workspaceId, branch, targetPath);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── Change Requests ───────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
listChangeRequests(opts?: { fresh?: boolean }): Promise<ChangeRequest[]> {
|
|
832
|
+
return this.prs.listOpenPrs(opts);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
listChangeRequestsAuthoredBy(emailOrLogin: string): Promise<ChangeRequest[]> {
|
|
836
|
+
return this.prs.listPrsAuthoredBy(emailOrLogin);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
listChangeRequestsForUser(
|
|
840
|
+
workspaceId: string,
|
|
841
|
+
email: string,
|
|
842
|
+
opts?: { fresh?: boolean },
|
|
843
|
+
): Promise<ChangeRequest[]> {
|
|
844
|
+
return this.prs.listPrsForOwnerEmail(workspaceId, email, opts);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
getChangeRequest(number: number): Promise<ChangeRequest | null> {
|
|
848
|
+
return this.prs.getPr(number);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
getChangeRequestDetail(
|
|
852
|
+
number: number,
|
|
853
|
+
opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
|
|
854
|
+
): Promise<ChangeRequestDetail | null> {
|
|
855
|
+
return this.prs.getPrDetail(number, opts);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Open a new change request. A change request is a plain `change_requests`
|
|
860
|
+
* row plus the two branches — no provider PR — so any git host works.
|
|
861
|
+
*
|
|
862
|
+
* 1. Validation — non-empty branches, distinct branches, source is not
|
|
863
|
+
* protected, title present.
|
|
864
|
+
* 2. CR uniqueness — refuse if an open CR already exists for the same
|
|
865
|
+
* `(source, target)` pair (A→B blocks A→B; B→A is allowed).
|
|
866
|
+
* 3. Auto-merge — `git merge origin/<target>` into `<source>` locally,
|
|
867
|
+
* so the resulting CR shows the post-merge diff. Conflicts surface
|
|
868
|
+
* as `ChangeRequestConflictsError` (409 with the conflicting paths)
|
|
869
|
+
* so the caller (agent or UI) can route into the resolution flow.
|
|
870
|
+
* 4. Push the source branch so the remote carries every commit the CR
|
|
871
|
+
* will reference, including the auto-merge commit if any.
|
|
872
|
+
* 5. Insert the `change_requests` row (author stored directly — no service
|
|
873
|
+
* account, so no hidden body marker). The DB-assigned number is the CR id.
|
|
874
|
+
* 6. Re-fetch the full detail via the PR service so the response shape
|
|
875
|
+
* matches every other CR endpoint.
|
|
876
|
+
*/
|
|
877
|
+
async openChangeRequest(
|
|
878
|
+
workspaceId: string,
|
|
879
|
+
user: AuthUser,
|
|
880
|
+
input: OpenChangeRequestInput,
|
|
881
|
+
): Promise<ChangeRequestDetail> {
|
|
882
|
+
if (!input.sourceBranch) throw new WorkflowValidationError('sourceBranch is required');
|
|
883
|
+
if (!input.targetBranch) throw new WorkflowValidationError('targetBranch is required');
|
|
884
|
+
if (input.sourceBranch === input.targetBranch) {
|
|
885
|
+
throw new WorkflowValidationError('source and target branches must differ');
|
|
886
|
+
}
|
|
887
|
+
if (!input.title?.trim()) throw new WorkflowValidationError('title is required');
|
|
888
|
+
if (isProtectedBranch(input.sourceBranch)) {
|
|
889
|
+
throw new WorkflowValidationError(
|
|
890
|
+
`Cannot open a change request *from* a protected branch ("${input.sourceBranch}").`,
|
|
891
|
+
{ kind: 'source-is-protected', sourceBranch: input.sourceBranch },
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Uniqueness rule — A→B blocks A→B (the spec is explicit that B→A in
|
|
896
|
+
// parallel is still allowed). `listOpenPrs` is cached for 30s so we
|
|
897
|
+
// force-fresh to catch a CR that opened just now.
|
|
898
|
+
const openCrs = await this.prs.listOpenPrs({ fresh: true });
|
|
899
|
+
const duplicate = openCrs.find(
|
|
900
|
+
(cr) => cr.branch === input.sourceBranch && cr.base === input.targetBranch,
|
|
901
|
+
);
|
|
902
|
+
if (duplicate) {
|
|
903
|
+
throw new DuplicateChangeRequestError(
|
|
904
|
+
input.sourceBranch,
|
|
905
|
+
input.targetBranch,
|
|
906
|
+
duplicate.number,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Auto-merge target → source. `mergeFromOrigin` enforces "current
|
|
911
|
+
// branch == source" and "clean working tree" before attempting; both
|
|
912
|
+
// failures surface as structured workflow errors that pass straight
|
|
913
|
+
// through to the caller.
|
|
914
|
+
const mergeOutcome = await this.git.mergeFromOrigin(
|
|
915
|
+
workspaceId,
|
|
916
|
+
input.sourceBranch,
|
|
917
|
+
input.targetBranch,
|
|
918
|
+
user,
|
|
919
|
+
);
|
|
920
|
+
if (mergeOutcome.kind === 'conflicts') {
|
|
921
|
+
throw new ChangeRequestConflictsError(
|
|
922
|
+
input.sourceBranch,
|
|
923
|
+
input.targetBranch,
|
|
924
|
+
mergeOutcome.paths,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Push the source — `gh pr create` against an unpushed branch returns
|
|
929
|
+
// "head branch does not exist on remote". Push *after* the auto-merge
|
|
930
|
+
// so the remote sees the merge commit too.
|
|
931
|
+
await this.git.push(workspaceId, user);
|
|
932
|
+
|
|
933
|
+
const workspacePath = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
934
|
+
const cwd = path.join(workspacePath, this.kbDirName);
|
|
935
|
+
|
|
936
|
+
// Touched paths between target and source — basis for the
|
|
937
|
+
// affected-owners block we append to the CR body. We compute it
|
|
938
|
+
// ourselves rather than waiting for GitHub to expose `pr.files`
|
|
939
|
+
// (which only populates after the PR exists). Three-dot range
|
|
940
|
+
// matches what `gh pr create` will eventually show as the diff.
|
|
941
|
+
const touchedPaths = await this.listChangedPathsBetweenBranches(
|
|
942
|
+
cwd,
|
|
943
|
+
input.targetBranch,
|
|
944
|
+
input.sourceBranch,
|
|
945
|
+
);
|
|
946
|
+
const affectedOwnersBlock = await this.buildAffectedOwnersBlock(
|
|
947
|
+
workspaceId,
|
|
948
|
+
input.targetBranch,
|
|
949
|
+
touchedPaths,
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
// The change request is a plain DB row now — no provider PR. Author is a
|
|
953
|
+
// real column (no hidden body marker needed, since nothing runs as a shared
|
|
954
|
+
// service account anymore).
|
|
955
|
+
const description = input.description?.trim() ?? '';
|
|
956
|
+
const body = [description, affectedOwnersBlock].filter((s) => s.length > 0).join('\n\n');
|
|
957
|
+
|
|
958
|
+
let prNumber: number;
|
|
959
|
+
try {
|
|
960
|
+
const [row] = await this.db
|
|
961
|
+
.insert(changeRequests)
|
|
962
|
+
.values({
|
|
963
|
+
sourceBranch: input.sourceBranch,
|
|
964
|
+
targetBranch: input.targetBranch,
|
|
965
|
+
title: input.title.trim(),
|
|
966
|
+
body,
|
|
967
|
+
authorEmail: user.email.trim().toLowerCase(),
|
|
968
|
+
authorName: user.name,
|
|
969
|
+
})
|
|
970
|
+
.returning({ number: changeRequests.number });
|
|
971
|
+
prNumber = row.number;
|
|
972
|
+
} catch (err) {
|
|
973
|
+
// The partial unique index (source, target WHERE state='open') is the
|
|
974
|
+
// race-proof backstop for the pre-check above: a concurrent open of the
|
|
975
|
+
// same pair lands here. Surface it as the same duplicate error, with the
|
|
976
|
+
// winning CR's number. Any OTHER unique violation (e.g. the identity
|
|
977
|
+
// `change_requests_number_unq`) is a genuine integrity fault and falls
|
|
978
|
+
// through to the generic path below rather than being mislabeled.
|
|
979
|
+
if (isOpenPairViolation(err)) {
|
|
980
|
+
const [existing] = await this.db
|
|
981
|
+
.select({ number: changeRequests.number })
|
|
982
|
+
.from(changeRequests)
|
|
983
|
+
.where(
|
|
984
|
+
and(
|
|
985
|
+
eq(changeRequests.sourceBranch, input.sourceBranch),
|
|
986
|
+
eq(changeRequests.targetBranch, input.targetBranch),
|
|
987
|
+
eq(changeRequests.state, 'open'),
|
|
988
|
+
),
|
|
989
|
+
)
|
|
990
|
+
.limit(1);
|
|
991
|
+
// Normally the conflicting open CR is right there. If it vanished (the
|
|
992
|
+
// rival was merged/closed in the microsecond between the conflict and
|
|
993
|
+
// this re-query), don't fabricate a #0 — report it as a transient race.
|
|
994
|
+
if (!existing) {
|
|
995
|
+
throw new WorkflowValidationError(
|
|
996
|
+
`Change request for "${input.sourceBranch}" → "${input.targetBranch}" ` +
|
|
997
|
+
'raced with a concurrent open/close; retry.',
|
|
998
|
+
{ kind: 'open-change-request-failed' },
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
throw new DuplicateChangeRequestError(
|
|
1002
|
+
input.sourceBranch,
|
|
1003
|
+
input.targetBranch,
|
|
1004
|
+
existing.number,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
// Non-uniqueness failure AFTER the auto-merge + push already advanced the
|
|
1008
|
+
// source branch: no `change_requests` row was created, but the source now
|
|
1009
|
+
// carries the auto-merge commit. This is safely retryable — re-running
|
|
1010
|
+
// openChangeRequest finds no duplicate, re-runs the (idempotent, already
|
|
1011
|
+
// up-to-date) auto-merge, and inserts the row.
|
|
1012
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
1013
|
+
throw new WorkflowValidationError(
|
|
1014
|
+
`Failed to open change request: ${redactTokens(rawMsg)}`,
|
|
1015
|
+
{ kind: 'open-change-request-failed' },
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
this.prs.invalidateDetailCache(prNumber);
|
|
1019
|
+
|
|
1020
|
+
const detail = await this.prs.getPrDetail(prNumber, {
|
|
1021
|
+
fresh: true,
|
|
1022
|
+
workspaceId,
|
|
1023
|
+
viewerEmail: user.email,
|
|
1024
|
+
});
|
|
1025
|
+
if (!detail) {
|
|
1026
|
+
throw new WorkflowValidationError(
|
|
1027
|
+
`Created change request #${prNumber} but could not fetch its detail`,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
this.events?.emit({
|
|
1031
|
+
kind: 'change-request-opened',
|
|
1032
|
+
number: prNumber,
|
|
1033
|
+
source: input.sourceBranch,
|
|
1034
|
+
target: input.targetBranch,
|
|
1035
|
+
authorIdHash: hashEmail(user.email),
|
|
1036
|
+
title: input.title,
|
|
1037
|
+
});
|
|
1038
|
+
return detail;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Refresh an existing change request from its target — re-runs the
|
|
1043
|
+
* `target → source` auto-merge so the CR diff reflects what the merge
|
|
1044
|
+
* would actually produce against the current target. Conflicts surface
|
|
1045
|
+
* as `ChangeRequestConflictsError`; the caller (UI or agent) is expected
|
|
1046
|
+
* to route into the resolution flow exactly as on open.
|
|
1047
|
+
*
|
|
1048
|
+
* Approvals on files touched by the merge resolution invalidate
|
|
1049
|
+
* automatically — they're pinned to the head SHA, and any new commit on
|
|
1050
|
+
* source drops the per-file approval gate (handled by the existing
|
|
1051
|
+
* approval staleness check). No special bookkeeping needed here.
|
|
1052
|
+
*/
|
|
1053
|
+
async updateFromTarget(
|
|
1054
|
+
workspaceId: string,
|
|
1055
|
+
user: AuthUser,
|
|
1056
|
+
number: number,
|
|
1057
|
+
): Promise<ChangeRequestDetail> {
|
|
1058
|
+
const detail = await this.prs.getPrDetail(number, { fresh: true, workspaceId, viewerEmail: user.email });
|
|
1059
|
+
if (!detail) {
|
|
1060
|
+
throw new WorkflowValidationError(
|
|
1061
|
+
`Change request #${number} not found.`,
|
|
1062
|
+
{ kind: 'change-request-not-found', number },
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
if (detail.state !== 'open') {
|
|
1066
|
+
throw new WorkflowValidationError(
|
|
1067
|
+
`Change request #${number} is ${detail.state} — only open requests can refresh from target.`,
|
|
1068
|
+
{ kind: 'change-request-not-open', state: detail.state },
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
const outcome = await this.git.mergeFromOrigin(
|
|
1072
|
+
workspaceId,
|
|
1073
|
+
detail.branch,
|
|
1074
|
+
detail.base,
|
|
1075
|
+
user,
|
|
1076
|
+
);
|
|
1077
|
+
if (outcome.kind === 'conflicts') {
|
|
1078
|
+
throw new ChangeRequestConflictsError(detail.branch, detail.base, outcome.paths);
|
|
1079
|
+
}
|
|
1080
|
+
// If a new merge commit landed, push so the CR picks it up. When
|
|
1081
|
+
// already up to date there's nothing to share — short-circuit the push.
|
|
1082
|
+
if (!outcome.alreadyUpToDate) {
|
|
1083
|
+
await this.git.push(workspaceId, user);
|
|
1084
|
+
}
|
|
1085
|
+
this.prs.invalidateDetailCache(number);
|
|
1086
|
+
const refreshed = await this.prs.getPrDetail(number, {
|
|
1087
|
+
fresh: true,
|
|
1088
|
+
workspaceId,
|
|
1089
|
+
viewerEmail: user.email,
|
|
1090
|
+
});
|
|
1091
|
+
if (!refreshed) {
|
|
1092
|
+
throw new WorkflowValidationError(
|
|
1093
|
+
`Refreshed change request #${number} but could not re-fetch its detail.`,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
return refreshed;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
listComments(number: number): Promise<ChangeRequestComment[]> {
|
|
1100
|
+
return this.reviewWorkflow.listComments(number);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async postComment(
|
|
1104
|
+
number: number,
|
|
1105
|
+
user: AuthUser,
|
|
1106
|
+
input: PostChangeRequestCommentInput,
|
|
1107
|
+
headSha: string,
|
|
1108
|
+
): Promise<ChangeRequestComment> {
|
|
1109
|
+
const comment = await this.reviewWorkflow.postComment(number, user, input, headSha);
|
|
1110
|
+
this.prs.invalidateDetailCache(number);
|
|
1111
|
+
return comment;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async editComment(
|
|
1115
|
+
commentId: string,
|
|
1116
|
+
number: number,
|
|
1117
|
+
user: AuthUser,
|
|
1118
|
+
body: string,
|
|
1119
|
+
): Promise<ChangeRequestComment> {
|
|
1120
|
+
const updated = await this.reviewWorkflow.editComment(commentId, number, user, body);
|
|
1121
|
+
this.prs.invalidateDetailCache(number);
|
|
1122
|
+
return updated;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async deleteComment(commentId: string, number: number, user: AuthUser): Promise<void> {
|
|
1126
|
+
await this.reviewWorkflow.deleteComment(commentId, number, user);
|
|
1127
|
+
this.prs.invalidateDetailCache(number);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async approveFile(
|
|
1131
|
+
number: number,
|
|
1132
|
+
path: string,
|
|
1133
|
+
user: AuthUser,
|
|
1134
|
+
files: ChangedFile[],
|
|
1135
|
+
headSha: string,
|
|
1136
|
+
baseBranch: string,
|
|
1137
|
+
authorIdHash: string | null,
|
|
1138
|
+
workspaceId: string,
|
|
1139
|
+
): Promise<FileApproval[]> {
|
|
1140
|
+
const approvals = await this.reviewWorkflow.approveFile(
|
|
1141
|
+
number,
|
|
1142
|
+
path,
|
|
1143
|
+
user,
|
|
1144
|
+
files,
|
|
1145
|
+
headSha,
|
|
1146
|
+
baseBranch,
|
|
1147
|
+
authorIdHash,
|
|
1148
|
+
workspaceId,
|
|
1149
|
+
);
|
|
1150
|
+
this.prs.invalidateDetailCache(number);
|
|
1151
|
+
this.events?.emit({
|
|
1152
|
+
kind: 'approval-changed',
|
|
1153
|
+
number,
|
|
1154
|
+
path,
|
|
1155
|
+
approverUserId: user.id,
|
|
1156
|
+
change: 'added',
|
|
1157
|
+
});
|
|
1158
|
+
return approvals;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
async unapproveFile(
|
|
1162
|
+
number: number,
|
|
1163
|
+
path: string,
|
|
1164
|
+
user: AuthUser,
|
|
1165
|
+
files: ChangedFile[],
|
|
1166
|
+
headSha: string,
|
|
1167
|
+
baseBranch: string,
|
|
1168
|
+
authorIdHash: string | null,
|
|
1169
|
+
workspaceId: string,
|
|
1170
|
+
): Promise<FileApproval[]> {
|
|
1171
|
+
const approvals = await this.reviewWorkflow.unapproveFile(
|
|
1172
|
+
number,
|
|
1173
|
+
path,
|
|
1174
|
+
user,
|
|
1175
|
+
files,
|
|
1176
|
+
headSha,
|
|
1177
|
+
baseBranch,
|
|
1178
|
+
authorIdHash,
|
|
1179
|
+
workspaceId,
|
|
1180
|
+
);
|
|
1181
|
+
this.prs.invalidateDetailCache(number);
|
|
1182
|
+
this.events?.emit({
|
|
1183
|
+
kind: 'approval-changed',
|
|
1184
|
+
number,
|
|
1185
|
+
path,
|
|
1186
|
+
approverUserId: user.id,
|
|
1187
|
+
change: 'removed',
|
|
1188
|
+
});
|
|
1189
|
+
return approvals;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Reject (close-without-merging) a change request. Per PLAN.md spec, the
|
|
1194
|
+
* authorized set is:
|
|
1195
|
+
* - the CR author (hash-matched against the body marker), OR
|
|
1196
|
+
* - any user with write permission to *every* changed file on the
|
|
1197
|
+
* target branch, OR
|
|
1198
|
+
* - an admin (write on `roles.yaml` at `origin/<target>`).
|
|
1199
|
+
*
|
|
1200
|
+
* The first and third are enforced by the underlying `cancelPr`. The
|
|
1201
|
+
* second is the workflow's broader rule; we check it here and, on
|
|
1202
|
+
* success, spoof `authorIdHash` to `hashEmail(user.email)` so `cancelPr`
|
|
1203
|
+
* accepts the call via its existing "viewer is author" branch. The
|
|
1204
|
+
* spoofing is safe because no caller outside this method can construct
|
|
1205
|
+
* an `authorIdHash` that matches its own email's hash — `cancelPr`
|
|
1206
|
+
* receives the hash via this internal call only.
|
|
1207
|
+
*/
|
|
1208
|
+
async rejectChangeRequest(
|
|
1209
|
+
number: number,
|
|
1210
|
+
user: AuthUser,
|
|
1211
|
+
state: ChangeRequestState,
|
|
1212
|
+
authorIdHash: string | null,
|
|
1213
|
+
baseBranch: string,
|
|
1214
|
+
workspaceId: string,
|
|
1215
|
+
): Promise<CancelChangeRequestResult> {
|
|
1216
|
+
let effectiveAuthorIdHash = authorIdHash;
|
|
1217
|
+
const callerHash = hashEmail(user.email);
|
|
1218
|
+
const callerIsAuthor = !!(authorIdHash && authorIdHash === callerHash);
|
|
1219
|
+
|
|
1220
|
+
if (!callerIsAuthor) {
|
|
1221
|
+
// Broader rule: write perm on every changed file at origin/<target>.
|
|
1222
|
+
// The path list comes from the PR detail — fetch fresh so the rule
|
|
1223
|
+
// applies against the current head (a stale list would let a user
|
|
1224
|
+
// reject a CR whose files they no longer all own).
|
|
1225
|
+
const detail = await this.prs.getPrDetail(number, {
|
|
1226
|
+
fresh: true,
|
|
1227
|
+
workspaceId,
|
|
1228
|
+
viewerEmail: user.email,
|
|
1229
|
+
});
|
|
1230
|
+
const paths = detail?.files.map((f) => f.path) ?? [];
|
|
1231
|
+
if (paths.length > 0) {
|
|
1232
|
+
const writeMap = await this.accessControl.canWriteBatchAtRef(
|
|
1233
|
+
workspaceId,
|
|
1234
|
+
`origin/${baseBranch}`,
|
|
1235
|
+
user.email,
|
|
1236
|
+
paths,
|
|
1237
|
+
);
|
|
1238
|
+
const writesAll = !!writeMap && paths.every((p) => writeMap.get(p) === true);
|
|
1239
|
+
if (writesAll) {
|
|
1240
|
+
// Spoof so the underlying `cancelPr` authorizes via its viewer-
|
|
1241
|
+
// is-author branch. The admin branch (write on roles.yaml) still
|
|
1242
|
+
// works alongside — `cancelPr` checks both.
|
|
1243
|
+
effectiveAuthorIdHash = callerHash;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const result = await this.reviewWorkflow.cancelPr(
|
|
1249
|
+
number,
|
|
1250
|
+
user,
|
|
1251
|
+
state,
|
|
1252
|
+
effectiveAuthorIdHash,
|
|
1253
|
+
baseBranch,
|
|
1254
|
+
workspaceId,
|
|
1255
|
+
);
|
|
1256
|
+
this.prs.invalidateDetailCache(number);
|
|
1257
|
+
this.events?.emit({ kind: 'change-request-rejected', number });
|
|
1258
|
+
return result;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Merge a change request. The underlying `mergePr` does a local
|
|
1263
|
+
* `git merge --no-ff` + push; when it hits conflicts it throws
|
|
1264
|
+
* `ChangeRequestConflictsError`, which we translate into a structured
|
|
1265
|
+
* `conflicts-need-resolution` outcome (carrying the actual conflicting
|
|
1266
|
+
* paths) so the agent can resolve them on the source branch and retry.
|
|
1267
|
+
*
|
|
1268
|
+
* Hard-block and warning gates (PR state, approvals, admin bypass) are
|
|
1269
|
+
* still enforced by the underlying `mergePr` — this method just adds
|
|
1270
|
+
* the conflict short-circuit on top.
|
|
1271
|
+
*/
|
|
1272
|
+
async mergeChangeRequest(
|
|
1273
|
+
number: number,
|
|
1274
|
+
user: AuthUser,
|
|
1275
|
+
headSha: string,
|
|
1276
|
+
approvals: FileApproval[],
|
|
1277
|
+
state: ChangeRequestState,
|
|
1278
|
+
title: string,
|
|
1279
|
+
baseBranch: string,
|
|
1280
|
+
workspaceId: string,
|
|
1281
|
+
opts?: { bypass?: boolean },
|
|
1282
|
+
): Promise<MergeChangeRequestOutcome> {
|
|
1283
|
+
// Conflicts are surfaced by the local merge itself now (with the actual
|
|
1284
|
+
// conflicting paths) — no provider "mergeable" pre-check needed.
|
|
1285
|
+
|
|
1286
|
+
// roles.yaml is the file that decides admin membership, and only an admin
|
|
1287
|
+
// may edit it (enforced at lock-acquire on protected branches). Feature
|
|
1288
|
+
// branches are a free-for-all, so a non-admin CAN edit roles.yaml on their
|
|
1289
|
+
// own draft — and a CR merge is the one path that lands draft content on a
|
|
1290
|
+
// protected branch. To stop that becoming a privilege-escalation vector, we
|
|
1291
|
+
// never let a CR carry a roles.yaml change across the merge: if the CR's
|
|
1292
|
+
// roles.yaml differs from the base branch's, we restore the base version on
|
|
1293
|
+
// the source branch (commit + push) BEFORE merging, so the merged diff
|
|
1294
|
+
// carries no roles.yaml change. roles.yaml is mutable ONLY via the admin
|
|
1295
|
+
// Roles & Members surface (itself admin-gated). The rest of the CR merges
|
|
1296
|
+
// normally. Best-effort by design is NOT acceptable here — a failure to
|
|
1297
|
+
// neutralise must abort the merge, never fall through.
|
|
1298
|
+
const preserved = await this.preserveBaseRolesYaml(number, user, baseBranch);
|
|
1299
|
+
|
|
1300
|
+
// When preservation pushed a restore commit, the source-branch head advanced,
|
|
1301
|
+
// so the caller's `headSha`/`approvals` (resolved against the pre-preservation
|
|
1302
|
+
// head) are now stale — wrong for the merge log's `headShaAtMerge` and for the
|
|
1303
|
+
// approval-staleness the gate reads. Re-resolve from the live PR before merging.
|
|
1304
|
+
// Fail CLOSED: if the fresh detail can't be loaded we abort rather than fall
|
|
1305
|
+
// back to the stale inputs — merging on outdated gate inputs after a
|
|
1306
|
+
// security-sensitive rewrite is exactly what this path exists to prevent.
|
|
1307
|
+
let mergeHeadSha = headSha;
|
|
1308
|
+
let mergeApprovals = approvals;
|
|
1309
|
+
if (preserved) {
|
|
1310
|
+
const fresh = await this.getChangeRequestDetail(number, {
|
|
1311
|
+
fresh: true,
|
|
1312
|
+
workspaceId,
|
|
1313
|
+
viewerEmail: user.email,
|
|
1314
|
+
});
|
|
1315
|
+
if (!fresh) {
|
|
1316
|
+
throw new RolesYamlPreservationError(
|
|
1317
|
+
`could not reload change request #${number} after roles.yaml preservation; refusing to merge on stale gate inputs`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
mergeHeadSha = fresh.headSha;
|
|
1321
|
+
mergeApprovals = fresh.approvals;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
let result;
|
|
1325
|
+
try {
|
|
1326
|
+
result = await this.reviewWorkflow.mergePr(
|
|
1327
|
+
number,
|
|
1328
|
+
user,
|
|
1329
|
+
mergeHeadSha,
|
|
1330
|
+
mergeApprovals,
|
|
1331
|
+
state,
|
|
1332
|
+
title,
|
|
1333
|
+
baseBranch,
|
|
1334
|
+
workspaceId,
|
|
1335
|
+
opts,
|
|
1336
|
+
);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
// The local merge hit conflicts — hand the caller (agent/UI) the actual
|
|
1339
|
+
// conflicting paths so they can resolve on the source branch and retry.
|
|
1340
|
+
if (err instanceof ChangeRequestConflictsError) {
|
|
1341
|
+
return { kind: 'conflicts-need-resolution', conflictedPaths: err.conflictedPaths };
|
|
1342
|
+
}
|
|
1343
|
+
throw err;
|
|
1344
|
+
}
|
|
1345
|
+
// The merge landed on `origin/<baseBranch>`. Pull the
|
|
1346
|
+
// TARGET branch's own workspace so its working tree doesn't fall behind the
|
|
1347
|
+
// remote — the file tools serve the working tree (not origin), so without
|
|
1348
|
+
// this a read right after a merge misses the just-merged change. Best-effort:
|
|
1349
|
+
// the merge already succeeded on origin, so a pull hiccup must not fail the
|
|
1350
|
+
// response (a later fetch/pull reconciles).
|
|
1351
|
+
try {
|
|
1352
|
+
const targetWorkspace = await this.workspaceService.getOrCreateForBranch(baseBranch);
|
|
1353
|
+
await this.git.pull(targetWorkspace.id);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
console.warn(
|
|
1356
|
+
`[merge] post-merge pull of target "${baseBranch}" failed — its workspace may be momentarily behind origin`,
|
|
1357
|
+
err,
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
this.prs.invalidateDetailCache(number);
|
|
1361
|
+
this.events?.emit({ kind: 'change-request-merged', number });
|
|
1362
|
+
return { kind: 'merged', result };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Guarantee a CR carries NO `roles.yaml` change across its merge: if the CR's
|
|
1367
|
+
* `roles.yaml` differs from the base branch's, restore the base version on the
|
|
1368
|
+
* SOURCE branch (commit + push) so the merged diff has no roles.yaml change.
|
|
1369
|
+
* No-op when the CR doesn't touch roles.yaml. Always resolves identity from
|
|
1370
|
+
* `origin/<base>` and `origin/<head>` (the published state) — never the local
|
|
1371
|
+
* working tree, which a caller could have dirtied.
|
|
1372
|
+
*
|
|
1373
|
+
* Fail-CLOSED: any error restoring the base version throws
|
|
1374
|
+
* {@link RolesYamlPreservationError}, aborting the merge. We must never let a
|
|
1375
|
+
* roles.yaml change slip through because the neutralisation step failed.
|
|
1376
|
+
*/
|
|
1377
|
+
private async preserveBaseRolesYaml(
|
|
1378
|
+
number: number,
|
|
1379
|
+
user: AuthUser,
|
|
1380
|
+
baseBranch: string,
|
|
1381
|
+
): Promise<boolean> {
|
|
1382
|
+
const ROLES_YAML = 'roles.yaml';
|
|
1383
|
+
let headBranch: string;
|
|
1384
|
+
try {
|
|
1385
|
+
const summary = await this.prs.getPr(number);
|
|
1386
|
+
if (!summary) throw new Error(`change request #${number} not found`);
|
|
1387
|
+
// Use the PR's own authoritative base rather than trusting the caller's
|
|
1388
|
+
// baseBranch arg throughout — cross-check to catch stale/mismatched inputs.
|
|
1389
|
+
if (summary.base !== baseBranch)
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
`change request #${number} base mismatch: expected "${baseBranch}", PR reports "${summary.base}"`,
|
|
1392
|
+
);
|
|
1393
|
+
headBranch = summary.branch;
|
|
1394
|
+
// A CR into a protected branch is the only escalation vector we're closing;
|
|
1395
|
+
// base is always a protected branch here, head is always a feature branch.
|
|
1396
|
+
// Guard against a degenerate self-targeting CR (head === base) just in case.
|
|
1397
|
+
if (!headBranch || headBranch === summary.base) return false;
|
|
1398
|
+
|
|
1399
|
+
// Work in the SOURCE branch's own per-branch workspace (already checked out
|
|
1400
|
+
// on that branch). Fetch so we read the CR's TRUE published roles.yaml from
|
|
1401
|
+
// the origin refs below (the comparison never touches the working tree) and
|
|
1402
|
+
// so a restoring commit fast-forwards on push. We deliberately DON'T
|
|
1403
|
+
// `resetToRemote` here: that workspace is shared per-branch and can carry
|
|
1404
|
+
// disk-first edits whose commits are still queued (see PendingCommitsService),
|
|
1405
|
+
// and a hard reset would silently discard them.
|
|
1406
|
+
const ws = await this.workspaceService.getOrCreateForBranch(headBranch);
|
|
1407
|
+
await this.git.fetch(ws.id);
|
|
1408
|
+
|
|
1409
|
+
const baseRoles = await this.git.readFileAtRef(ws.id, `origin/${summary.base}`, ROLES_YAML);
|
|
1410
|
+
const headRoles = await this.git.readFileAtRef(ws.id, `origin/${headBranch}`, ROLES_YAML);
|
|
1411
|
+
|
|
1412
|
+
// Identical (incl. both-absent) → the CR doesn't change roles.yaml. Done.
|
|
1413
|
+
if (baseRoles === headRoles) return false;
|
|
1414
|
+
|
|
1415
|
+
// The restore below writes roles.yaml directly to the shared per-branch
|
|
1416
|
+
// working tree. `LockingFilesystem` serializes every OTHER roles.yaml edit
|
|
1417
|
+
// (human save button, agent write) through the SAME per-file lock, keyed on
|
|
1418
|
+
// the workspace-relative path (`<kbDirName>/roles.yaml`) — see
|
|
1419
|
+
// `access.routes.ts` (`wsEditPath`) and `LockingFilesystem.withLock`. Take
|
|
1420
|
+
// that lock here too, or a concurrent same-branch editor can overwrite our
|
|
1421
|
+
// restore between the write and the commit (or slip a fresh roles.yaml
|
|
1422
|
+
// change past the neutralisation). Contended → the editor holds it → fail
|
|
1423
|
+
// CLOSED: abort the merge rather than race. TTL-expiry on abandoned locks
|
|
1424
|
+
// means a stale holder can't wedge merges forever.
|
|
1425
|
+
const rolesLockPath = `${this.kbDirName}/${ROLES_YAML}`;
|
|
1426
|
+
const lock = await this.fileLocks.acquire(ws.id, headBranch, rolesLockPath, user);
|
|
1427
|
+
if (!lock.acquired) {
|
|
1428
|
+
throw new Error(
|
|
1429
|
+
`roles.yaml is being edited on "${headBranch}" (locked by ${lock.lock.holderName}); refusing to merge until the edit settles`,
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
// Restore the base version on the source branch so the change disappears
|
|
1435
|
+
// from the merge diff. (If base has no roles.yaml at all — baseRoles ===
|
|
1436
|
+
// null — restore by deleting the file.)
|
|
1437
|
+
const repoDir = path.join(await this.workspaceService.getWorkspacePath(ws.id), this.kbDirName);
|
|
1438
|
+
const abs = path.join(repoDir, ROLES_YAML);
|
|
1439
|
+
if (baseRoles === null) {
|
|
1440
|
+
await fs.rm(abs, { force: true });
|
|
1441
|
+
} else {
|
|
1442
|
+
await fs.writeFile(abs, baseRoles, 'utf-8');
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Commit ONLY roles.yaml via commitFile (`git add -- roles.yaml`), never
|
|
1446
|
+
// `commitChanges` (`git add -A`): on this shared per-branch workspace other
|
|
1447
|
+
// files may be dirty from a concurrent same-branch save, and `add -A` would
|
|
1448
|
+
// sweep those unrelated edits into our "preserve roles.yaml" commit under the
|
|
1449
|
+
// wrong author/message. Feature branch → the protected-branch gate doesn't
|
|
1450
|
+
// fire, so this commits cleanly. Then publish so the merge sees the
|
|
1451
|
+
// neutralised source. A non-fast-forward push (the source advanced under us)
|
|
1452
|
+
// throws → RolesYamlPreservationError → the merge aborts, fail-closed.
|
|
1453
|
+
const committed = await this.git.commitFile(
|
|
1454
|
+
ws.id,
|
|
1455
|
+
user,
|
|
1456
|
+
ROLES_YAML,
|
|
1457
|
+
'Preserve official roles.yaml (not editable via change request)',
|
|
1458
|
+
true, // skipValidator — this restores an already-validated base version
|
|
1459
|
+
);
|
|
1460
|
+
|
|
1461
|
+
// We are past the `baseRoles === headRoles` check, so origin/<head> and
|
|
1462
|
+
// origin/<base> DO differ — a restore commit is REQUIRED to protect the
|
|
1463
|
+
// remote. If commitFile produced nothing (the local working tree already
|
|
1464
|
+
// matched the base version, e.g. the per-branch checkout is out of sync
|
|
1465
|
+
// with origin/<head>), then origin/<head> STILL carries the divergent
|
|
1466
|
+
// roles.yaml and the merge would land it. Treat that as a hard failure —
|
|
1467
|
+
// returning false here would fail OPEN. Fail closed instead.
|
|
1468
|
+
if (!committed) {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
'roles.yaml restore produced no commit while origin still diverges from base — source workspace out of sync; refusing to merge',
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
await this.git.push(ws.id, user);
|
|
1474
|
+
this.accessControl.invalidate(ws.id);
|
|
1475
|
+
return true;
|
|
1476
|
+
} finally {
|
|
1477
|
+
// We already committed inline, so drop the lock row directly rather than
|
|
1478
|
+
// going through `releaseLock` (which would enqueue a duplicate commit).
|
|
1479
|
+
await this.fileLocks.release(ws.id, headBranch, rolesLockPath, user);
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1483
|
+
// Log the raw git/push detail server-side; the thrown error keeps it OFF
|
|
1484
|
+
// the client-facing 502 message (see RolesYamlPreservationError).
|
|
1485
|
+
console.warn(`[merge] roles.yaml preservation failed for #${number}:`, detail);
|
|
1486
|
+
throw new RolesYamlPreservationError(detail);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Names of files changed between the target branch (on origin) and the
|
|
1492
|
+
* source (HEAD) — the diff the CR will surface. Three-dot range so
|
|
1493
|
+
* commits that exist only on origin/target but not on source don't
|
|
1494
|
+
* inflate the list. Failures fall back to an empty list — a missing
|
|
1495
|
+
* affected-owners block is cosmetic, not a reason to abort CR creation.
|
|
1496
|
+
*/
|
|
1497
|
+
private async listChangedPathsBetweenBranches(
|
|
1498
|
+
cwd: string,
|
|
1499
|
+
targetBranch: string,
|
|
1500
|
+
sourceBranch: string,
|
|
1501
|
+
): Promise<string[]> {
|
|
1502
|
+
try {
|
|
1503
|
+
const { stdout } = await execFileAsync(
|
|
1504
|
+
'git',
|
|
1505
|
+
['-C', cwd, 'diff', '--name-only', `origin/${targetBranch}...${sourceBranch}`],
|
|
1506
|
+
{ env: { ...process.env }, maxBuffer: 4 * 1024 * 1024 },
|
|
1507
|
+
);
|
|
1508
|
+
return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
console.warn(
|
|
1511
|
+
'[workflow] listChangedPathsBetweenBranches failed:',
|
|
1512
|
+
redactTokens(err instanceof Error ? err.message : String(err)),
|
|
1513
|
+
);
|
|
1514
|
+
return [];
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Build the `## Affected owners` markdown block that gets appended to
|
|
1520
|
+
* the CR body. Lists the eligible approvers (roles + named users) per
|
|
1521
|
+
* changed path, per the access tree on `origin/<targetBranch>` — the same
|
|
1522
|
+
* tree the approval gate uses server-side, so what the body advertises
|
|
1523
|
+
* matches who can actually approve at merge time. The per-path list is
|
|
1524
|
+
* capped (see {@link formatAffectedOwnersBlock}) so the body stays within
|
|
1525
|
+
* the `gh pr create --body` arg limit on large changesets.
|
|
1526
|
+
*
|
|
1527
|
+
* Returns the empty string when there are no touched paths or no
|
|
1528
|
+
* resolvable eligibility — keeps the body clean rather than printing
|
|
1529
|
+
* a stub heading.
|
|
1530
|
+
*/
|
|
1531
|
+
private async buildAffectedOwnersBlock(
|
|
1532
|
+
workspaceId: string,
|
|
1533
|
+
targetBranch: string,
|
|
1534
|
+
paths: string[],
|
|
1535
|
+
): Promise<string> {
|
|
1536
|
+
if (paths.length === 0) return '';
|
|
1537
|
+
let resolved: Awaited<ReturnType<IAccessControl['eligibleWritersForPathsAtRef']>> = null;
|
|
1538
|
+
try {
|
|
1539
|
+
resolved = await this.accessControl.eligibleWritersForPathsAtRef(
|
|
1540
|
+
workspaceId,
|
|
1541
|
+
`origin/${targetBranch}`,
|
|
1542
|
+
paths,
|
|
1543
|
+
);
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
console.warn(
|
|
1546
|
+
'[workflow] eligibleWritersForPathsAtRef failed:',
|
|
1547
|
+
err instanceof Error ? err.message : String(err),
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
if (!resolved || resolved.size === 0) return '';
|
|
1551
|
+
|
|
1552
|
+
return formatAffectedOwnersBlock(paths, resolved);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
}
|