@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.
Files changed (177) hide show
  1. package/THIRD-PARTY-NOTICES.md +9 -7
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/clone-config.d.ts +40 -2
  37. package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/clone-config.js +94 -2
  39. package/dist/modules/kb-fs/clone-config.js.map +1 -1
  40. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  41. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  42. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  43. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  44. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  45. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  46. package/dist/modules/kb-fs/repo-path.js +54 -0
  47. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  48. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  50. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  52. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  53. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  54. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  55. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  56. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  57. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  58. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  59. package/dist/modules/tool-helpers/tool-context.js +1 -0
  60. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  62. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  63. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  64. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  66. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  68. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  70. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  71. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  72. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  73. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  74. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  75. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  76. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  77. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  78. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  79. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  80. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  81. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  82. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  83. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  84. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  85. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/git.service.js +70 -5
  87. package/dist/modules/workflow/git/git.service.js.map +1 -1
  88. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  89. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  90. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  91. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  92. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  93. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  94. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  95. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  96. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  97. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  98. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  99. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.routes.js +12 -0
  101. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  102. package/dist/modules/workflow/workflow.service.d.ts +39 -0
  103. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  104. package/dist/modules/workflow/workflow.service.js +116 -6
  105. package/dist/modules/workflow/workflow.service.js.map +1 -1
  106. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
  107. package/dist/modules/workspace/startup/kb-git.js +21 -4
  108. package/dist/modules/workspace/startup/kb-git.js.map +1 -1
  109. package/dist/modules/workspace/workspace.service.d.ts +52 -8
  110. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  111. package/dist/modules/workspace/workspace.service.js +121 -23
  112. package/dist/modules/workspace/workspace.service.js.map +1 -1
  113. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  114. package/dist/modules/workspace/workspace.tools.js +31 -15
  115. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  116. package/kb-template/AGENTS.md +52 -8
  117. package/package.json +6 -5
  118. package/src/core/create-core-server.ts +13 -1
  119. package/src/core/create-core-services.ts +1 -0
  120. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  121. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  122. package/src/modules/access/access-control.service.ts +198 -37
  123. package/src/modules/access/admin-locked-commit.ts +1 -0
  124. package/src/modules/access/synced-groups-committer.ts +1 -1
  125. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  126. package/src/modules/access-model/access-errors.ts +19 -0
  127. package/src/modules/access-model/access-grammar.ts +67 -3
  128. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  129. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  130. package/src/modules/declared-variables/index.ts +1 -0
  131. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  132. package/src/modules/diff/diff.routes.ts +3 -2
  133. package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
  134. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  135. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  136. package/src/modules/kb-fs/clone-config.ts +97 -2
  137. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  138. package/src/modules/kb-fs/repo-path.ts +56 -0
  139. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  140. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  141. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  142. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  143. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  144. package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
  145. package/src/modules/tool-helpers/tool-context.ts +1 -0
  146. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  147. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  148. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  149. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  150. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  151. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  152. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  153. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  154. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  155. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  156. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  157. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  158. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
  159. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
  160. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  161. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  162. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  163. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  164. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  165. package/src/modules/workflow/git/git.service.ts +73 -6
  166. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  167. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  168. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  169. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  170. package/src/modules/workflow/workflow.routes.ts +12 -0
  171. package/src/modules/workflow/workflow.service.ts +123 -7
  172. package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
  173. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  174. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
  175. package/src/modules/workspace/startup/kb-git.ts +20 -7
  176. package/src/modules/workspace/workspace.service.ts +132 -25
  177. 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
- // Path traversal — Mastra's `contained: true` rejects this with a
189
- // PermissionError. The lock must still go away so the next caller
190
- // can edit a different file, but releaseLock (which would commit +
191
- // push whatever partial state is on disk) must NOT fire — instead
192
- // we use the no-commit variant so partial writes don't silently
193
- // persist as committed changes.
194
- await expect(fsLayer.writeFile('../escape.md', 'x')).rejects.toThrow();
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 fsLayer.writeFile('KnowledgeBase/new.md', 'x');
779
- expect(await fs.readFile(path.join(root, 'KnowledgeBase/new.md'), 'utf-8')).toBe('x');
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
  });