@bevel-software/platform-core-backend 0.12.0 → 0.13.1
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/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- 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 +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.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 +7 -0
- 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 +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- 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 +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -5,7 +5,10 @@ import os from 'node:os';
|
|
|
5
5
|
import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
|
|
6
6
|
import { LocalFilesystem } from '@mastra/core/workspace';
|
|
7
7
|
import { LockingFilesystem } from '../locking-filesystem.js';
|
|
8
|
-
import { PushNeedsAgentResolutionError } from '../../../shared/domain-errors.js';
|
|
8
|
+
import { PushNeedsAgentResolutionError, WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
9
|
+
|
|
10
|
+
/** The clone folder at the workspace root: every path the filesystem mutates lives under it. */
|
|
11
|
+
const KB = 'knowledge-base';
|
|
9
12
|
|
|
10
13
|
const USER: AuthUser = {
|
|
11
14
|
id: 'user-1',
|
|
@@ -56,20 +59,20 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
56
59
|
const workflow = makeWorkflow();
|
|
57
60
|
const fsLayer = new LockingFilesystem(
|
|
58
61
|
{ basePath: root, contained: true },
|
|
59
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
62
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
60
63
|
);
|
|
61
|
-
await fsLayer.writeFile('Knowledge/Foo.md', 'hello\n');
|
|
64
|
+
await fsLayer.writeFile('knowledge-base/Knowledge/Foo.md', 'hello\n');
|
|
62
65
|
|
|
63
66
|
expect(workflow.acquireLock).toHaveBeenCalledWith(
|
|
64
67
|
'ws-feat',
|
|
65
68
|
'feat',
|
|
66
|
-
'Knowledge/Foo.md',
|
|
69
|
+
'knowledge-base/Knowledge/Foo.md',
|
|
67
70
|
USER,
|
|
68
71
|
);
|
|
69
72
|
expect(workflow.releaseLock).toHaveBeenCalledWith(
|
|
70
73
|
'ws-feat',
|
|
71
74
|
'feat',
|
|
72
|
-
'Knowledge/Foo.md',
|
|
75
|
+
'knowledge-base/Knowledge/Foo.md',
|
|
73
76
|
USER,
|
|
74
77
|
);
|
|
75
78
|
// Acquire fires before release.
|
|
@@ -79,20 +82,21 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
79
82
|
.invocationCallOrder[0];
|
|
80
83
|
expect(acquireOrder).toBeLessThan(releaseOrder);
|
|
81
84
|
// The bytes actually landed on disk.
|
|
82
|
-
expect(await fs.readFile(path.join(root, 'Knowledge/Foo.md'), 'utf-8')).toBe('hello\n');
|
|
85
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/Knowledge/Foo.md'), 'utf-8')).toBe('hello\n');
|
|
83
86
|
});
|
|
84
87
|
|
|
85
88
|
it('deleteFile acquires + deletes + releases', async () => {
|
|
86
|
-
const filePath = path.join(root, 'a.md');
|
|
89
|
+
const filePath = path.join(root, 'knowledge-base/a.md');
|
|
90
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
87
91
|
await fs.writeFile(filePath, 'x', 'utf-8');
|
|
88
92
|
const workflow = makeWorkflow();
|
|
89
93
|
const fsLayer = new LockingFilesystem(
|
|
90
94
|
{ basePath: root, contained: true },
|
|
91
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
95
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
92
96
|
);
|
|
93
|
-
await fsLayer.deleteFile('a.md');
|
|
94
|
-
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'a.md', USER);
|
|
95
|
-
expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'a.md', USER);
|
|
97
|
+
await fsLayer.deleteFile('knowledge-base/a.md');
|
|
98
|
+
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/a.md', USER);
|
|
99
|
+
expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/a.md', USER);
|
|
96
100
|
await expect(fs.access(filePath)).rejects.toBeDefined();
|
|
97
101
|
});
|
|
98
102
|
|
|
@@ -100,49 +104,49 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
100
104
|
const workflow = makeWorkflow();
|
|
101
105
|
const fsLayer = new LockingFilesystem(
|
|
102
106
|
{ basePath: root, contained: true },
|
|
103
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
107
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
104
108
|
);
|
|
105
|
-
await fsLayer.mkdir('empty');
|
|
109
|
+
await fsLayer.mkdir('knowledge-base/empty');
|
|
106
110
|
// .gitkeep got written through the lock path. The lock is on the
|
|
107
111
|
// .gitkeep path (that's the file the commit attaches to).
|
|
108
112
|
expect(workflow.acquireLock).toHaveBeenCalledWith(
|
|
109
113
|
'ws-feat',
|
|
110
114
|
'feat',
|
|
111
|
-
'empty/.gitkeep',
|
|
115
|
+
'knowledge-base/empty/.gitkeep',
|
|
112
116
|
USER,
|
|
113
117
|
);
|
|
114
118
|
expect(workflow.releaseLock).toHaveBeenCalledWith(
|
|
115
119
|
'ws-feat',
|
|
116
120
|
'feat',
|
|
117
|
-
'empty/.gitkeep',
|
|
121
|
+
'knowledge-base/empty/.gitkeep',
|
|
118
122
|
USER,
|
|
119
123
|
);
|
|
120
|
-
expect(await fs.readFile(path.join(root, 'empty/.gitkeep'), 'utf-8')).toBe('');
|
|
124
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/empty/.gitkeep'), 'utf-8')).toBe('');
|
|
121
125
|
});
|
|
122
126
|
|
|
123
127
|
it('mkdir of an already-populated dir does NOT drop a .gitkeep', async () => {
|
|
124
|
-
await fs.mkdir(path.join(root, 'has-content'), { recursive: true });
|
|
125
|
-
await fs.writeFile(path.join(root, 'has-content/real.md'), 'hi', 'utf-8');
|
|
128
|
+
await fs.mkdir(path.join(root, 'knowledge-base/has-content'), { recursive: true });
|
|
129
|
+
await fs.writeFile(path.join(root, 'knowledge-base/has-content/real.md'), 'hi', 'utf-8');
|
|
126
130
|
const workflow = makeWorkflow();
|
|
127
131
|
const fsLayer = new LockingFilesystem(
|
|
128
132
|
{ basePath: root, contained: true },
|
|
129
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
133
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
130
134
|
);
|
|
131
|
-
await fsLayer.mkdir('has-content', { recursive: true });
|
|
135
|
+
await fsLayer.mkdir('knowledge-base/has-content', { recursive: true });
|
|
132
136
|
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
133
137
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
134
138
|
// The existing file is untouched.
|
|
135
|
-
expect(await fs.readFile(path.join(root, 'has-content/real.md'), 'utf-8')).toBe('hi');
|
|
139
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/has-content/real.md'), 'utf-8')).toBe('hi');
|
|
136
140
|
});
|
|
137
141
|
|
|
138
142
|
it('rmdir is refused with a clear error — one-change-per-file invariant', async () => {
|
|
139
|
-
await fs.mkdir(path.join(root, 'doomed'), { recursive: true });
|
|
143
|
+
await fs.mkdir(path.join(root, 'knowledge-base/doomed'), { recursive: true });
|
|
140
144
|
const workflow = makeWorkflow();
|
|
141
145
|
const fsLayer = new LockingFilesystem(
|
|
142
146
|
{ basePath: root, contained: true },
|
|
143
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
147
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
144
148
|
);
|
|
145
|
-
await expect(fsLayer.rmdir('doomed')).rejects.toThrow(
|
|
149
|
+
await expect(fsLayer.rmdir('knowledge-base/doomed')).rejects.toThrow(
|
|
146
150
|
/Recursive directory removal is not supported/i,
|
|
147
151
|
);
|
|
148
152
|
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
@@ -157,7 +161,7 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
157
161
|
acquired: false,
|
|
158
162
|
lock: {
|
|
159
163
|
branch: 'feat',
|
|
160
|
-
path: 'Locked.md',
|
|
164
|
+
path: 'knowledge-base/Locked.md',
|
|
161
165
|
holderUserId: 'bob',
|
|
162
166
|
holderName: 'Bob',
|
|
163
167
|
acquiredAt: '',
|
|
@@ -167,11 +171,11 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
167
171
|
});
|
|
168
172
|
const fsLayer = new LockingFilesystem(
|
|
169
173
|
{ basePath: root, contained: true },
|
|
170
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
174
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
171
175
|
);
|
|
172
176
|
|
|
173
|
-
await expect(fsLayer.writeFile('Locked.md', 'x')).rejects.toThrow(
|
|
174
|
-
/Skipped editing "Locked\.md" — locked by Bob/,
|
|
177
|
+
await expect(fsLayer.writeFile('knowledge-base/Locked.md', 'x')).rejects.toThrow(
|
|
178
|
+
/Skipped editing "knowledge-base\/Locked\.md" — locked by Bob/,
|
|
175
179
|
);
|
|
176
180
|
expect(workflow.acquireLock).toHaveBeenCalledTimes(3);
|
|
177
181
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
@@ -183,15 +187,22 @@ describe('LockingFilesystem — every mutating op runs acquire → super → rel
|
|
|
183
187
|
const workflow = makeWorkflow();
|
|
184
188
|
const fsLayer = new LockingFilesystem(
|
|
185
189
|
{ basePath: root, contained: true },
|
|
186
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
187
|
-
);
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
191
|
+
);
|
|
192
|
+
// The underlying write dies (disk full, permissions, whatever). The lock
|
|
193
|
+
// must still go away so the next caller can edit a different file, but
|
|
194
|
+
// releaseLock (which would commit + push whatever partial state is on
|
|
195
|
+
// disk) must NOT fire — instead we use the no-commit variant so partial
|
|
196
|
+
// writes don't silently persist as committed changes.
|
|
197
|
+
const spy = vi
|
|
198
|
+
.spyOn(LocalFilesystem.prototype, 'writeFile')
|
|
199
|
+
.mockRejectedValue(new Error('disk exploded'));
|
|
200
|
+
try {
|
|
201
|
+
await expect(fsLayer.writeFile('knowledge-base/Boom.md', 'x')).rejects.toThrow('disk exploded');
|
|
202
|
+
} finally {
|
|
203
|
+
spy.mockRestore();
|
|
204
|
+
}
|
|
205
|
+
expect(workflow.acquireLock).toHaveBeenCalled();
|
|
195
206
|
expect(workflow.releaseLockNoCommit).toHaveBeenCalled();
|
|
196
207
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
197
208
|
});
|
|
@@ -209,8 +220,8 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
209
220
|
});
|
|
210
221
|
|
|
211
222
|
it('locks writes AND deletes, applies both, emits ONE batched onFilesChanged', async () => {
|
|
212
|
-
await fs.mkdir(path.join(root, 'Tools'), { recursive: true });
|
|
213
|
-
await fs.writeFile(path.join(root, 'Tools/old.tool'), '{}');
|
|
223
|
+
await fs.mkdir(path.join(root, 'knowledge-base/Tools'), { recursive: true });
|
|
224
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Tools/old.tool'), '{}');
|
|
214
225
|
|
|
215
226
|
const workflow = makeWorkflow();
|
|
216
227
|
const emitted: unknown[] = [];
|
|
@@ -220,36 +231,36 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
220
231
|
workflow,
|
|
221
232
|
workspaceId: 'ws-feat',
|
|
222
233
|
branch: 'feat',
|
|
223
|
-
user: USER,
|
|
234
|
+
user: USER, kbDirName: KB,
|
|
224
235
|
fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
|
|
225
236
|
},
|
|
226
237
|
);
|
|
227
238
|
|
|
228
239
|
const change = await fsLayer.writeFiles(
|
|
229
|
-
[{ path: 'Tools/new.tool', content: '{"type":"http","url":"https://x/m"}' }],
|
|
240
|
+
[{ path: 'knowledge-base/Tools/new.tool', content: '{"type":"http","url":"https://x/m"}' }],
|
|
230
241
|
'batch',
|
|
231
|
-
['Tools/old.tool'],
|
|
242
|
+
['knowledge-base/Tools/old.tool'],
|
|
232
243
|
);
|
|
233
244
|
|
|
234
245
|
// Both paths locked; both mutations landed; commit returned.
|
|
235
|
-
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'Tools/new.tool', USER);
|
|
236
|
-
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'Tools/old.tool', USER);
|
|
237
|
-
expect(await fs.readFile(path.join(root, 'Tools/new.tool'), 'utf-8')).toContain('http');
|
|
238
|
-
await expect(fs.access(path.join(root, 'Tools/old.tool'))).rejects.toThrow();
|
|
246
|
+
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/new.tool', USER);
|
|
247
|
+
expect(workflow.acquireLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/old.tool', USER);
|
|
248
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/Tools/new.tool'), 'utf-8')).toContain('http');
|
|
249
|
+
await expect(fs.access(path.join(root, 'knowledge-base/Tools/old.tool'))).rejects.toThrow();
|
|
239
250
|
expect(change).toEqual({});
|
|
240
251
|
// ONE event carrying the whole batch (sorted), attributed to the user.
|
|
241
252
|
expect(emitted).toHaveLength(1);
|
|
242
253
|
expect(emitted[0]).toMatchObject({
|
|
243
254
|
workspaceId: 'ws-feat',
|
|
244
255
|
branch: 'feat',
|
|
245
|
-
paths: ['Tools/new.tool', 'Tools/old.tool'],
|
|
256
|
+
paths: ['knowledge-base/Tools/new.tool', 'knowledge-base/Tools/old.tool'],
|
|
246
257
|
byUser: USER,
|
|
247
258
|
});
|
|
248
259
|
});
|
|
249
260
|
|
|
250
261
|
it('a failing commit releases every lock WITHOUT committing and emits nothing', async () => {
|
|
251
|
-
await fs.mkdir(path.join(root, 'Tools'), { recursive: true });
|
|
252
|
-
await fs.writeFile(path.join(root, 'Tools/old.tool'), '{}');
|
|
262
|
+
await fs.mkdir(path.join(root, 'knowledge-base/Tools'), { recursive: true });
|
|
263
|
+
await fs.writeFile(path.join(root, 'knowledge-base/Tools/old.tool'), '{}');
|
|
253
264
|
|
|
254
265
|
const workflow = makeWorkflow();
|
|
255
266
|
(workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('push exploded'));
|
|
@@ -260,16 +271,16 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
260
271
|
workflow,
|
|
261
272
|
workspaceId: 'ws-feat',
|
|
262
273
|
branch: 'feat',
|
|
263
|
-
user: USER,
|
|
274
|
+
user: USER, kbDirName: KB,
|
|
264
275
|
fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
|
|
265
276
|
},
|
|
266
277
|
);
|
|
267
278
|
|
|
268
279
|
await expect(
|
|
269
280
|
fsLayer.writeFiles(
|
|
270
|
-
[{ path: 'Tools/new.tool', content: '{"type":"http","url":"https://x/m"}' }],
|
|
281
|
+
[{ path: 'knowledge-base/Tools/new.tool', content: '{"type":"http","url":"https://x/m"}' }],
|
|
271
282
|
'batch',
|
|
272
|
-
['Tools/old.tool'],
|
|
283
|
+
['knowledge-base/Tools/old.tool'],
|
|
273
284
|
),
|
|
274
285
|
).rejects.toThrow('push exploded');
|
|
275
286
|
|
|
@@ -277,8 +288,8 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
277
288
|
// discards the uncommitted bytes (write reverted, delete restored) in the
|
|
278
289
|
// real WorkflowService via git.discardPath. The committing release must
|
|
279
290
|
// never run, and no change event fires for a failed batch.
|
|
280
|
-
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'Tools/new.tool', USER);
|
|
281
|
-
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'Tools/old.tool', USER);
|
|
291
|
+
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/new.tool', USER);
|
|
292
|
+
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/Tools/old.tool', USER);
|
|
282
293
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
283
294
|
expect(emitted).toHaveLength(0);
|
|
284
295
|
});
|
|
@@ -293,7 +304,7 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
293
304
|
// push ladder) and still propagate the error.
|
|
294
305
|
const workflow = makeWorkflow();
|
|
295
306
|
(workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
296
|
-
new PushNeedsAgentResolutionError('feat', 'A.md', 'non-fast-forward', 'rebase failed'),
|
|
307
|
+
new PushNeedsAgentResolutionError('feat', 'knowledge-base/A.md', 'non-fast-forward', 'rebase failed'),
|
|
297
308
|
);
|
|
298
309
|
const emitted: unknown[] = [];
|
|
299
310
|
const fsLayer = new LockingFilesystem(
|
|
@@ -302,16 +313,16 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
302
313
|
workflow,
|
|
303
314
|
workspaceId: 'ws-feat',
|
|
304
315
|
branch: 'feat',
|
|
305
|
-
user: USER,
|
|
316
|
+
user: USER, kbDirName: KB,
|
|
306
317
|
fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
|
|
307
318
|
},
|
|
308
319
|
);
|
|
309
320
|
|
|
310
321
|
await expect(
|
|
311
|
-
fsLayer.writeFiles([{ path: 'A.md', content: 'x' }], 'batch'),
|
|
322
|
+
fsLayer.writeFiles([{ path: 'knowledge-base/A.md', content: 'x' }], 'batch'),
|
|
312
323
|
).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
313
324
|
|
|
314
|
-
expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'A.md', USER);
|
|
325
|
+
expect(workflow.releaseLock).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/A.md', USER);
|
|
315
326
|
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
|
|
316
327
|
expect(emitted).toHaveLength(0);
|
|
317
328
|
});
|
|
@@ -326,11 +337,11 @@ describe('LockingFilesystem.writeFiles — batch with deletes + one batched chan
|
|
|
326
337
|
workflow,
|
|
327
338
|
workspaceId: 'ws-feat',
|
|
328
339
|
branch: 'feat',
|
|
329
|
-
user: USER,
|
|
340
|
+
user: USER, kbDirName: KB,
|
|
330
341
|
fileChanges: { emit: (c: unknown) => emitted.push(c) } as never,
|
|
331
342
|
},
|
|
332
343
|
);
|
|
333
|
-
await fsLayer.writeFiles([{ path: 'A.md', content: 'same' }], 'noop');
|
|
344
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/A.md', content: 'same' }], 'noop');
|
|
334
345
|
expect(emitted).toHaveLength(0);
|
|
335
346
|
});
|
|
336
347
|
});
|
|
@@ -363,16 +374,16 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
363
374
|
});
|
|
364
375
|
const fsLayer = new LockingFilesystem(
|
|
365
376
|
{ basePath: root, contained: true },
|
|
366
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
377
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
367
378
|
);
|
|
368
|
-
await fsLayer.writeFile('KnowledgeBase/new.md', '# New\n');
|
|
379
|
+
await fsLayer.writeFile('knowledge-base/KnowledgeBase/new.md', '# New\n');
|
|
369
380
|
expect(creatorAccess.planForCreate).toHaveBeenCalledWith(
|
|
370
381
|
'ws-feat',
|
|
371
382
|
USER,
|
|
372
|
-
'KnowledgeBase/new.md',
|
|
383
|
+
'knowledge-base/KnowledgeBase/new.md',
|
|
373
384
|
'file',
|
|
374
385
|
);
|
|
375
|
-
expect(await fs.readFile(path.join(root, 'KnowledgeBase/new.md'), 'utf-8')).toBe(
|
|
386
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/new.md'), 'utf-8')).toBe(
|
|
376
387
|
'---\nread: Alice <alice@example.com>\n---\n# New\n',
|
|
377
388
|
);
|
|
378
389
|
});
|
|
@@ -381,10 +392,10 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
381
392
|
const workflow = makeWorkflow();
|
|
382
393
|
const creatorAccess = makeCreatorAccess();
|
|
383
394
|
creatorAccess.planForCreate.mockImplementation(async (_w, _u, p: string) =>
|
|
384
|
-
p === 'KnowledgeBase/Mine/doc.md'
|
|
395
|
+
p === 'knowledge-base/KnowledgeBase/Mine/doc.md'
|
|
385
396
|
? {
|
|
386
397
|
kind: 'seed-access-md',
|
|
387
|
-
wsRelPath: 'KnowledgeBase/Mine/access.md',
|
|
398
|
+
wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
388
399
|
apply: (current: string) =>
|
|
389
400
|
current + '---\nread:\n - Alice <alice@example.com>\n---\n',
|
|
390
401
|
}
|
|
@@ -392,18 +403,18 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
392
403
|
);
|
|
393
404
|
const fsLayer = new LockingFilesystem(
|
|
394
405
|
{ basePath: root, contained: true },
|
|
395
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
406
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
396
407
|
);
|
|
397
|
-
await fsLayer.writeFile('KnowledgeBase/Mine/doc.md', 'body');
|
|
408
|
+
await fsLayer.writeFile('knowledge-base/KnowledgeBase/Mine/doc.md', 'body');
|
|
398
409
|
const locked = (workflow.acquireLock as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
399
410
|
(c) => c[2],
|
|
400
411
|
);
|
|
401
|
-
expect(locked).toEqual(['KnowledgeBase/Mine/access.md', 'KnowledgeBase/Mine/doc.md']);
|
|
412
|
+
expect(locked).toEqual(['knowledge-base/KnowledgeBase/Mine/access.md', 'knowledge-base/KnowledgeBase/Mine/doc.md']);
|
|
402
413
|
expect(
|
|
403
|
-
await fs.readFile(path.join(root, 'KnowledgeBase/Mine/access.md'), 'utf-8'),
|
|
414
|
+
await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), 'utf-8'),
|
|
404
415
|
).toContain('Alice <alice@example.com>');
|
|
405
416
|
// The file content is untouched — the grant lives in the seeded access.md.
|
|
406
|
-
expect(await fs.readFile(path.join(root, 'KnowledgeBase/Mine/doc.md'), 'utf-8')).toBe('body');
|
|
417
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/doc.md'), 'utf-8')).toBe('body');
|
|
407
418
|
expect(creatorAccess.noteAccessFileWritten).toHaveBeenCalledWith('ws-feat');
|
|
408
419
|
});
|
|
409
420
|
|
|
@@ -421,14 +432,14 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
421
432
|
);
|
|
422
433
|
const fsLayer = new LockingFilesystem(
|
|
423
434
|
{ basePath: root, contained: true },
|
|
424
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
435
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
425
436
|
);
|
|
426
|
-
await fsLayer.mkdir('KnowledgeBase/Projects');
|
|
437
|
+
await fsLayer.mkdir('knowledge-base/KnowledgeBase/Projects');
|
|
427
438
|
expect(
|
|
428
|
-
await fs.readFile(path.join(root, 'KnowledgeBase/Projects/access.md'), 'utf-8'),
|
|
439
|
+
await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Projects/access.md'), 'utf-8'),
|
|
429
440
|
).toContain('Alice <alice@example.com>');
|
|
430
441
|
await expect(
|
|
431
|
-
fs.access(path.join(root, 'KnowledgeBase/Projects/.gitkeep')),
|
|
442
|
+
fs.access(path.join(root, 'knowledge-base/KnowledgeBase/Projects/.gitkeep')),
|
|
432
443
|
).rejects.toBeDefined();
|
|
433
444
|
});
|
|
434
445
|
|
|
@@ -437,32 +448,32 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
437
448
|
const creatorAccess = makeCreatorAccess();
|
|
438
449
|
creatorAccess.planForCreate.mockResolvedValue({
|
|
439
450
|
kind: 'seed-access-md',
|
|
440
|
-
wsRelPath: 'KnowledgeBase/Mine/access.md',
|
|
451
|
+
wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
441
452
|
apply: () => '---\nread:\n - Alice <alice@example.com>\n---\n',
|
|
442
453
|
});
|
|
443
454
|
const fsLayer = new LockingFilesystem(
|
|
444
455
|
{ basePath: root, contained: true },
|
|
445
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
456
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
446
457
|
);
|
|
447
458
|
await fsLayer.writeFiles(
|
|
448
459
|
[
|
|
449
|
-
{ path: 'KnowledgeBase/Mine/a.md', content: 'A' },
|
|
450
|
-
{ path: 'KnowledgeBase/Mine/b.md', content: 'B' },
|
|
460
|
+
{ path: 'knowledge-base/KnowledgeBase/Mine/a.md', content: 'A' },
|
|
461
|
+
{ path: 'knowledge-base/KnowledgeBase/Mine/b.md', content: 'B' },
|
|
451
462
|
],
|
|
452
463
|
'batch',
|
|
453
464
|
);
|
|
454
465
|
// One seed for the shared new folder, committed with the batch.
|
|
455
466
|
expect(
|
|
456
|
-
await fs.readFile(path.join(root, 'KnowledgeBase/Mine/access.md'), 'utf-8'),
|
|
467
|
+
await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), 'utf-8'),
|
|
457
468
|
).toContain('Alice <alice@example.com>');
|
|
458
469
|
expect(workflow.commitChanges).toHaveBeenCalledTimes(1);
|
|
459
470
|
const locked = (workflow.acquireLock as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
460
471
|
(c) => c[2],
|
|
461
472
|
);
|
|
462
473
|
expect(locked).toEqual([
|
|
463
|
-
'KnowledgeBase/Mine/a.md',
|
|
464
|
-
'KnowledgeBase/Mine/b.md',
|
|
465
|
-
'KnowledgeBase/Mine/access.md',
|
|
474
|
+
'knowledge-base/KnowledgeBase/Mine/a.md',
|
|
475
|
+
'knowledge-base/KnowledgeBase/Mine/b.md',
|
|
476
|
+
'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
466
477
|
]);
|
|
467
478
|
expect(creatorAccess.noteAccessFileWritten).toHaveBeenCalledWith('ws-feat');
|
|
468
479
|
});
|
|
@@ -474,14 +485,14 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
474
485
|
// be dirty from ANOTHER save whose commit is still queued, and scoping the
|
|
475
486
|
// batch to a merely-locked path would sweep those bytes in under this
|
|
476
487
|
// batch's author/summary.
|
|
477
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
488
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
478
489
|
const seedContent = '---\nread:\n - Alice <alice@example.com>\n---\n';
|
|
479
|
-
await fs.writeFile(path.join(root, 'KnowledgeBase/Mine/access.md'), seedContent);
|
|
490
|
+
await fs.writeFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), seedContent);
|
|
480
491
|
const workflow = makeWorkflow();
|
|
481
492
|
const creatorAccess = makeCreatorAccess();
|
|
482
493
|
creatorAccess.planForCreate.mockResolvedValue({
|
|
483
494
|
kind: 'seed-access-md',
|
|
484
|
-
wsRelPath: 'KnowledgeBase/Mine/access.md',
|
|
495
|
+
wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
485
496
|
apply: (current: string) => current, // grant already present → no-op
|
|
486
497
|
});
|
|
487
498
|
const emitted: { paths: string[] }[] = [];
|
|
@@ -491,22 +502,22 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
491
502
|
workflow,
|
|
492
503
|
workspaceId: 'ws-feat',
|
|
493
504
|
branch: 'feat',
|
|
494
|
-
user: USER,
|
|
505
|
+
user: USER, kbDirName: KB,
|
|
495
506
|
creatorAccess,
|
|
496
507
|
fileChanges: { emit: (c: { paths: string[] }) => emitted.push(c) } as never,
|
|
497
508
|
},
|
|
498
509
|
);
|
|
499
|
-
await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
510
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
500
511
|
|
|
501
512
|
// The seed's lock cycle still ran (acquired + released)...
|
|
502
513
|
const locked = (workflow.acquireLock as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[2]);
|
|
503
|
-
expect(locked).toContain('KnowledgeBase/Mine/access.md');
|
|
514
|
+
expect(locked).toContain('knowledge-base/KnowledgeBase/Mine/access.md');
|
|
504
515
|
// ...but the commit is scoped to the caller's path only.
|
|
505
516
|
const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
|
506
|
-
expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
|
|
507
|
-
expect(emitted[0].paths).toEqual(['KnowledgeBase/Mine/doc.md']);
|
|
517
|
+
expect(commitPaths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
|
|
518
|
+
expect(emitted[0].paths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
|
|
508
519
|
// The seeded file is untouched.
|
|
509
|
-
expect(await fs.readFile(path.join(root, 'KnowledgeBase/Mine/access.md'), 'utf-8')).toBe(
|
|
520
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), 'utf-8')).toBe(
|
|
510
521
|
seedContent,
|
|
511
522
|
);
|
|
512
523
|
});
|
|
@@ -523,34 +534,34 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
523
534
|
// resets its retry ladder — the prior save's bytes would publish under
|
|
524
535
|
// the wrong name. The seed lock must release UNTOUCHED (drop the lock
|
|
525
536
|
// row, leave disk and queue exactly as they are).
|
|
526
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
537
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
527
538
|
const priorSave = '---\nread:\n - Alice <alice@example.com>\n---\nprior queued bytes\n';
|
|
528
|
-
await fs.writeFile(path.join(root, 'KnowledgeBase/Mine/access.md'), priorSave);
|
|
539
|
+
await fs.writeFile(path.join(root, 'knowledge-base/KnowledgeBase/Mine/access.md'), priorSave);
|
|
529
540
|
const workflow = makeWorkflow();
|
|
530
541
|
const creatorAccess = makeCreatorAccess();
|
|
531
542
|
creatorAccess.planForCreate.mockResolvedValue({
|
|
532
543
|
kind: 'seed-access-md',
|
|
533
|
-
wsRelPath: 'KnowledgeBase/Mine/access.md',
|
|
544
|
+
wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
534
545
|
apply: (current: string) => current, // grant already present → no-op
|
|
535
546
|
});
|
|
536
547
|
const fsLayer = new LockingFilesystem(
|
|
537
548
|
{ basePath: root, contained: true },
|
|
538
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
549
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
539
550
|
);
|
|
540
|
-
await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
551
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
541
552
|
|
|
542
553
|
// The batch's OWN committed path releases no-commit (clean — discard no-ops)...
|
|
543
554
|
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith(
|
|
544
|
-
'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
|
|
555
|
+
'ws-feat', 'feat', 'knowledge-base/KnowledgeBase/Mine/doc.md', USER,
|
|
545
556
|
);
|
|
546
557
|
// ...but the merely-locked seed path releases UNTOUCHED — never the
|
|
547
558
|
// enqueue, never the discard.
|
|
548
559
|
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith(
|
|
549
|
-
'ws-feat', 'feat', 'KnowledgeBase/Mine/access.md', USER,
|
|
560
|
+
'ws-feat', 'feat', 'knowledge-base/KnowledgeBase/Mine/access.md', USER,
|
|
550
561
|
);
|
|
551
562
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
552
563
|
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
|
|
553
|
-
'ws-feat', 'feat', 'KnowledgeBase/Mine/access.md', USER,
|
|
564
|
+
'ws-feat', 'feat', 'knowledge-base/KnowledgeBase/Mine/access.md', USER,
|
|
554
565
|
);
|
|
555
566
|
});
|
|
556
567
|
|
|
@@ -564,18 +575,18 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
564
575
|
.mockResolvedValue({ acquired: false, lock: { holderUserId: 'bob', holderName: 'Bob' } });
|
|
565
576
|
const fsLayer = new LockingFilesystem(
|
|
566
577
|
{ basePath: root, contained: true },
|
|
567
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER },
|
|
578
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB },
|
|
568
579
|
);
|
|
569
580
|
await expect(
|
|
570
581
|
fsLayer.writeFiles(
|
|
571
582
|
[
|
|
572
|
-
{ path: 'A.md', content: 'a' },
|
|
573
|
-
{ path: 'B.md', content: 'b' },
|
|
583
|
+
{ path: 'knowledge-base/A.md', content: 'a' },
|
|
584
|
+
{ path: 'knowledge-base/B.md', content: 'b' },
|
|
574
585
|
],
|
|
575
586
|
'batch',
|
|
576
587
|
),
|
|
577
588
|
).rejects.toThrow(/locked by Bob/);
|
|
578
|
-
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', 'A.md', USER);
|
|
589
|
+
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', 'knowledge-base/A.md', USER);
|
|
579
590
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
580
591
|
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
|
|
581
592
|
}, 10_000);
|
|
@@ -586,9 +597,9 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
586
597
|
// loop read the pre-image under this very lock, so it can put it back —
|
|
587
598
|
// after which the path is byte-identical to before the batch and must
|
|
588
599
|
// release untouched (a prior save's queued bytes, if any, survive).
|
|
589
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
600
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
590
601
|
const priorBytes = 'prior queued bytes\n';
|
|
591
|
-
const seedPath = 'KnowledgeBase/Mine/access.md';
|
|
602
|
+
const seedPath = 'knowledge-base/KnowledgeBase/Mine/access.md';
|
|
592
603
|
await fs.writeFile(path.join(root, seedPath), priorBytes);
|
|
593
604
|
const workflow = makeWorkflow();
|
|
594
605
|
const creatorAccess = makeCreatorAccess();
|
|
@@ -599,7 +610,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
599
610
|
});
|
|
600
611
|
const fsLayer = new LockingFilesystem(
|
|
601
612
|
{ basePath: root, contained: true },
|
|
602
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
613
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
603
614
|
);
|
|
604
615
|
// Fail the SEED write once (simulating a mid-write death that left
|
|
605
616
|
// partial bytes); every other write — including the restore — passes
|
|
@@ -617,7 +628,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
617
628
|
return original.call(this as LocalFilesystem, p, c, o);
|
|
618
629
|
});
|
|
619
630
|
try {
|
|
620
|
-
await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
631
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
621
632
|
} finally {
|
|
622
633
|
spy.mockRestore();
|
|
623
634
|
}
|
|
@@ -625,7 +636,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
625
636
|
expect(await fs.readFile(path.join(root, seedPath), 'utf-8')).toBe(priorBytes);
|
|
626
637
|
// The failed seed stays OUT of the commit scope and releases untouched.
|
|
627
638
|
const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
|
628
|
-
expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
|
|
639
|
+
expect(commitPaths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
|
|
629
640
|
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
|
|
630
641
|
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
|
|
631
642
|
'ws-feat', 'feat', seedPath, USER,
|
|
@@ -640,9 +651,9 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
640
651
|
// on disk as if they were real; discarding to HEAD would destroy a prior
|
|
641
652
|
// save's queued bytes. The right restore is the pre-image read under the
|
|
642
653
|
// seed's own lock.
|
|
643
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
654
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
644
655
|
const priorBytes = 'prior queued bytes\n';
|
|
645
|
-
const seedPath = 'KnowledgeBase/Mine/access.md';
|
|
656
|
+
const seedPath = 'knowledge-base/KnowledgeBase/Mine/access.md';
|
|
646
657
|
await fs.writeFile(path.join(root, seedPath), priorBytes);
|
|
647
658
|
const workflow = makeWorkflow();
|
|
648
659
|
(workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
@@ -656,10 +667,10 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
656
667
|
});
|
|
657
668
|
const fsLayer = new LockingFilesystem(
|
|
658
669
|
{ basePath: root, contained: true },
|
|
659
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
670
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
660
671
|
);
|
|
661
672
|
await expect(
|
|
662
|
-
fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
|
|
673
|
+
fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
|
|
663
674
|
).rejects.toThrow('commit exploded');
|
|
664
675
|
// The landed grant was rolled back to the pre-image, byte-identical…
|
|
665
676
|
expect(await fs.readFile(path.join(root, seedPath), 'utf-8')).toBe(priorBytes);
|
|
@@ -667,7 +678,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
667
678
|
// batch's to discard), while the caller's own write releases via discard.
|
|
668
679
|
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
|
|
669
680
|
expect(workflow.releaseLockNoCommit).toHaveBeenCalledWith(
|
|
670
|
-
'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
|
|
681
|
+
'ws-feat', 'feat', 'knowledge-base/KnowledgeBase/Mine/doc.md', USER,
|
|
671
682
|
);
|
|
672
683
|
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalledWith(
|
|
673
684
|
'ws-feat', 'feat', seedPath, USER,
|
|
@@ -679,8 +690,8 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
679
690
|
// pre-image restore failed, so the path holds known-partial bytes. The
|
|
680
691
|
// release must reset it to HEAD (releaseLockNoCommit) — never enqueue the
|
|
681
692
|
// corrupt bytes as a commit, never leave them for the next acquirer.
|
|
682
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
683
|
-
const seedPath = 'KnowledgeBase/Mine/access.md';
|
|
693
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
694
|
+
const seedPath = 'knowledge-base/KnowledgeBase/Mine/access.md';
|
|
684
695
|
await fs.writeFile(path.join(root, seedPath), 'prior\n');
|
|
685
696
|
const workflow = makeWorkflow();
|
|
686
697
|
const creatorAccess = makeCreatorAccess();
|
|
@@ -691,7 +702,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
691
702
|
});
|
|
692
703
|
const fsLayer = new LockingFilesystem(
|
|
693
704
|
{ basePath: root, contained: true },
|
|
694
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
705
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
695
706
|
);
|
|
696
707
|
const original = LocalFilesystem.prototype.writeFile;
|
|
697
708
|
const spy = vi
|
|
@@ -701,7 +712,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
701
712
|
return original.call(this as LocalFilesystem, p, c, o);
|
|
702
713
|
});
|
|
703
714
|
try {
|
|
704
|
-
await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
715
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
705
716
|
} finally {
|
|
706
717
|
spy.mockRestore();
|
|
707
718
|
}
|
|
@@ -713,7 +724,7 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
713
724
|
);
|
|
714
725
|
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
715
726
|
const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
|
716
|
-
expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md']);
|
|
727
|
+
expect(commitPaths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md']);
|
|
717
728
|
});
|
|
718
729
|
|
|
719
730
|
it('push-retry unwind: committed paths re-arm via releaseLock, a merely-locked seed stays untouched', async () => {
|
|
@@ -722,8 +733,8 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
722
733
|
// IS the push-retry vehicle), while the seed that never wrote must NOT be
|
|
723
734
|
// swept into the same enqueue — its path may carry a prior save's queued
|
|
724
735
|
// row that a fresh enqueue would re-attribute and reset.
|
|
725
|
-
await fs.mkdir(path.join(root, 'KnowledgeBase/Mine'), { recursive: true });
|
|
726
|
-
const seedPath = 'KnowledgeBase/Mine/access.md';
|
|
736
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase/Mine'), { recursive: true });
|
|
737
|
+
const seedPath = 'knowledge-base/KnowledgeBase/Mine/access.md';
|
|
727
738
|
await fs.writeFile(path.join(root, seedPath), 'grant already present\n');
|
|
728
739
|
const workflow = makeWorkflow();
|
|
729
740
|
(workflow.commitChanges as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
@@ -737,13 +748,13 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
737
748
|
});
|
|
738
749
|
const fsLayer = new LockingFilesystem(
|
|
739
750
|
{ basePath: root, contained: true },
|
|
740
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
751
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
741
752
|
);
|
|
742
753
|
await expect(
|
|
743
|
-
fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
|
|
754
|
+
fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch'),
|
|
744
755
|
).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
745
756
|
expect(workflow.releaseLock).toHaveBeenCalledWith(
|
|
746
|
-
'ws-feat', 'feat', 'KnowledgeBase/Mine/doc.md', USER,
|
|
757
|
+
'ws-feat', 'feat', 'knowledge-base/KnowledgeBase/Mine/doc.md', USER,
|
|
747
758
|
);
|
|
748
759
|
expect(workflow.releaseLockUntouched).toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
|
|
749
760
|
expect(workflow.releaseLock).not.toHaveBeenCalledWith('ws-feat', 'feat', seedPath, USER);
|
|
@@ -755,16 +766,16 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
755
766
|
const creatorAccess = makeCreatorAccess();
|
|
756
767
|
creatorAccess.planForCreate.mockResolvedValue({
|
|
757
768
|
kind: 'seed-access-md',
|
|
758
|
-
wsRelPath: 'KnowledgeBase/Mine/access.md',
|
|
769
|
+
wsRelPath: 'knowledge-base/KnowledgeBase/Mine/access.md',
|
|
759
770
|
apply: () => '---\nread:\n - Alice <alice@example.com>\n---\n',
|
|
760
771
|
});
|
|
761
772
|
const fsLayer = new LockingFilesystem(
|
|
762
773
|
{ basePath: root, contained: true },
|
|
763
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
774
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
764
775
|
);
|
|
765
|
-
await fsLayer.writeFiles([{ path: 'KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
776
|
+
await fsLayer.writeFiles([{ path: 'knowledge-base/KnowledgeBase/Mine/doc.md', content: 'body' }], 'batch');
|
|
766
777
|
const commitPaths = (workflow.commitChanges as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
|
767
|
-
expect(commitPaths).toEqual(['KnowledgeBase/Mine/doc.md', 'KnowledgeBase/Mine/access.md']);
|
|
778
|
+
expect(commitPaths).toEqual(['knowledge-base/KnowledgeBase/Mine/doc.md', 'knowledge-base/KnowledgeBase/Mine/access.md']);
|
|
768
779
|
});
|
|
769
780
|
|
|
770
781
|
it('a planner failure never blocks the write', async () => {
|
|
@@ -773,9 +784,173 @@ describe('LockingFilesystem — creator read grants on creation', () => {
|
|
|
773
784
|
creatorAccess.planForCreate.mockRejectedValue(new Error('planner down'));
|
|
774
785
|
const fsLayer = new LockingFilesystem(
|
|
775
786
|
{ basePath: root, contained: true },
|
|
776
|
-
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, creatorAccess },
|
|
787
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess },
|
|
788
|
+
);
|
|
789
|
+
await fsLayer.writeFile('knowledge-base/KnowledgeBase/new.md', 'x');
|
|
790
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/new.md'), 'utf-8')).toBe('x');
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
describe('LockingFilesystem refuses to create anything outside the repository folder', () => {
|
|
795
|
+
// The bug this pins: the filesystem is rooted at the WORKSPACE dir, one level
|
|
796
|
+
// above the git clone, so a repo-relative path (`KnowledgeBase/…`, the shape
|
|
797
|
+
// every doc and URL shows) is "contained" and its bytes land BESIDE the
|
|
798
|
+
// repository. The release commit then finds nothing dirty and returns null,
|
|
799
|
+
// the tool reports success, and the explorer re-roots on the stray folder.
|
|
800
|
+
// The refusal has to fire before any side effect: no lock, no creator-grant
|
|
801
|
+
// plan, no validator call, no bytes on disk.
|
|
802
|
+
let root: string;
|
|
803
|
+
|
|
804
|
+
beforeEach(async () => {
|
|
805
|
+
root = await mkTmpRoot();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
afterEach(async () => {
|
|
809
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
const STRAY = 'KnowledgeBase/Reviews/PR-12.html';
|
|
813
|
+
const CORRECTED = 'knowledge-base/KnowledgeBase/Reviews/PR-12.html';
|
|
814
|
+
|
|
815
|
+
function makeCreatorAccess() {
|
|
816
|
+
return {
|
|
817
|
+
planForCreate: vi.fn().mockResolvedValue(null),
|
|
818
|
+
grantInExtractedFile: vi.fn().mockResolvedValue(null),
|
|
819
|
+
noteAccessFileWritten: vi.fn(),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function layer(workflow: IWorkflowService) {
|
|
824
|
+
const creatorAccess = makeCreatorAccess();
|
|
825
|
+
const validateWrite = vi.fn();
|
|
826
|
+
const fsLayer = new LockingFilesystem(
|
|
827
|
+
{ basePath: root, contained: true },
|
|
828
|
+
{ workflow, workspaceId: 'ws-feat', branch: 'feat', user: USER, kbDirName: KB, creatorAccess, validateWrite },
|
|
829
|
+
);
|
|
830
|
+
return { fsLayer, creatorAccess, validateWrite };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function expectRefused(
|
|
834
|
+
op: Promise<unknown>,
|
|
835
|
+
workflow: IWorkflowService,
|
|
836
|
+
creatorAccess: ReturnType<typeof makeCreatorAccess>,
|
|
837
|
+
validateWrite: ReturnType<typeof vi.fn>,
|
|
838
|
+
): Promise<void> {
|
|
839
|
+
await expect(op).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
840
|
+
// The corrected path is spelled out, under the clone folder.
|
|
841
|
+
await expect(op).rejects.toThrow('"knowledge-base/KnowledgeBase/Reviews');
|
|
842
|
+
// Refused before any side effect.
|
|
843
|
+
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
844
|
+
expect(workflow.releaseLock).not.toHaveBeenCalled();
|
|
845
|
+
expect(workflow.releaseLockNoCommit).not.toHaveBeenCalled();
|
|
846
|
+
expect(workflow.commitChanges).not.toHaveBeenCalled();
|
|
847
|
+
expect(creatorAccess.planForCreate).not.toHaveBeenCalled();
|
|
848
|
+
expect(validateWrite).not.toHaveBeenCalled();
|
|
849
|
+
await expect(fs.access(path.join(root, 'KnowledgeBase'))).rejects.toBeDefined();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
it('writeFile', async () => {
|
|
853
|
+
const workflow = makeWorkflow();
|
|
854
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
855
|
+
await expectRefused(fsLayer.writeFile(STRAY, '<p>review</p>'), workflow, creatorAccess, validateWrite);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('appendFile', async () => {
|
|
859
|
+
const workflow = makeWorkflow();
|
|
860
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
861
|
+
await expectRefused(fsLayer.appendFile('KnowledgeBase/Reviews/review.log', 'line\n'), workflow, creatorAccess, validateWrite);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('writeFiles refuses the whole batch when one path is outside: nothing written, nothing locked', async () => {
|
|
865
|
+
const workflow = makeWorkflow();
|
|
866
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
867
|
+
await expectRefused(
|
|
868
|
+
fsLayer.writeFiles(
|
|
869
|
+
[
|
|
870
|
+
{ path: 'knowledge-base/KnowledgeBase/ok.md', content: 'fine' },
|
|
871
|
+
{ path: STRAY, content: '<p>review</p>' },
|
|
872
|
+
],
|
|
873
|
+
'batch',
|
|
874
|
+
),
|
|
875
|
+
workflow,
|
|
876
|
+
creatorAccess,
|
|
877
|
+
validateWrite,
|
|
777
878
|
);
|
|
778
|
-
await
|
|
779
|
-
|
|
879
|
+
await expect(fs.access(path.join(root, 'knowledge-base/KnowledgeBase/ok.md'))).rejects.toBeDefined();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('mkdir', async () => {
|
|
883
|
+
const workflow = makeWorkflow();
|
|
884
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
885
|
+
await expectRefused(fsLayer.mkdir('KnowledgeBase/Reviews'), workflow, creatorAccess, validateWrite);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('moveFile refuses a destination outside the repository and leaves the source in place', async () => {
|
|
889
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase'), { recursive: true });
|
|
890
|
+
await fs.writeFile(path.join(root, 'knowledge-base/KnowledgeBase/PR-12.html'), 'x');
|
|
891
|
+
const workflow = makeWorkflow();
|
|
892
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
893
|
+
await expectRefused(
|
|
894
|
+
fsLayer.moveFile('knowledge-base/KnowledgeBase/PR-12.html', STRAY),
|
|
895
|
+
workflow,
|
|
896
|
+
creatorAccess,
|
|
897
|
+
validateWrite,
|
|
898
|
+
);
|
|
899
|
+
expect(await fs.readFile(path.join(root, 'knowledge-base/KnowledgeBase/PR-12.html'), 'utf-8')).toBe('x');
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('copyFile refuses a destination outside the repository', async () => {
|
|
903
|
+
await fs.mkdir(path.join(root, 'knowledge-base/KnowledgeBase'), { recursive: true });
|
|
904
|
+
await fs.writeFile(path.join(root, 'knowledge-base/KnowledgeBase/PR-12.html'), 'x');
|
|
905
|
+
const workflow = makeWorkflow();
|
|
906
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
907
|
+
await expectRefused(
|
|
908
|
+
fsLayer.copyFile('knowledge-base/KnowledgeBase/PR-12.html', STRAY),
|
|
909
|
+
workflow,
|
|
910
|
+
creatorAccess,
|
|
911
|
+
validateWrite,
|
|
912
|
+
);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('a `..` under the prefix is refused before the lock: the bytes would land beside the clone', async () => {
|
|
916
|
+
// Containment is checked against the WORKSPACE dir, so the underlying
|
|
917
|
+
// filesystem would happily resolve `knowledge-base/../stray.md` to a file
|
|
918
|
+
// next to the clone. The prefix alone is not proof of being inside.
|
|
919
|
+
const workflow = makeWorkflow();
|
|
920
|
+
const { fsLayer, creatorAccess, validateWrite } = layer(workflow);
|
|
921
|
+
await expect(fsLayer.writeFile('knowledge-base/../stray.md', 'x')).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
922
|
+
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
923
|
+
expect(creatorAccess.planForCreate).not.toHaveBeenCalled();
|
|
924
|
+
expect(validateWrite).not.toHaveBeenCalled();
|
|
925
|
+
await expect(fs.access(path.join(root, 'stray.md'))).rejects.toBeDefined();
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('the folder is matched as a whole segment: `knowledge-based/x.md` is outside too', async () => {
|
|
929
|
+
const workflow = makeWorkflow();
|
|
930
|
+
const { fsLayer } = layer(workflow);
|
|
931
|
+
await expect(fsLayer.writeFile('knowledge-based/x.md', 'x')).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
932
|
+
expect(workflow.acquireLock).not.toHaveBeenCalled();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
it('deleteFile still removes a file the old behaviour left beside the repository', async () => {
|
|
936
|
+
// Removal is deliberately not gated: an agent that discovers a stray it
|
|
937
|
+
// created before this guard existed must be able to clean it up.
|
|
938
|
+
await fs.mkdir(path.join(root, 'KnowledgeBase/Reviews'), { recursive: true });
|
|
939
|
+
await fs.writeFile(path.join(root, STRAY), 'stray');
|
|
940
|
+
const workflow = makeWorkflow();
|
|
941
|
+
const { fsLayer } = layer(workflow);
|
|
942
|
+
await fsLayer.deleteFile(STRAY);
|
|
943
|
+
await expect(fs.access(path.join(root, STRAY))).rejects.toBeDefined();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('moveFile still rescues a stray INTO the repository', async () => {
|
|
947
|
+
await fs.mkdir(path.join(root, 'KnowledgeBase/Reviews'), { recursive: true });
|
|
948
|
+
await fs.writeFile(path.join(root, STRAY), 'stray');
|
|
949
|
+
await fs.mkdir(path.join(root, 'knowledge-base'), { recursive: true });
|
|
950
|
+
const workflow = makeWorkflow();
|
|
951
|
+
const { fsLayer } = layer(workflow);
|
|
952
|
+
await fsLayer.moveFile(STRAY, CORRECTED);
|
|
953
|
+
expect(await fs.readFile(path.join(root, CORRECTED), 'utf-8')).toBe('stray');
|
|
954
|
+
await expect(fs.access(path.join(root, STRAY))).rejects.toBeDefined();
|
|
780
955
|
});
|
|
781
956
|
});
|