@bevel-software/platform-core-backend 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/__tests__/test-repositories.d.ts +3 -0
  4. package/dist/__tests__/test-repositories.d.ts.map +1 -0
  5. package/dist/__tests__/test-repositories.js +18 -0
  6. package/dist/__tests__/test-repositories.js.map +1 -0
  7. package/dist/core/catalog-revision.js +3 -3
  8. package/dist/core/create-core-server.d.ts.map +1 -1
  9. package/dist/core/create-core-server.js +3 -2
  10. package/dist/core/create-core-server.js.map +1 -1
  11. package/dist/core/create-core-services.d.ts +3 -0
  12. package/dist/core/create-core-services.d.ts.map +1 -1
  13. package/dist/core/create-core-services.js +35 -5
  14. package/dist/core/create-core-services.js.map +1 -1
  15. package/dist/core/lifecycle.d.ts +8 -0
  16. package/dist/core/lifecycle.d.ts.map +1 -1
  17. package/dist/core/lifecycle.js +4 -0
  18. package/dist/core/lifecycle.js.map +1 -1
  19. package/dist/modules/access/access-control.interface.d.ts +20 -0
  20. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.d.ts +32 -1
  22. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.js +135 -17
  24. package/dist/modules/access/access-control.service.js.map +1 -1
  25. package/dist/modules/access/access.routes.d.ts.map +1 -1
  26. package/dist/modules/access/access.routes.js +20 -6
  27. package/dist/modules/access/access.routes.js.map +1 -1
  28. package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
  29. package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
  30. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  31. package/dist/modules/audit/agent-audit.service.js +66 -6
  32. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  33. package/dist/modules/audit/audit.contract.d.ts +52 -8
  34. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  35. package/dist/modules/audit/audit.contract.js +37 -1
  36. package/dist/modules/audit/audit.contract.js.map +1 -1
  37. package/dist/modules/audit/audit.routes.d.ts +1 -1
  38. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  39. package/dist/modules/audit/audit.routes.js +15 -13
  40. package/dist/modules/audit/audit.routes.js.map +1 -1
  41. package/dist/modules/audit/request-audit.d.ts +21 -10
  42. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  43. package/dist/modules/audit/request-audit.js +26 -10
  44. package/dist/modules/audit/request-audit.js.map +1 -1
  45. package/dist/modules/database/core-schema.d.ts +22 -3
  46. package/dist/modules/database/core-schema.d.ts.map +1 -1
  47. package/dist/modules/database/core-schema.js +15 -6
  48. package/dist/modules/database/core-schema.js.map +1 -1
  49. package/dist/modules/diff/diff.service.d.ts.map +1 -1
  50. package/dist/modules/diff/diff.service.js +7 -1
  51. package/dist/modules/diff/diff.service.js.map +1 -1
  52. package/dist/modules/kb-fs/git-dir.d.ts +21 -0
  53. package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
  54. package/dist/modules/kb-fs/git-dir.js +58 -0
  55. package/dist/modules/kb-fs/git-dir.js.map +1 -0
  56. package/dist/modules/kb-fs/index.d.ts +3 -0
  57. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  58. package/dist/modules/kb-fs/index.js +3 -1
  59. package/dist/modules/kb-fs/index.js.map +1 -1
  60. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  61. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  62. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  63. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  64. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  65. package/dist/modules/kb-fs/mutex.js +14 -0
  66. package/dist/modules/kb-fs/mutex.js.map +1 -1
  67. package/dist/modules/kb-fs/ref-files.d.ts +63 -0
  68. package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
  69. package/dist/modules/kb-fs/ref-files.js +249 -0
  70. package/dist/modules/kb-fs/ref-files.js.map +1 -0
  71. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  72. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  73. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  74. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  75. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  76. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  77. package/dist/modules/mcp/mcp.routes.js +67 -0
  78. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  79. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  80. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  81. package/dist/modules/mcp/mcp.service.js +257 -50
  82. package/dist/modules/mcp/mcp.service.js.map +1 -1
  83. package/dist/modules/mcp/request-context.d.ts +25 -0
  84. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  85. package/dist/modules/mcp/request-context.js +11 -0
  86. package/dist/modules/mcp/request-context.js.map +1 -0
  87. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  88. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  89. package/dist/modules/mcp/shared-definitions.js +117 -0
  90. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  91. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  92. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  93. package/dist/modules/mcp/surface-cache.js +104 -0
  94. package/dist/modules/mcp/surface-cache.js.map +1 -0
  95. package/dist/modules/plugins/teams.routes.d.ts +4 -0
  96. package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
  97. package/dist/modules/plugins/teams.routes.js +9 -1
  98. package/dist/modules/plugins/teams.routes.js.map +1 -1
  99. package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
  100. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
  101. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  102. package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
  103. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  104. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  105. package/dist/modules/tool-helpers/tool-context.js +33 -12
  106. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
  108. package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
  109. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  110. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  111. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  112. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  113. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  114. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  115. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  116. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  117. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  118. package/dist/modules/workflow/git/git.service.d.ts +142 -4
  119. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  120. package/dist/modules/workflow/git/git.service.js +645 -128
  121. package/dist/modules/workflow/git/git.service.js.map +1 -1
  122. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  123. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  124. package/dist/modules/workflow/read-on-branch.js +58 -0
  125. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  126. package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
  127. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  128. package/dist/modules/workflow/workflow.routes.js +22 -41
  129. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  130. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  131. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  132. package/dist/modules/workflow/workflow.service.js +163 -16
  133. package/dist/modules/workflow/workflow.service.js.map +1 -1
  134. package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
  135. package/dist/modules/workspace/agent-access.gate.js +2 -2
  136. package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
  137. package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
  138. package/dist/modules/workspace/bounded-matcher.js +261 -0
  139. package/dist/modules/workspace/bounded-matcher.js.map +1 -0
  140. package/dist/modules/workspace/fetch-instant.d.ts +9 -0
  141. package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
  142. package/dist/modules/workspace/fetch-instant.js +13 -0
  143. package/dist/modules/workspace/fetch-instant.js.map +1 -0
  144. package/dist/modules/workspace/index.d.ts +9 -0
  145. package/dist/modules/workspace/index.d.ts.map +1 -1
  146. package/dist/modules/workspace/index.js +10 -0
  147. package/dist/modules/workspace/index.js.map +1 -1
  148. package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
  149. package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
  150. package/dist/modules/workspace/kb-repository.contract.js +19 -0
  151. package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
  152. package/dist/modules/workspace/kb-repository.d.ts +236 -0
  153. package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
  154. package/dist/modules/workspace/kb-repository.js +811 -0
  155. package/dist/modules/workspace/kb-repository.js.map +1 -0
  156. package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
  157. package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
  158. package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
  159. package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
  160. package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
  161. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  162. package/dist/modules/workspace/routine-write-policy.js +0 -5
  163. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  164. package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
  165. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
  166. package/dist/modules/workspace/set-aside-clone.js +139 -0
  167. package/dist/modules/workspace/set-aside-clone.js.map +1 -1
  168. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
  169. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  170. package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
  171. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  172. package/dist/modules/workspace/workspace.service.d.ts +68 -32
  173. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.service.js +240 -161
  175. package/dist/modules/workspace/workspace.service.js.map +1 -1
  176. package/dist/modules/workspace/workspace.tools.d.ts +13 -4
  177. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.tools.js +162 -336
  179. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  180. package/dist/shared/git.contract.d.ts +7 -0
  181. package/dist/shared/git.contract.d.ts.map +1 -1
  182. package/dist/shared/git.contract.js +9 -0
  183. package/dist/shared/git.contract.js.map +1 -1
  184. package/dist/shared/no-link-below.d.ts +10 -0
  185. package/dist/shared/no-link-below.d.ts.map +1 -0
  186. package/dist/shared/no-link-below.js +30 -0
  187. package/dist/shared/no-link-below.js.map +1 -0
  188. package/dist/test-setup.js +4 -0
  189. package/dist/test-setup.js.map +1 -1
  190. package/migrations/0018_agent_events_page.sql +11 -0
  191. package/migrations/meta/0018_snapshot.json +2429 -0
  192. package/migrations/meta/_journal.json +8 -1
  193. package/package.json +3 -3
  194. package/src/__tests__/test-repositories.ts +22 -0
  195. package/src/__tests__/workspace-service.ts +69 -0
  196. package/src/core/__tests__/lifecycle.test.ts +16 -0
  197. package/src/core/catalog-revision.ts +3 -3
  198. package/src/core/create-core-server.ts +3 -2
  199. package/src/core/create-core-services.ts +39 -4
  200. package/src/core/lifecycle.ts +10 -0
  201. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
  202. package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
  203. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
  204. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
  205. package/src/modules/access/access-control.interface.ts +22 -0
  206. package/src/modules/access/access-control.service.ts +138 -15
  207. package/src/modules/access/access.routes.ts +22 -6
  208. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  209. package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
  210. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  211. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  212. package/src/modules/audit/agent-audit.service.ts +66 -6
  213. package/src/modules/audit/audit.contract.ts +62 -10
  214. package/src/modules/audit/audit.routes.ts +14 -12
  215. package/src/modules/audit/request-audit.ts +28 -13
  216. package/src/modules/database/core-schema.ts +15 -6
  217. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
  218. package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
  219. package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
  220. package/src/modules/diff/diff.service.ts +6 -4
  221. package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
  222. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  223. package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
  224. package/src/modules/kb-fs/git-dir.ts +53 -0
  225. package/src/modules/kb-fs/index.ts +3 -0
  226. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  227. package/src/modules/kb-fs/mutex.ts +16 -0
  228. package/src/modules/kb-fs/ref-files.ts +279 -0
  229. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  230. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  231. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  232. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  233. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  234. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  235. package/src/modules/mcp/mcp.routes.ts +68 -0
  236. package/src/modules/mcp/mcp.service.ts +315 -51
  237. package/src/modules/mcp/request-context.ts +34 -0
  238. package/src/modules/mcp/shared-definitions.ts +121 -0
  239. package/src/modules/mcp/surface-cache.ts +154 -0
  240. package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
  241. package/src/modules/plugins/teams.routes.ts +14 -1
  242. package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
  243. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
  244. package/src/modules/tool-auth/internal-token.service.ts +1 -1
  245. package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
  246. package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
  247. package/src/modules/tool-helpers/tool-context.ts +37 -18
  248. package/src/modules/tool-helpers/tool-handler.ts +2 -2
  249. package/src/modules/tool-helpers/tool.contract.ts +10 -2
  250. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  251. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
  252. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  253. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  254. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  255. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  256. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  257. package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
  258. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  259. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  260. package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
  261. package/src/modules/workflow/git/git.service.ts +719 -126
  262. package/src/modules/workflow/read-on-branch.ts +79 -0
  263. package/src/modules/workflow/workflow-hooks.ts +1 -1
  264. package/src/modules/workflow/workflow.routes.ts +21 -39
  265. package/src/modules/workflow/workflow.service.ts +203 -16
  266. package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
  267. package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
  268. package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
  269. package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
  270. package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
  271. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
  272. package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
  273. package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
  274. package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
  275. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
  276. package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
  278. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
  279. package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
  280. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
  281. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
  282. package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
  283. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
  284. package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
  285. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
  286. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
  287. package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
  288. package/src/modules/workspace/agent-access.gate.ts +2 -2
  289. package/src/modules/workspace/bounded-matcher.ts +328 -0
  290. package/src/modules/workspace/fetch-instant.ts +13 -0
  291. package/src/modules/workspace/index.ts +23 -0
  292. package/src/modules/workspace/kb-repository.contract.ts +124 -0
  293. package/src/modules/workspace/kb-repository.ts +844 -0
  294. package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
  295. package/src/modules/workspace/routine-write-policy.ts +0 -15
  296. package/src/modules/workspace/set-aside-clone.ts +140 -0
  297. package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
  298. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
  299. package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
  300. package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
  301. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
  302. package/src/modules/workspace/workspace.service.ts +247 -169
  303. package/src/modules/workspace/workspace.tools.ts +179 -365
  304. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  305. package/src/shared/git.contract.ts +10 -0
  306. package/src/shared/no-link-below.ts +29 -0
  307. package/src/test-setup.ts +5 -0
