@bevel-software/platform-core-backend 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/core-ports.d.ts +15 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +157 -54
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +97 -5
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +393 -114
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +172 -64
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +365 -19
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -319,4 +319,39 @@ describe('WorkflowEventBus', () => {
|
|
|
319
319
|
expect(bus.size()).toBe(0);
|
|
320
320
|
});
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
describe('onEmit — the server-side tap', () => {
|
|
324
|
+
it('sees every event, unfiltered by SSE scope, and unsubscribes cleanly', () => {
|
|
325
|
+
const bus = new WorkflowEventBus();
|
|
326
|
+
const seen: string[] = [];
|
|
327
|
+
const off = bus.onEmit((e) => seen.push(e.kind));
|
|
328
|
+
// A workspace-scoped event with NO matching SSE session still reaches
|
|
329
|
+
// the tap — scope filtering exists for sessions, not in-process
|
|
330
|
+
// consumers (write-time cache invalidation must never miss one).
|
|
331
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
|
|
332
|
+
expect(seen).toEqual(['fs-tree-changed']);
|
|
333
|
+
off();
|
|
334
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' });
|
|
335
|
+
expect(seen).toEqual(['fs-tree-changed']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('a throwing listener is contained — emit never propagates it', () => {
|
|
339
|
+
const bus = new WorkflowEventBus();
|
|
340
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
341
|
+
bus.onEmit(() => {
|
|
342
|
+
throw new Error('bad listener');
|
|
343
|
+
});
|
|
344
|
+
const after: string[] = [];
|
|
345
|
+
bus.onEmit((e) => after.push(e.kind));
|
|
346
|
+
expect(() =>
|
|
347
|
+
bus.emit({ kind: 'fs-tree-changed', workspaceId: 'ws-1', branch: 'main' }),
|
|
348
|
+
).not.toThrow();
|
|
349
|
+
// …and the listeners after the bad one still ran.
|
|
350
|
+
expect(after).toEqual(['fs-tree-changed']);
|
|
351
|
+
// The containment is LOGGED, not silent — a listener dying quietly
|
|
352
|
+
// would be undebuggable.
|
|
353
|
+
expect(warn).toHaveBeenCalledWith('[event-bus] onEmit listener threw:', 'bad listener');
|
|
354
|
+
warn.mockRestore();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
322
357
|
});
|
|
@@ -1,72 +1,82 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
3
|
-
import type { Database } from '../../database/connection.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
|
|
7
|
-
* insert only when NO row exists for the (workspace, branch, path) in ANY
|
|
8
|
-
* status. A plain `enqueue` would refresh an existing pending row and reset
|
|
9
|
-
* its retry counters — and the dispatch path fires on every sync attempt,
|
|
10
|
-
* so those resets would starve the worker's ladder and the recovery agent
|
|
11
|
-
* would never spawn. A `needs_attention` row must also block re-entry (the
|
|
12
|
-
* ladder already escalated to a human; more agent runs are just a loop).
|
|
13
|
-
*
|
|
14
|
-
* Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
|
|
15
|
-
* behavior (claim fencing etc.) is covered elsewhere; this pins the
|
|
16
|
-
* check-then-insert decision logic.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
3
|
+
import type { Database } from '../../database/connection.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `enqueueIfAbsent` backs the pull-conflict recovery dispatch: it must
|
|
7
|
+
* insert only when NO row exists for the (workspace, branch, path) in ANY
|
|
8
|
+
* status. A plain `enqueue` would refresh an existing pending row and reset
|
|
9
|
+
* its retry counters — and the dispatch path fires on every sync attempt,
|
|
10
|
+
* so those resets would starve the worker's ladder and the recovery agent
|
|
11
|
+
* would never spawn. A `needs_attention` row must also block re-entry (the
|
|
12
|
+
* ladder already escalated to a human; more agent runs are just a loop).
|
|
13
|
+
*
|
|
14
|
+
* Fake drizzle chain, same shape as the vault tests — the queue's SQL-level
|
|
15
|
+
* behavior (claim fencing etc.) is covered elsewhere; this pins the
|
|
16
|
+
* check-then-insert decision logic.
|
|
17
|
+
*/
|
|
18
|
+
interface SelectChain extends PromiseLike<unknown> {
|
|
19
|
+
from: (...args: unknown[]) => SelectChain;
|
|
20
|
+
where: (...args: unknown[]) => SelectChain;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InsertChain extends PromiseLike<unknown> {
|
|
24
|
+
values: (...args: unknown[]) => InsertChain;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeFakeDb(existingCount: number) {
|
|
28
|
+
const inserted: unknown[] = [];
|
|
29
|
+
const selectChain: SelectChain = {
|
|
30
|
+
from: vi.fn(() => selectChain),
|
|
31
|
+
where: vi.fn(() => selectChain),
|
|
32
|
+
then: (onF, onR) => Promise.resolve([{ count: existingCount }]).then(onF, onR),
|
|
33
|
+
};
|
|
34
|
+
const insertChain: InsertChain = {
|
|
35
|
+
values: vi.fn((v: unknown) => {
|
|
36
|
+
inserted.push(v);
|
|
37
|
+
return insertChain;
|
|
38
|
+
}),
|
|
39
|
+
then: (onF, onR) => Promise.resolve(undefined).then(onF, onR),
|
|
40
|
+
};
|
|
41
|
+
const db = {
|
|
42
|
+
select: vi.fn(() => selectChain),
|
|
43
|
+
insert: vi.fn(() => insertChain),
|
|
44
|
+
} as unknown as Database;
|
|
45
|
+
return { db, inserted };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const INPUT = {
|
|
49
|
+
workspaceId: 'razvan.radulescu/feature',
|
|
50
|
+
branch: 'razvan.radulescu/feature',
|
|
51
|
+
path: 'PR-Overviews/PR-7.html',
|
|
52
|
+
authorEmail: 'Alice@Example.com',
|
|
53
|
+
authorName: 'Alice',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe('PendingCommitsService.enqueueIfAbsent', () => {
|
|
57
|
+
it('inserts (canonical workspace id, lowercased email) when no row exists', async () => {
|
|
58
|
+
const { db, inserted } = makeFakeDb(0);
|
|
59
|
+
const svc = new PendingCommitsService(db);
|
|
60
|
+
|
|
61
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(true);
|
|
62
|
+
|
|
63
|
+
expect(inserted).toHaveLength(1);
|
|
64
|
+
expect(inserted[0]).toMatchObject({
|
|
65
|
+
// Same canonicalization as `enqueue` — the worker claims per the
|
|
66
|
+
// ENCODED `knownWorkspaces()` id, so a decoded id would never drain.
|
|
67
|
+
workspaceId: 'razvan.radulescu%2Ffeature',
|
|
68
|
+
branch: 'razvan.radulescu/feature',
|
|
69
|
+
path: 'PR-Overviews/PR-7.html',
|
|
70
|
+
authorEmail: 'alice@example.com',
|
|
71
|
+
authorName: 'Alice',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is a no-op when ANY row exists for the tuple (pending, running, or needs_attention)', async () => {
|
|
76
|
+
const { db, inserted } = makeFakeDb(1);
|
|
77
|
+
const svc = new PendingCommitsService(db);
|
|
78
|
+
|
|
79
|
+
await expect(svc.enqueueIfAbsent(INPUT)).resolves.toBe(false);
|
|
80
|
+
expect(inserted).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -12,12 +12,20 @@ import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
|
12
12
|
import { FileLockService } from '../file-lock.service.js';
|
|
13
13
|
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
14
14
|
import { WorkflowService } from '../workflow.service.js';
|
|
15
|
+
import { PullRebaseConflictError } from '../workflow.errors.js';
|
|
15
16
|
import type { Database } from '../../database/connection.js';
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// `deleteBranch`'s open-request guard is the only DB touch these tests
|
|
19
|
+
// exercise: a chainable select→from→where→limit stub resolving `rows` covers
|
|
20
|
+
// it (rows default to "no open request").
|
|
21
|
+
function makeDb(rows: unknown[] = []): Database {
|
|
22
|
+
const chain = {
|
|
23
|
+
select: vi.fn(() => chain),
|
|
24
|
+
from: vi.fn(() => chain),
|
|
25
|
+
where: vi.fn(() => chain),
|
|
26
|
+
limit: vi.fn(async () => rows),
|
|
27
|
+
};
|
|
28
|
+
return chain as unknown as Database;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
function makeWorkspaceService(): WorkspaceService {
|
|
@@ -28,6 +36,9 @@ function makeWorkspaceService(): WorkspaceService {
|
|
|
28
36
|
getWorkspacePath: vi.fn().mockResolvedValue('/tmp/ws'),
|
|
29
37
|
sweepOrphanedWorkspaces: vi.fn().mockResolvedValue({ removed: [] }),
|
|
30
38
|
getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch) })),
|
|
39
|
+
ensureRemotesFetched: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
hasBootstrappedWorkspace: vi.fn().mockResolvedValue(false),
|
|
41
|
+
deleteWorkspace: vi.fn().mockResolvedValue(undefined),
|
|
31
42
|
} as unknown as WorkspaceService;
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -50,6 +61,7 @@ function makePendingCommits(): PendingCommitsService {
|
|
|
50
61
|
// behavior is covered by `pending-commits.service.test.ts`.
|
|
51
62
|
return {
|
|
52
63
|
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
64
|
+
enqueueIfAbsent: vi.fn().mockResolvedValue(true),
|
|
53
65
|
claimNext: vi.fn().mockResolvedValue(null),
|
|
54
66
|
markSucceeded: vi.fn().mockResolvedValue(undefined),
|
|
55
67
|
markTransientFailure: vi.fn().mockResolvedValue(undefined),
|
|
@@ -128,7 +140,6 @@ function makeGit(): GitService {
|
|
|
128
140
|
diffStat: vi.fn(),
|
|
129
141
|
pendingChanges: vi.fn(),
|
|
130
142
|
resolveForkBase: vi.fn(),
|
|
131
|
-
revertCommit: vi.fn(),
|
|
132
143
|
logForFile: vi.fn(),
|
|
133
144
|
diffFileAtCommit: vi.fn(),
|
|
134
145
|
diffFileBetweenBranches: vi.fn(),
|
|
@@ -327,6 +338,48 @@ describe('WorkflowService — change request delegation + cache invalidation', (
|
|
|
327
338
|
);
|
|
328
339
|
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(4);
|
|
329
340
|
});
|
|
341
|
+
|
|
342
|
+
it('mergeChangeRequest still merges when the post-merge pull conflicts, and queues recovery on the TARGET workspace', async () => {
|
|
343
|
+
const prs = makePrs();
|
|
344
|
+
const reviewWorkflow = makeReviewWorkflow();
|
|
345
|
+
const mergeResult = { prNumber: 4, sha: 'abc', mergedAt: 't' };
|
|
346
|
+
(reviewWorkflow.mergePr as ReturnType<typeof vi.fn>).mockResolvedValue(mergeResult);
|
|
347
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({ branch: 'alice/feat', base: 'main' });
|
|
348
|
+
|
|
349
|
+
const git = makeGit();
|
|
350
|
+
const conflict = new PullRebaseConflictError(
|
|
351
|
+
'main',
|
|
352
|
+
// Deliberately unsorted — the representative row must be the smallest.
|
|
353
|
+
['b-second.md', 'a-first.md'],
|
|
354
|
+
'git rebase failed: could not apply deadbee',
|
|
355
|
+
);
|
|
356
|
+
(git.pull as ReturnType<typeof vi.fn>).mockRejectedValue(conflict);
|
|
357
|
+
const workspaceService = makeWorkspaceService();
|
|
358
|
+
// Return an id that ISN'T derivable from the branch name, pinning that
|
|
359
|
+
// the dispatch uses the id `getOrCreateForBranch` returned rather than
|
|
360
|
+
// re-deriving it.
|
|
361
|
+
(workspaceService.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
362
|
+
id: 'resolved-target-ws',
|
|
363
|
+
});
|
|
364
|
+
const pending = makePendingCommits();
|
|
365
|
+
const svc = new WorkflowService(makeDb(), git, prs, reviewWorkflow, workspaceService, makeAccessControl(), makeFileLockService(), pending, 'knowledge-base');
|
|
366
|
+
|
|
367
|
+
const outcome = await svc.mergeChangeRequest(
|
|
368
|
+
4, makeUser(), 'sha', [], 'open', 'PR title', 'main', 'w1', { bypass: true },
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
// The merge already landed on origin — a stuck target workspace must not
|
|
372
|
+
// fail the response.
|
|
373
|
+
expect(outcome).toEqual({ kind: 'merged', result: mergeResult });
|
|
374
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledTimes(1);
|
|
375
|
+
expect(pending.enqueueIfAbsent).toHaveBeenCalledWith({
|
|
376
|
+
workspaceId: 'resolved-target-ws',
|
|
377
|
+
branch: 'main',
|
|
378
|
+
path: 'a-first.md',
|
|
379
|
+
authorEmail: 'alice@example.com',
|
|
380
|
+
authorName: 'Alice',
|
|
381
|
+
});
|
|
382
|
+
});
|
|
330
383
|
});
|
|
331
384
|
|
|
332
385
|
describe('WorkflowService — file lock delegation', () => {
|
|
@@ -368,6 +421,48 @@ describe('WorkflowService — file lock delegation', () => {
|
|
|
368
421
|
expect(fileLocks.release).toHaveBeenCalledWith('w1', 'feat', 'Foo.md', expect.any(Object));
|
|
369
422
|
});
|
|
370
423
|
|
|
424
|
+
it('deleteBranch refuses while the branch carries an open change request', async () => {
|
|
425
|
+
const git = makeGit();
|
|
426
|
+
// One open request rides the branch — deleting it would strand the request.
|
|
427
|
+
const svc = new WorkflowService(makeDb([{ number: 7 }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
428
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).rejects.toThrow(/open change request \(#7\)/);
|
|
429
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('deleteBranch deletes when the branch has no open change request', async () => {
|
|
433
|
+
const git = makeGit();
|
|
434
|
+
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
435
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).resolves.toBeUndefined();
|
|
436
|
+
expect(git.deleteBranch).toHaveBeenCalledWith('w1', 'feat/x', expect.objectContaining({ email: 'alice@example.com' }), undefined);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The lifecycle lock is what keeps merge-time retirement from deleting a
|
|
441
|
+
* branch mid-`openChangeRequest`. The full interleaving needs a real git
|
|
442
|
+
* repo + DB; what IS unit-testable is the lock's contract — two operations
|
|
443
|
+
* keyed on the same branch never overlap, the second fully waiting out the
|
|
444
|
+
* first.
|
|
445
|
+
*/
|
|
446
|
+
it('serialises same-branch lifecycle operations', async () => {
|
|
447
|
+
const order: string[] = [];
|
|
448
|
+
const git = makeGit();
|
|
449
|
+
let releaseFirst!: () => void;
|
|
450
|
+
const gate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
451
|
+
vi.mocked(git.deleteBranch)
|
|
452
|
+
.mockImplementationOnce(async () => { order.push('first:start'); await gate; order.push('first:end'); })
|
|
453
|
+
.mockImplementationOnce(async () => { order.push('second'); });
|
|
454
|
+
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
455
|
+
const first = svc.deleteBranch('w1', 'feat/x', makeUser());
|
|
456
|
+
const second = svc.deleteBranch('w1', 'feat/x', makeUser());
|
|
457
|
+
// Only release the gate once the first operation is provably inside its
|
|
458
|
+
// critical section — otherwise a non-serialised second could sneak
|
|
459
|
+
// through before 'first:start' and the assertion would not distinguish.
|
|
460
|
+
await vi.waitFor(() => { expect(order).toContain('first:start'); });
|
|
461
|
+
releaseFirst();
|
|
462
|
+
await Promise.all([first, second]);
|
|
463
|
+
expect(order).toEqual(['first:start', 'first:end', 'second']);
|
|
464
|
+
});
|
|
465
|
+
|
|
371
466
|
it('releaseLock refuses when the caller does not hold the lock', async () => {
|
|
372
467
|
const git = makeGit();
|
|
373
468
|
const fileLocks = makeFileLockService(); // default: get → null
|
|
@@ -378,3 +473,198 @@ describe('WorkflowService — file lock delegation', () => {
|
|
|
378
473
|
expect(git.commitFile).not.toHaveBeenCalled();
|
|
379
474
|
});
|
|
380
475
|
});
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The reviewer's per-file scalpel, and the empty-close it can trigger. All
|
|
479
|
+
* collaborators stubbed — what's under test is the orchestration: the auth
|
|
480
|
+
* predicate, the restore→commit→push order, the lock discipline, and the
|
|
481
|
+
* authoritative emptiness re-check before anything closes.
|
|
482
|
+
*/
|
|
483
|
+
describe('WorkflowService — revertChangeRequestFile / closeEmptyChangeRequest', () => {
|
|
484
|
+
const SUMMARY = { number: 7, base: 'main', branch: 'ali/x', state: 'open' };
|
|
485
|
+
|
|
486
|
+
function makeRevertGit(overrides: Record<string, unknown> = {}): GitService {
|
|
487
|
+
return Object.assign(makeGit(), {
|
|
488
|
+
pull: vi.fn().mockResolvedValue(undefined),
|
|
489
|
+
changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md', 'Docs/b.md']),
|
|
490
|
+
mergeBaseForPr: vi.fn().mockResolvedValue('mb-sha'),
|
|
491
|
+
restorePathFromRef: vi.fn().mockResolvedValue(undefined),
|
|
492
|
+
commitFile: vi.fn().mockResolvedValue({}),
|
|
493
|
+
push: vi.fn().mockResolvedValue(undefined),
|
|
494
|
+
...overrides,
|
|
495
|
+
}) as unknown as GitService;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function makeHarness(opts: {
|
|
499
|
+
git?: GitService;
|
|
500
|
+
canWriteAtRef?: boolean | null;
|
|
501
|
+
prState?: string;
|
|
502
|
+
updateRows?: unknown[];
|
|
503
|
+
} = {}) {
|
|
504
|
+
const git = opts.git ?? makeRevertGit();
|
|
505
|
+
const prs = makePrs();
|
|
506
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
507
|
+
...SUMMARY,
|
|
508
|
+
state: opts.prState ?? 'open',
|
|
509
|
+
});
|
|
510
|
+
const access = makeAccessControl();
|
|
511
|
+
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
512
|
+
opts.canWriteAtRef ?? true,
|
|
513
|
+
);
|
|
514
|
+
const fileLocks = makeFileLockService();
|
|
515
|
+
// The chainable stub answers SELECTs with [] (no open request rows) and
|
|
516
|
+
// UPDATE...returning with `updateRows` (default: one row = the close won).
|
|
517
|
+
const updateRows = opts.updateRows ?? [{ id: 1 }];
|
|
518
|
+
const chain: Record<string, unknown> = {};
|
|
519
|
+
Object.assign(chain, {
|
|
520
|
+
select: vi.fn(() => chain),
|
|
521
|
+
from: vi.fn(() => chain),
|
|
522
|
+
where: vi.fn(() => chain),
|
|
523
|
+
limit: vi.fn(async () => []),
|
|
524
|
+
update: vi.fn(() => chain),
|
|
525
|
+
set: vi.fn(() => chain),
|
|
526
|
+
returning: vi.fn(async () => updateRows),
|
|
527
|
+
});
|
|
528
|
+
const db = chain as unknown as Database;
|
|
529
|
+
const svc = new WorkflowService(db, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, fileLocks, makePendingCommits(), 'knowledge-base');
|
|
530
|
+
return { svc, git, prs, access, fileLocks, db: chain };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
it('restores the merge-base copy on the source branch: restore, commit (validator skipped), push — under the file lock', async () => {
|
|
534
|
+
const { svc, git, prs, fileLocks } = makeHarness();
|
|
535
|
+
const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
|
|
536
|
+
|
|
537
|
+
expect(git.restorePathFromRef).toHaveBeenCalledWith('ali%2Fx', 'mb-sha', 'Docs/a.md');
|
|
538
|
+
expect(git.commitFile).toHaveBeenCalledWith(
|
|
539
|
+
'ali%2Fx',
|
|
540
|
+
expect.objectContaining({ email: 'alice@example.com' }),
|
|
541
|
+
'Docs/a.md',
|
|
542
|
+
expect.stringContaining('change request #7'),
|
|
543
|
+
true,
|
|
544
|
+
);
|
|
545
|
+
expect(git.push).toHaveBeenCalled();
|
|
546
|
+
expect(fileLocks.acquire).toHaveBeenCalledWith(
|
|
547
|
+
'ali%2Fx',
|
|
548
|
+
'ali/x',
|
|
549
|
+
'knowledge-base/Docs/a.md',
|
|
550
|
+
expect.anything(),
|
|
551
|
+
);
|
|
552
|
+
expect(fileLocks.release).toHaveBeenCalled();
|
|
553
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
|
|
554
|
+
// Both diffs answered two paths → one revert leaves one.
|
|
555
|
+
expect(result).toEqual({ closed: false, remainingPaths: ['Docs/a.md', 'Docs/b.md'] });
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('refuses a caller who could not approve the file — same permission, both verbs', async () => {
|
|
559
|
+
const { svc, git } = makeHarness({ canWriteAtRef: false });
|
|
560
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
|
|
561
|
+
status: 403,
|
|
562
|
+
});
|
|
563
|
+
expect(git.restorePathFromRef).not.toHaveBeenCalled();
|
|
564
|
+
expect(git.push).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('refuses a path the request does not touch', async () => {
|
|
568
|
+
const { svc, git } = makeHarness();
|
|
569
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/elsewhere.md')).rejects.toMatchObject({
|
|
570
|
+
status: 422,
|
|
571
|
+
});
|
|
572
|
+
expect(git.restorePathFromRef).not.toHaveBeenCalled();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('refuses a request that is no longer open', async () => {
|
|
576
|
+
const { svc } = makeHarness({ prState: 'closed' });
|
|
577
|
+
await expect(svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md')).rejects.toMatchObject({
|
|
578
|
+
status: 422,
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('closes the request when the LAST file is reverted', async () => {
|
|
583
|
+
const git = makeRevertGit({
|
|
584
|
+
changedPathsForPr: vi
|
|
585
|
+
.fn()
|
|
586
|
+
// in order: the pre-revert list, the post-revert remainder, and the
|
|
587
|
+
// close path's own authoritative re-check.
|
|
588
|
+
.mockResolvedValueOnce(['Docs/a.md'])
|
|
589
|
+
.mockResolvedValueOnce([])
|
|
590
|
+
.mockResolvedValueOnce([]),
|
|
591
|
+
});
|
|
592
|
+
const { svc, db } = makeHarness({ git });
|
|
593
|
+
const result = await svc.revertChangeRequestFile(7, makeUser(), 'Docs/a.md');
|
|
594
|
+
expect(result).toEqual({ closed: true, remainingPaths: [] });
|
|
595
|
+
expect(db.update).toHaveBeenCalled();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('closeEmptyChangeRequest never closes on a FAILED diff', async () => {
|
|
599
|
+
const git = makeRevertGit({
|
|
600
|
+
changedPathsForPr: vi.fn().mockRejectedValue(new Error('git down')),
|
|
601
|
+
});
|
|
602
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
603
|
+
const { svc, db } = makeHarness({ git });
|
|
604
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
|
|
605
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
606
|
+
warn.mockRestore();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('closeEmptyChangeRequest leaves a request with real changes alone', async () => {
|
|
610
|
+
const { svc, db } = makeHarness();
|
|
611
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(false);
|
|
612
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('closeEmptyChangeRequest closes an emptied request', async () => {
|
|
616
|
+
const git = makeRevertGit({ changedPathsForPr: vi.fn().mockResolvedValue([]) });
|
|
617
|
+
const { svc, db, prs } = makeHarness({ git });
|
|
618
|
+
await expect(svc.closeEmptyChangeRequest(7, makeUser())).resolves.toBe(true);
|
|
619
|
+
expect(db.update).toHaveBeenCalled();
|
|
620
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(7);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () => {
|
|
625
|
+
function makeDeleteHarness(opts: { isAdmin?: boolean; prState?: string } = {}) {
|
|
626
|
+
const git = Object.assign(makeGit(), {
|
|
627
|
+
changedPathsForPr: vi.fn().mockResolvedValue(['Docs/a.md']),
|
|
628
|
+
}) as unknown as GitService;
|
|
629
|
+
const prs = makePrs();
|
|
630
|
+
(prs.getPr as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
631
|
+
number: 9,
|
|
632
|
+
base: 'main',
|
|
633
|
+
branch: 'mallory/spam',
|
|
634
|
+
state: opts.prState ?? 'open',
|
|
635
|
+
});
|
|
636
|
+
const access = makeAccessControl();
|
|
637
|
+
(access.canWriteAtRef as ReturnType<typeof vi.fn>).mockResolvedValue(opts.isAdmin ?? false);
|
|
638
|
+
const chain: Record<string, unknown> = {};
|
|
639
|
+
Object.assign(chain, {
|
|
640
|
+
select: vi.fn(() => chain),
|
|
641
|
+
from: vi.fn(() => chain),
|
|
642
|
+
where: vi.fn(() => chain),
|
|
643
|
+
limit: vi.fn(async () => []),
|
|
644
|
+
update: vi.fn(() => chain),
|
|
645
|
+
set: vi.fn(() => chain),
|
|
646
|
+
returning: vi.fn(async () => [{ id: 1 }]),
|
|
647
|
+
});
|
|
648
|
+
const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
649
|
+
return { svc, prs, db: chain, access };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
it('refuses a non-admin with 403 and touches nothing', async () => {
|
|
653
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: false });
|
|
654
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
|
|
655
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('an admin closes the request (row flipped, cache dropped)', async () => {
|
|
659
|
+
const { svc, prs, db } = makeDeleteHarness({ isAdmin: true });
|
|
660
|
+
await svc.deleteChangeRequest(9, makeUser());
|
|
661
|
+
expect(db.update).toHaveBeenCalled();
|
|
662
|
+
expect(prs.invalidateDetailCache).toHaveBeenCalledWith(9);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('refuses a merged request — applied history is not deletable', async () => {
|
|
666
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: true, prState: 'merged' });
|
|
667
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 422 });
|
|
668
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
669
|
+
});
|
|
670
|
+
});
|
|
@@ -7,7 +7,9 @@ import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
|
7
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
8
|
import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// A function, not a constant: the branch model is applied during boot, and a
|
|
11
|
+
// module-scope capture would freeze this at the empty set that exists before it.
|
|
12
|
+
const protectedInline = () => [...PROTECTED_BRANCHES].map((b) => `\`${b}\``).join(' / ');
|
|
11
13
|
|
|
12
14
|
// Output sub-schemas for the git-aliased workflow payloads (Branch, Change,
|
|
13
15
|
// ChangeRequestComment, …). Defined once and referenced by the individual tool
|
|
@@ -268,7 +270,7 @@ export function registerWorkflowTools(
|
|
|
268
270
|
name: 'create_branch',
|
|
269
271
|
description:
|
|
270
272
|
'Create a new unprotected draft named `name`, forked from the existing branch `branch`. The ' +
|
|
271
|
-
`backend rejects protected names (${
|
|
273
|
+
`backend rejects protected names (${protectedInline()}) and filesystem-invalid names — surface ` +
|
|
272
274
|
'those errors verbatim. Convention: `<email-localpart>/<kebab-slug>`. Does NOT switch the ' +
|
|
273
275
|
'agent onto the new draft.',
|
|
274
276
|
// `branch` here is the fork BASE — its own meaning — so skip the generic
|
|
@@ -91,6 +91,21 @@ export class WorkflowEventBus {
|
|
|
91
91
|
private nextId = 1;
|
|
92
92
|
private readonly buffer: WorkflowEvent[] = [];
|
|
93
93
|
private readonly subscribers = new Map<string, Subscriber>();
|
|
94
|
+
private readonly emitListeners = new Set<(event: WorkflowEvent) => void>();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Server-side tap: called for EVERY emitted event, before the per-session
|
|
98
|
+
* scope filtering (which exists for SSE fan-out, not for in-process
|
|
99
|
+
* consumers). The composition root uses this for write-time cache
|
|
100
|
+
* invalidation — the routes emit `fs-tree-changed` the moment bytes hit a
|
|
101
|
+
* working tree, long before the async commit reaches `FileChangeNotifier`.
|
|
102
|
+
* Best-effort like SSE dispatch: a throwing listener is logged, never
|
|
103
|
+
* propagated to the emitter. Returns the unsubscribe.
|
|
104
|
+
*/
|
|
105
|
+
onEmit(listener: (event: WorkflowEvent) => void): () => void {
|
|
106
|
+
this.emitListeners.add(listener);
|
|
107
|
+
return () => this.emitListeners.delete(listener);
|
|
108
|
+
}
|
|
94
109
|
|
|
95
110
|
/**
|
|
96
111
|
* Stamp `payload` with `id` + `ts`, append to the ring buffer, then fan
|
|
@@ -108,6 +123,16 @@ export class WorkflowEventBus {
|
|
|
108
123
|
if (this.buffer.length > BUFFER_CAPACITY) {
|
|
109
124
|
this.buffer.shift();
|
|
110
125
|
}
|
|
126
|
+
for (const listener of this.emitListeners) {
|
|
127
|
+
try {
|
|
128
|
+
listener(event);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(
|
|
131
|
+
'[event-bus] onEmit listener threw:',
|
|
132
|
+
err instanceof Error ? err.message : err,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
111
136
|
// Single-line emit log with all the fields most useful for tracing a
|
|
112
137
|
// failed delivery: which event, which scope it'll filter on, and the
|
|
113
138
|
// total subscriber pool size before fan-out. Per-subscriber matched/
|
|
@@ -266,4 +266,21 @@ export class FileLockService {
|
|
|
266
266
|
}
|
|
267
267
|
return rowToFileLock(row);
|
|
268
268
|
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Is ANY unexpired lock held anywhere in this workspace? Feeds the git
|
|
272
|
+
* status sanity check: a held lock means a mutation is mid-flight — the
|
|
273
|
+
* file may already be changed (or deleted) on disk while its commit is only
|
|
274
|
+
* queued at RELEASE — so a dirty tree during that window is expected, not
|
|
275
|
+
* the "missed lock-release commit" the loud warning is for. Expired rows
|
|
276
|
+
* don't count (their holder is gone; they explain nothing).
|
|
277
|
+
*/
|
|
278
|
+
async hasAnyActive(workspaceId: string): Promise<boolean> {
|
|
279
|
+
const rows = await this.db
|
|
280
|
+
.select({ path: fileLocks.path })
|
|
281
|
+
.from(fileLocks)
|
|
282
|
+
.where(and(eq(fileLocks.workspaceId, workspaceId), gt(fileLocks.expiresAt, new Date())))
|
|
283
|
+
.limit(1);
|
|
284
|
+
return rows.length > 0;
|
|
285
|
+
}
|
|
269
286
|
}
|