@@ -5,6 +5,7 @@ import { createAuditRoutes } from '../audit.routes.js';
5
5
  import {
6
6
  AuditPrincipalNotFoundError,
7
7
  InvalidCursorError,
8
+ pagePrincipalId,
8
9
  type AuditPrincipal,
9
10
  type IAgentAuditService,
10
11
  } from '../audit.contract.js';
@@ -31,7 +32,9 @@ const ALICE_KEY: AuditPrincipal = {
31
32
  };
32
33
 
33
34
  /** Who owns what, as the fake service answers `ownerOf`. */
34
- const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id };
35
+ /** A page Alice's views have called tools from; its id names a person by uuid, so it is minted for one. */
36
+ const ALICE_PAGE_ID = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Roadmap.html');
37
+ const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id, [ALICE_PAGE_ID]: ALICE.id };
35
38
 
36
39
  const audit = {
37
40
  listPrincipals: vi.fn(async () => [ALICE_KEY]),
@@ -103,6 +106,24 @@ describe('audit routes — events', () => {
103
106
  expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'key', id: KEY_ID }, { before: '123.e-9', limit: 200 });
104
107
  });
105
108
 
109
+ it("serves a page's events to its viewer, and 404s an id that names no page", async () => {
110
+ const base = await listen(makeApp({ admin: false }));
111
+ const res = await fetch(`${base}/api/audit/principals/page/${ALICE_PAGE_ID}/events`);
112
+ expect(res.status).toBe(200);
113
+ expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'page', id: ALICE_PAGE_ID }, { before: undefined, limit: 50 });
114
+ const notAPage = await fetch(`${base}/api/audit/principals/page/not-a-page/events`);
115
+ expect(notAPage.status).toBe(404);
116
+ expect(await notAPage.json()).toEqual({ error: 'No such agent, key or page' });
117
+ });
118
+
119
+ it('404s a page id whose path carries a line break of any kind', async () => {
120
+ const base = await listen(makeApp({ admin: false }));
121
+ for (const brk of ['\n', '\r', '\u0085', '\u2028', '\u2029']) {
122
+ const id = pagePrincipalId('33333333-4444-4555-8666-777777777777', `Dashboards/Road${brk}map.html`);
123
+ expect((await fetch(`${base}/api/audit/principals/page/${id}/events`)).status).toBe(404);
124
+ }
125
+ });
126
+
106
127
  it('answers a cursor the service does not recognise with 400, not 500', async () => {
107
128
  const base = await listen(makeApp({ admin: false }));
108
129
  audit.listEvents.mockRejectedValueOnce(new InvalidCursorError());
@@ -110,9 +131,23 @@ describe('audit routes — events', () => {
110
131
  expect(res.status).toBe(400);
111
132
  });
112
133
 
113
- it("refuses another person's principal to a member, and serves it to an admin", async () => {
114
- const asAlice = await listen(makeApp({ admin: false }));
115
- expect((await fetch(`${asAlice}/api/audit/principals/agent/${AGENT_ID}/events`)).status).toBe(403);
134
+ it("refuses another person's principal to a member exactly as one that does not exist, and serves it to an admin", async () => {
135
+ const asBob = await listen(makeApp({ admin: false, as: BOB }));
136
+ const missingPage = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Nothing.html');
137
+ const refusals = [];
138
+ const missing = '00000000-0000-4000-8000-000000000000';
139
+ for (const path of [
140
+ `page/${ALICE_PAGE_ID}`,
141
+ `page/${missingPage}`,
142
+ `key/${KEY_ID}`,
143
+ `key/${missing}`,
144
+ `agent/${ALICE_AGENT_ID}`,
145
+ `agent/${missing}`,
146
+ ]) {
147
+ const res = await fetch(`${asBob}/api/audit/principals/${path}/events`);
148
+ refusals.push({ status: res.status, body: await res.json() });
149
+ }
150
+ expect(refusals).toEqual(Array(6).fill({ status: 404, body: { error: 'No such agent, key or page' } }));
116
151
  expect(audit.listEvents).not.toHaveBeenCalled();
117
152
  server.close();
118
153
 
@@ -166,7 +201,10 @@ describe('audit routes — revoke an agent', () => {
166
201
  it('404s when the connection vanished between the ownership check and the revoke', async () => {
167
202
  const base = await listen(makeApp({ admin: false, as: BOB }));
168
203
  audit.revokeConnection.mockRejectedValueOnce(new AuditPrincipalNotFoundError());
169
- expect((await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' })).status).toBe(404);
204
+ const res = await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' });
205
+ expect(res.status).toBe(404);
206
+ // Only an agent revoke throws it, so it names an agent, as the route's other 404s do.
207
+ expect(await res.json()).toEqual({ error: 'No such agent' });
170
208
  });
171
209
  });
172
210
 
@@ -1,4 +1,4 @@
1
- import { and, count, desc, eq, isNotNull, isNull, lt, or, type SQL } from 'drizzle-orm';
1
+ import { and, count, desc, eq, isNotNull, isNull, lt, max, min, or, type SQL } from 'drizzle-orm';
2
2
  import { logger } from '../../shared/logging.js';
3
3
  import type { Database } from '../database/connection.js';
4
4
  import { agentConnections, agentEvents, externalApiKeys, oauthTokens, users } from '../database/schema.js';
@@ -7,6 +7,8 @@ import {
7
7
  AuditPrincipalNotFoundError,
8
8
  InvalidCursorError,
9
9
  isUuid,
10
+ pagePrincipalId,
11
+ parsePagePrincipalId,
10
12
  parseRetentionWindow,
11
13
  type AgentEventInput,
12
14
  type AgentEventPage,
@@ -59,6 +61,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
59
61
  userId: event.userId,
60
62
  keyId: event.principal.kind === 'key' ? event.principal.id : null,
61
63
  connectionId: event.principal.kind === 'agent' ? event.principal.id : null,
64
+ pagePath: event.principal.kind === 'page' ? event.principal.path : null,
62
65
  kind: event.kind,
63
66
  manual: event.manual,
64
67
  name: event.name,
@@ -93,7 +96,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
93
96
 
94
97
  async listPrincipals(scope: AuditScope): Promise<AuditPrincipal[]> {
95
98
  const mine = scope === 'all' ? null : scope.userId;
96
- const [keyRows, connectionRows, countRows] = await Promise.all([
99
+ const [keyRows, connectionRows, countRows, pageRows] = await Promise.all([
97
100
  mine === null ? this.keys.listForDeployment() : this.keysForUser(mine),
98
101
  this.db
99
102
  .select({
@@ -111,6 +114,22 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
111
114
  .from(agentEvents)
112
115
  .where(mine === null ? undefined : eq(agentEvents.userId, mine))
113
116
  .groupBy(agentEvents.keyId, agentEvents.connectionId),
117
+ // A page has no row of its own: it is every event recorded under one
118
+ // person and one path, so its "created" is its first call and its
119
+ // "last used" its latest.
120
+ this.db
121
+ .select({
122
+ userId: agentEvents.userId,
123
+ pagePath: agentEvents.pagePath,
124
+ n: count(),
125
+ first: min(agentEvents.at),
126
+ last: max(agentEvents.at),
127
+ user: { id: users.id, email: users.email, name: users.name },
128
+ })
129
+ .from(agentEvents)
130
+ .innerJoin(users, eq(agentEvents.userId, users.id))
131
+ .where(and(isNotNull(agentEvents.pagePath), mine === null ? undefined : eq(agentEvents.userId, mine)))
132
+ .groupBy(agentEvents.userId, agentEvents.pagePath, users.id, users.email, users.name),
114
133
  ]);
115
134
 
116
135
  const counts = new Map<string, number>();
@@ -144,6 +163,24 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
144
163
  eventCount: counts.get(c.id) ?? 0,
145
164
  user,
146
165
  })),
166
+ ...pageRows.flatMap((r) =>
167
+ r.pagePath
168
+ ? [
169
+ {
170
+ kind: 'page' as const,
171
+ id: pagePrincipalId(r.userId, r.pagePath),
172
+ label: r.pagePath,
173
+ createdAt: toMs(r.first),
174
+ lastUsedAt: r.last ? toMs(r.last) : null,
175
+ // A page cannot be revoked: it acts only while its viewer has it open.
176
+ revokedAt: null,
177
+ revokedBy: null,
178
+ eventCount: Number(r.n),
179
+ user: r.user,
180
+ },
181
+ ]
182
+ : [],
183
+ ),
147
184
  ];
148
185
  // Per account (by email), live before revoked, most recently used first,
149
186
  // newest first among the never-used — the order the page shows without
@@ -175,6 +212,17 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
175
212
  }
176
213
 
177
214
  async ownerOf(principal: AuditPrincipalRef): Promise<string | null> {
215
+ if (principal.kind === 'page') {
216
+ // The person is in the id; the page exists for them once it has called something.
217
+ const page = parsePagePrincipalId(principal.id);
218
+ if (!page) return null;
219
+ const [row] = await this.db
220
+ .select({ id: agentEvents.id })
221
+ .from(agentEvents)
222
+ .where(and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path)))
223
+ .limit(1);
224
+ return row ? page.userId : null;
225
+ }
178
226
  if (principal.kind === 'agent') {
179
227
  const [row] = await this.db
180
228
  .select({ userId: agentConnections.userId })
@@ -192,10 +240,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
192
240
  page: { before?: string; limit: number },
193
241
  ): Promise<AgentEventPage> {
194
242
  const limit = Math.max(1, Math.min(MAX_EVENT_PAGE, Math.floor(page.limit)));
195
- const byPrincipal =
196
- principal.kind === 'key'
197
- ? eq(agentEvents.keyId, principal.id)
198
- : eq(agentEvents.connectionId, principal.id);
243
+ const byPrincipal = principalFilter(principal);
199
244
  // Keyset on (at, id): a page boundary between two events at the same
200
245
  // instant is still a boundary, and a row inserted while the reader pages
201
246
  // never shifts what the next page returns.
@@ -340,6 +385,21 @@ function formatCursor(at: Date, id: string): string {
340
385
  * compare it with anything else) and the instant a whole number of epoch
341
386
  * milliseconds a `timestamp` can hold.
342
387
  */
388
+ /** The events of one principal. A page id that names no page matches nothing. */
389
+ function principalFilter(principal: AuditPrincipalRef): SQL {
390
+ if (principal.kind === 'key') return eq(agentEvents.keyId, principal.id);
391
+ if (principal.kind === 'agent') return eq(agentEvents.connectionId, principal.id);
392
+ const page = parsePagePrincipalId(principal.id);
393
+ if (!page) return isNull(agentEvents.id);
394
+ return and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path))!;
395
+ }
396
+
397
+ /** An aggregate's timestamp, which a driver may hand back as a Date or as the text Postgres sent. */
398
+ function toMs(value: Date | string | null): number {
399
+ if (value === null) return 0;
400
+ return value instanceof Date ? value.getTime() : new Date(value).getTime();
401
+ }
402
+
343
403
  function parseCursor(raw: string): { at: Date; id: string } | null {
344
404
  const dot = raw.indexOf('.');
345
405
  if (dot < 0) return null;
@@ -24,19 +24,71 @@ declare global {
24
24
  /** What was called: the platform's own tool, a manual's tool, or a skill read. */
25
25
  export type AgentEventKind = 'capability' | 'tool' | 'skill';
26
26
 
27
- /** How the call ended. `denied`: the caller's sign-in for the tool was missing, so nothing ran. */
28
- export type AgentEventOutcome = 'ok' | 'error' | 'denied';
27
+ /**
28
+ * How the call ended. `denied`: the caller's sign-in for the tool was
29
+ * missing, so nothing ran. `refused`: a page made a call that needs the
30
+ * viewer's approval (a writing tool, a tool the manual does not list, or a
31
+ * chain) and the person viewing it answered Deny, so nothing ran.
32
+ */
33
+ export type AgentEventOutcome = 'ok' | 'error' | 'denied' | 'refused';
29
34
 
30
35
  /**
31
36
  * Who a call is attributed to: the connection key it was authenticated with,
32
- * or the agent connection behind its OAuth grant. Exactly one.
37
+ * the agent connection behind its OAuth grant, or the knowledge-base page
38
+ * whose script made it as the person viewing it. Exactly one.
39
+ *
40
+ * A page has no row of its own: it is a person and a path, and its `id` is
41
+ * both, encoded by {@link pagePrincipalId} so the Audit log's routes can name
42
+ * it like the other two.
43
+ */
44
+ export type AuditPrincipalRef = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; id: string };
45
+
46
+ /** Who a call is attributed to, as the recorder takes it: a page by its path, the recorder knows the person. */
47
+ export type RecordedPrincipal = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; path: string };
48
+
49
+ /** The longest page path a page principal may carry. A path is a file in the knowledge base, never longer. */
50
+ export const MAX_PAGE_PATH_LENGTH = 1024;
51
+
52
+ /**
53
+ * A page principal's id: the person and the page's path, encoded so it is one
54
+ * URL segment. Opaque to the client, which only ever echoes it back.
33
55
  */
34
- export type AuditPrincipalRef = { kind: 'key'; id: string } | { kind: 'agent'; id: string };
56
+ export function pagePrincipalId(userId: string, path: string): string {
57
+ return Buffer.from(`${userId}\n${path}`, 'utf8').toString('base64url');
58
+ }
59
+
60
+ /** The person and the path a page principal's id names, or null when it names none. */
61
+ export function parsePagePrincipalId(id: string): { userId: string; path: string } | null {
62
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
63
+ const decoded = Buffer.from(id, 'base64url').toString('utf8');
64
+ const cut = decoded.indexOf('\n');
65
+ if (cut < 0) return null;
66
+ const userId = decoded.slice(0, cut);
67
+ const path = decoded.slice(cut + 1);
68
+ if (!isUuid(userId) || !isPagePath(path)) return null;
69
+ return { userId, path };
70
+ }
71
+
72
+ /**
73
+ * Whether `path` can be a page principal: a non-empty knowledge-base path of
74
+ * printable characters, bounded. A page names itself through a request
75
+ * header, so this is the check that keeps a line break or a control character
76
+ * out of the log and its listing — the C1 controls (NEL among them) and the
77
+ * Unicode line and paragraph separators included.
78
+ */
79
+ export function isPagePath(path: string): boolean {
80
+ return (
81
+ path.length > 0 &&
82
+ path.length <= MAX_PAGE_PATH_LENGTH &&
83
+ // eslint-disable-next-line no-control-regex -- the point is to refuse control characters
84
+ !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(path)
85
+ );
86
+ }
35
87
 
36
88
  /** One recorded call, as the recorder takes it. */
37
89
  export interface AgentEventInput {
38
90
  userId: string;
39
- principal: AuditPrincipalRef;
91
+ principal: RecordedPrincipal;
40
92
  kind: AgentEventKind;
41
93
  /** The MCP server / `.tool` manual for a tool, the skill folder for a skill; null for a capability. */
42
94
  manual: string | null;
@@ -54,18 +106,18 @@ export interface IAgentEventRecorder {
54
106
  record(event: AgentEventInput): void;
55
107
  }
56
108
 
57
- /** One row of the Audit log: an agent connection or a connection key, with its owner. */
109
+ /** One row of the Audit log: an agent connection, a connection key or a page, with its owner. */
58
110
  export interface AuditPrincipal {
59
- kind: 'key' | 'agent';
111
+ kind: 'key' | 'agent' | 'page';
60
112
  id: string;
61
- /** The key's label, or the agent's registered client name. */
113
+ /** The key's label, the agent's registered client name, or the page's knowledge-base path. */
62
114
  label: string;
63
115
  /**
64
116
  * For a key: what it was minted as (`key` by hand, `github-link` for a
65
117
  * Claude link). Absent for an agent.
66
118
  */
67
119
  keyKind?: string;
68
- /** When the key was created / the agent connected. Epoch ms. */
120
+ /** When the key was created / the agent connected / the page first called a tool. Epoch ms. */
69
121
  createdAt: number;
70
122
  lastUsedAt: number | null;
71
123
  revokedAt: number | null;
@@ -149,7 +201,7 @@ export interface IAgentConnectionStatus {
149
201
 
150
202
  export class AuditPrincipalNotFoundError extends Error {
151
203
  constructor() {
152
- super('No such agent or key');
204
+ super('No such agent');
153
205
  this.name = 'AuditPrincipalNotFoundError';
154
206
  }
155
207
  }
@@ -7,6 +7,7 @@ import {
7
7
  AuditPrincipalNotFoundError,
8
8
  InvalidCursorError,
9
9
  isUuid,
10
+ parsePagePrincipalId,
10
11
  type AuditPrincipalRef,
11
12
  type IAgentAuditService,
12
13
  } from './audit.contract.js';
@@ -20,7 +21,7 @@ const DEFAULT_EVENT_PAGE = 50;
20
21
  /**
21
22
  * The Audit log's routes, JWT-only (mounted behind the session middleware):
22
23
  *
23
- * GET /api/audit/principals?scope=me|all — agents + keys, with owners and event counts
24
+ * GET /api/audit/principals?scope=me|all — agents, keys and pages, with owners and event counts
24
25
  * GET /api/audit/principals/:kind/:id/events — one principal's events, newest first, cursor-paged
25
26
  * DELETE /api/audit/agents/:id — revoke an agent (owner or admin)
26
27
  * DELETE /api/audit/keys/:id — revoke a key (owner or admin)
@@ -44,14 +45,20 @@ export function createAuditRoutes(
44
45
  const principalOf = (req: express.Request): AuditPrincipalRef | null => {
45
46
  const kind = String(req.params.kind);
46
47
  const id = String(req.params.id);
48
+ if (kind === 'page') return parsePagePrincipalId(id) ? { kind, id } : null;
47
49
  if ((kind !== 'key' && kind !== 'agent') || !isUuid(id)) return null;
48
50
  return { kind, id };
49
51
  };
50
52
 
51
- /** Whether the caller may read or revoke `principal`: its owner, or an admin. Null when there is no such principal. */
52
- const mayAct = async (req: express.Request, principal: AuditPrincipalRef): Promise<boolean | null> => {
53
+ /**
54
+ * Whether the caller may read `principal`'s events: its owner, or an admin.
55
+ * False both when there is no such principal and when it is someone else's,
56
+ * so the refusal does not tell a member which of another person's pages,
57
+ * agents or keys exist.
58
+ */
59
+ const mayRead = async (req: express.Request, principal: AuditPrincipalRef): Promise<boolean> => {
53
60
  const owner = await audit.ownerOf(principal);
54
- if (owner === null) return null;
61
+ if (owner === null) return false;
55
62
  if (owner === req.userId) return true;
56
63
  return isAdmin(req);
57
64
  };
@@ -74,17 +81,12 @@ export function createAuditRoutes(
74
81
  router.get('/audit/principals/:kind/:id/events', async (req, res) => {
75
82
  const principal = principalOf(req);
76
83
  if (!principal) {
77
- res.status(404).json({ error: 'No such agent or key' });
84
+ res.status(404).json({ error: 'No such agent, key or page' });
78
85
  return;
79
86
  }
80
87
  try {
81
- const allowed = await mayAct(req, principal);
82
- if (allowed === null) {
83
- res.status(404).json({ error: 'No such agent or key' });
84
- return;
85
- }
86
- if (!allowed) {
87
- res.status(403).json({ error: 'Not yours' });
88
+ if (!(await mayRead(req, principal))) {
89
+ res.status(404).json({ error: 'No such agent, key or page' });
88
90
  return;
89
91
  }
90
92
  const rawLimit = Number(req.query.limit);
@@ -1,6 +1,6 @@
1
1
  import type { CodeModeUtcpClient } from '@utcp/code-mode';
2
2
  import { logger } from '../../shared/logging.js';
3
- import type { AgentEventOutcome, AuditPrincipalRef, IAgentEventRecorder } from './audit.contract.js';
3
+ import type { AgentEventOutcome, IAgentEventRecorder, RecordedPrincipal } from './audit.contract.js';
4
4
  import {
5
5
  classifyToolCall,
6
6
  skillReadPath,
@@ -13,11 +13,11 @@ const log = logger('audit');
13
13
 
14
14
  /**
15
15
  * The Audit log's view of ONE MCP request: who is calling (the user and the
16
- * key or agent connection the request arrived through), and how to name what
17
- * they call. The MCP proxy creates one per request that has a principal and
18
- * routes every call through it; a request with none (a browser JWT, an
19
- * internal token minted before connections existed) gets no instance and
20
- * records nothing.
16
+ * key, agent connection or page the request arrived through), and how to name
17
+ * what they call. The MCP proxy creates one per request that has a principal
18
+ * and routes every call through it; a request with none (a browser JWT that
19
+ * names no page, an internal token minted before connections existed) gets no
20
+ * instance and records nothing.
21
21
  *
22
22
  * Holds nothing that outlives the request. The skill catalog it may need to
23
23
  * tell a skill read from a file read is fetched at most once, and only when
@@ -29,7 +29,7 @@ export class RequestAudit {
29
29
  constructor(
30
30
  private readonly recorder: IAgentEventRecorder,
31
31
  private readonly userId: string,
32
- private readonly principal: AuditPrincipalRef,
32
+ private readonly principal: RecordedPrincipal,
33
33
  private readonly ctx: {
34
34
  kbManualName: string;
35
35
  /** Read at classification time — the surface (and its catalog names) is built lazily. */
@@ -93,9 +93,13 @@ export class RequestAudit {
93
93
  }
94
94
  }
95
95
 
96
- /** A call refused before it ran, because the caller's sign-in for its manual is missing. */
97
- async denied(utcpName: string, args: Record<string, unknown>): Promise<void> {
98
- this.emit(await this.classify(utcpName, args), 'denied', null);
96
+ /**
97
+ * A call refused before it ran: `denied` when the caller's sign-in for its
98
+ * manual is missing, `refused` when the person viewing a page answered its
99
+ * request to write with Deny.
100
+ */
101
+ async denied(utcpName: string, args: Record<string, unknown>, outcome: 'denied' | 'refused' = 'denied'): Promise<void> {
102
+ this.emit(await this.classify(utcpName, args), outcome, null);
99
103
  }
100
104
 
101
105
  /**
@@ -106,11 +110,22 @@ export class RequestAudit {
106
110
  * The direct MCP path (`callToolStreaming`) is deliberately NOT wrapped —
107
111
  * the proxy's tool handler records those itself, with the result's
108
112
  * `isError` in hand.
113
+ *
114
+ * Installed ONCE, when the client is built, because the client is shared by
115
+ * every request of its person: `current` names the request a call belongs
116
+ * to, at call time, and a call outside any request (or in one attributed to
117
+ * nothing) is recorded nowhere. Rebinding the client per request instead
118
+ * would hand one request's calls to another's audit, and stack a wrapper
119
+ * per request.
109
120
  */
110
- instrumentChainCalls(client: CodeModeUtcpClient): void {
121
+ static instrumentChainCalls(client: CodeModeUtcpClient, current: () => RequestAudit | null | undefined): void {
111
122
  const callTool = client.callTool.bind(client);
112
- client.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
113
- this.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped);
123
+ client.callTool = (toolName: string, toolArgs: Record<string, unknown>) => {
124
+ const audit = current();
125
+ return audit
126
+ ? audit.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped)
127
+ : callTool(toolName, toolArgs);
128
+ };
114
129
  }
115
130
 
116
131
  private skillCatalog(): Promise<readonly SkillFolder[] | null> {
@@ -589,22 +589,30 @@ export const oauthTokens = pgTable('oauth_tokens', {
589
589
  * which agent, when" — not "what was said".
590
590
  *
591
591
  * Exactly one principal per row (the CHECK): the connection key or the agent
592
- * connection the call arrived through. Both cascade, so deleting a key for
593
- * good, revoking-then-erasing a person, or dropping a connection takes its
594
- * events along — the same contract the LLM-usage rows already keep.
592
+ * connection the call arrived through, or the knowledge-base page whose
593
+ * script made the call as the person viewing it (`page_path`). Key and
594
+ * connection cascade, so deleting a key for good, revoking-then-erasing a
595
+ * person, or dropping a connection takes its events along — the same contract
596
+ * the LLM-usage rows already keep; a page's events go with the person.
595
597
  */
596
598
  export const agentEvents = pgTable('agent_events', {
597
599
  id: uuid('id').defaultRandom().primaryKey(),
598
600
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
599
601
  keyId: uuid('key_id').references(() => externalApiKeys.id, { onDelete: 'cascade' }),
600
602
  connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
603
+ /** The knowledge-base path of the HTML page that made the call, for a page's calls; null otherwise. */
604
+ pagePath: text('page_path'),
601
605
  /** `capability` | `tool` | `skill`. */
602
606
  kind: text('kind').notNull(),
603
607
  /** The MCP server / `.tool` manual (catalog name) for a tool, the skill folder for a skill; null for a capability. */
604
608
  manual: text('manual'),
605
609
  /** The tool's bare name, or the skill's name. */
606
610
  name: text('name').notNull(),
607
- /** `ok` | `error` | `denied` (denied: the caller's sign-in for the tool was missing, so nothing ran). */
611
+ /**
612
+ * `ok` | `error` | `denied` | `refused`. Denied: the caller's sign-in for the
613
+ * tool was missing, so nothing ran. Refused: the viewer answered a page's
614
+ * request to write with Deny, so nothing ran.
615
+ */
608
616
  outcome: text('outcome').notNull(),
609
617
  durationMs: integer('duration_ms'),
610
618
  at: timestamp('at').defaultNow().notNull(),
@@ -612,14 +620,15 @@ export const agentEvents = pgTable('agent_events', {
612
620
  // The two read paths: one principal's events, newest first.
613
621
  byKey: index('agent_events_by_key').on(t.keyId, t.at),
614
622
  byConnection: index('agent_events_by_connection').on(t.connectionId, t.at),
623
+ byPage: index('agent_events_by_page').on(t.userId, t.pagePath, t.at),
615
624
  // The retention prune, and per-user counts.
616
625
  byAt: index('agent_events_by_at').on(t.at),
617
626
  byUser: index('agent_events_by_user').on(t.userId),
618
627
  kindCheck: check('agent_events_kind', sql`${t.kind} IN ('capability', 'tool', 'skill')`),
619
- outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied')`),
628
+ outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied', 'refused')`),
620
629
  principalCheck: check(
621
630
  'agent_events_principal',
622
- sql`(${t.keyId} IS NULL) <> (${t.connectionId} IS NULL)`,
631
+ sql`num_nonnulls(${t.keyId}, ${t.connectionId}, ${t.pagePath}) = 1`,
623
632
  ),
624
633
  }));
625
634
 
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
8
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
9
  import { DiffService } from '../diff.service.js';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  /**
13
14
  * Atomic-seed regression suite.
@@ -53,7 +54,7 @@ async function setup(): Promise<Fixture> {
53
54
  const workspaceDir = path.join(workspacesRoot, workspaceId);
54
55
  const repoDir = path.join(workspaceDir, 'knowledge-base');
55
56
  await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
56
- const workspaceService = new WorkspaceService(
57
+ const workspaceService = newWorkspaceService(
57
58
  workspacesRoot,
58
59
  'https://github.com/Bevel-Software/knowledge-base.git',
59
60
  testKbContext(),
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
8
8
  import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
9
  import { DiffService } from '../diff.service.js';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
11
12
 
12
13
  async function mkTmpRoot(prefix: string): Promise<string> {
13
14
  return fs.mkdtemp(path.join(os.tmpdir(), prefix));
@@ -39,7 +40,7 @@ async function setup(): Promise<Fixture> {
39
40
  const repoDir = path.join(workspaceDir, 'knowledge-base');
40
41
  // Inner `.git` dir is the marker `resolveWorkspaceDir` looks for.
41
42
  await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
42
- const workspaceService = new WorkspaceService(
43
+ const workspaceService = newWorkspaceService(
43
44
  workspacesRoot,
44
45
  'https://github.com/Bevel-Software/knowledge-base.git',
45
46
  testKbContext(),
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { NodeFs } from '../../kb-fs/node-fs.js';
8
+ import { WorkspaceMutex } from '../../kb-fs/mutex.js';
9
+ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
10
+ import { testKbContext } from '../../../__tests__/kb-context.js';
11
+ import { DiffService } from '../diff.service.js';
12
+ import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
13
+
14
+ /**
15
+ * The review session names the branch the workspace is on, read from HEAD
16
+ * without spawning git. In a worktree `.git` is a FILE naming the worktree's
17
+ * folder in the repository, where its HEAD lives.
18
+ */
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ async function git(cwd: string, args: string[]): Promise<void> {
23
+ await execFileAsync('git', args, {
24
+ cwd,
25
+ env: {
26
+ ...process.env,
27
+ GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
28
+ GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
29
+ },
30
+ });
31
+ }
32
+
33
+ let root: string;
34
+
35
+ beforeEach(async () => {
36
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-diff-worktree-'));
37
+ });
38
+ afterEach(async () => {
39
+ await fs.rm(root, { recursive: true, force: true });
40
+ });
41
+
42
+ describe('DiffService on a worktree', () => {
43
+ it("names the worktree's own branch in the review session", async () => {
44
+ const upstream = path.join(root, 'upstream.git');
45
+ await git(root, ['init', '--bare', '-b', 'main', upstream]);
46
+ const seed = path.join(root, '.seed');
47
+ await fs.mkdir(seed);
48
+ await git(seed, ['init', '-b', 'alice/draft']);
49
+ await fs.writeFile(path.join(seed, 'page.md'), 'before\n');
50
+ await git(seed, ['add', '-A']);
51
+ await git(seed, ['commit', '-m', 'init']);
52
+ await git(seed, ['push', '-q', upstream, 'alice/draft']);
53
+
54
+ const workspacesRoot = path.join(root, 'workspaces');
55
+ const workspaces = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
56
+ const diff = new DiffService(
57
+ workspaces,
58
+ new WorkspaceMutex(),
59
+ workspacesRoot,
60
+ path.join(root, 'backups'),
61
+ 'knowledge-base',
62
+ new NodeFs(),
63
+ );
64
+ workspaces.setDiffService(diff);
65
+ const workspaceId = workspaceIdForBranch('alice/draft');
66
+ const info = await workspaces.getOrCreateForBranch('alice/draft');
67
+ expect((await fs.stat(path.join(info.absolutePath, 'knowledge-base', '.git'))).isFile()).toBe(true);
68
+
69
+ // The first look seeds the ledger; a change made after it is pending.
70
+ expect(await diff.currentSession(workspaceId)).toBeNull();
71
+ await fs.writeFile(path.join(info.absolutePath, 'knowledge-base', 'page.md'), 'after\n');
72
+
73
+ const session = await diff.currentSession(workspaceId);
74
+ expect(session?.branchName).toBe('alice/draft');
75
+ });
76
+ });
@@ -4,6 +4,7 @@ import type { FileDiffPayload, PendingChange, ReviewSession, ChangeKind } from '
4
4
  import type { IDiffService } from './diff.interface.js';
5
5
  import type { WorkspaceService } from '../workspace/workspace.service.js';
6
6
  import type { WorkspaceMutex } from '../kb-fs/mutex.js';
7
+ import { gitDirOf } from '../kb-fs/index.js';
7
8
  import { isAbsence, type ITreeWalker, type TreeWalkOptions } from '../../shared/fs.contract.js';
8
9
  import { isDiffable } from './diff.config.js';
9
10
  import { assertWithinDirectory } from '../../shared/path-containment.js';
@@ -385,10 +386,11 @@ export class DiffService implements IDiffService {
385
386
  private async tryReadCurrentBranch(workspaceId: string): Promise<string> {
386
387
  const workspaceDir = await this.workspaceService.getWorkspacePath(workspaceId);
387
388
  try {
388
- const head = await fs.readFile(
389
- path.join(workspaceDir, this.kbDirName, '.git', 'HEAD'),
390
- 'utf-8',
391
- );
389
+ // A worktree's `.git` is a FILE naming its folder in the repository,
390
+ // where its HEAD lives; a full clone's is the folder itself.
391
+ const gitDir = await gitDirOf(path.join(workspaceDir, this.kbDirName));
392
+ if (gitDir === null) return '';
393
+ const head = await fs.readFile(path.join(gitDir, 'HEAD'), 'utf-8');
392
394
  const m = head.match(/^ref:\s+refs\/heads\/(.+?)\s*$/);
393
395
  return m ? m[1] : '';
394
396
  } catch {