@cuongtran001/kanna 1.2.0 → 1.3.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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { ToolRequest } from "../shared/permission-policy"
3
+ import type { ToolRequestEvent } from "./events"
4
+ import {
5
+ applyToolRequestEvent,
6
+ deleteToolRequestsForChat,
7
+ getToolRequest,
8
+ listPendingToolRequests,
9
+ scanAllToolRequests,
10
+ } from "./event-store-tool-requests"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const TS = 1_700_000_000_000
17
+
18
+ function makeRequest(overrides: Partial<ToolRequest> = {}): ToolRequest {
19
+ return {
20
+ id: "req-1",
21
+ chatId: "chat-1",
22
+ sessionId: "session-1",
23
+ toolUseId: "tool-use-1",
24
+ toolName: "Bash",
25
+ arguments: { command: "ls" },
26
+ canonicalArgsHash: "abc123",
27
+ policyVerdict: "ask",
28
+ status: "pending",
29
+ createdAt: TS,
30
+ expiresAt: TS + 600_000,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function putEvent(req: ToolRequest): Extract<ToolRequestEvent, { type: "tool_request_put" }> {
36
+ return { v: 3, type: "tool_request_put", timestamp: TS, request: req }
37
+ }
38
+
39
+ function resolvedEvent(
40
+ id: string,
41
+ overrides: Partial<Extract<ToolRequestEvent, { type: "tool_request_resolved" }>> = {},
42
+ ): Extract<ToolRequestEvent, { type: "tool_request_resolved" }> {
43
+ return {
44
+ v: 3,
45
+ type: "tool_request_resolved",
46
+ timestamp: TS + 1000,
47
+ id,
48
+ status: "answered",
49
+ resolvedAt: TS + 1000,
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // applyToolRequestEvent — tool_request_put
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("applyToolRequestEvent / tool_request_put", () => {
59
+ test("inserts a defensive copy into the map", () => {
60
+ const map = new Map<string, ToolRequest>()
61
+ const req = makeRequest()
62
+ applyToolRequestEvent(map, putEvent(req))
63
+ expect(map.size).toBe(1)
64
+ expect(map.get("req-1")).toEqual(req)
65
+ // defensive copy — mutation of the original must not affect the stored value
66
+ req.status = "canceled"
67
+ expect(map.get("req-1")?.status).toBe("pending")
68
+ })
69
+
70
+ test("overwrites an existing entry for the same id", () => {
71
+ const map = new Map<string, ToolRequest>()
72
+ const first = makeRequest({ id: "req-1", toolName: "Read" })
73
+ const second = makeRequest({ id: "req-1", toolName: "Bash" })
74
+ applyToolRequestEvent(map, putEvent(first))
75
+ applyToolRequestEvent(map, putEvent(second))
76
+ expect(map.get("req-1")?.toolName).toBe("Bash")
77
+ expect(map.size).toBe(1)
78
+ })
79
+ })
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // applyToolRequestEvent — tool_request_resolved
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("applyToolRequestEvent / tool_request_resolved", () => {
86
+ test("updates status, decision, resolvedAt, and mismatchReason", () => {
87
+ const map = new Map<string, ToolRequest>()
88
+ applyToolRequestEvent(map, putEvent(makeRequest()))
89
+ applyToolRequestEvent(map, resolvedEvent("req-1", {
90
+ status: "answered",
91
+ decision: { kind: "allow" },
92
+ resolvedAt: TS + 5000,
93
+ mismatchReason: undefined,
94
+ }))
95
+ const stored = map.get("req-1")
96
+ expect(stored?.status).toBe("answered")
97
+ expect(stored?.decision).toEqual({ kind: "allow" })
98
+ expect(stored?.resolvedAt).toBe(TS + 5000)
99
+ })
100
+
101
+ test("is a no-op when id is unknown", () => {
102
+ const map = new Map<string, ToolRequest>()
103
+ applyToolRequestEvent(map, resolvedEvent("nonexistent"))
104
+ expect(map.size).toBe(0)
105
+ })
106
+
107
+ test("preserves existing decision when event omits it", () => {
108
+ const map = new Map<string, ToolRequest>()
109
+ const req = makeRequest({ decision: { kind: "deny", reason: "blocked" } })
110
+ applyToolRequestEvent(map, putEvent(req))
111
+ applyToolRequestEvent(map, resolvedEvent("req-1", { status: "timeout", decision: undefined }))
112
+ expect(map.get("req-1")?.decision).toEqual({ kind: "deny", reason: "blocked" })
113
+ })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // getToolRequest
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("getToolRequest", () => {
121
+ test("returns a defensive copy when found", () => {
122
+ const map = new Map<string, ToolRequest>()
123
+ const req = makeRequest()
124
+ map.set("req-1", req)
125
+ const result = getToolRequest(map, "req-1")
126
+ expect(result).toEqual(req)
127
+ // defensive copy
128
+ result!.status = "canceled"
129
+ expect(map.get("req-1")?.status).toBe("pending")
130
+ })
131
+
132
+ test("returns null when not found", () => {
133
+ const map = new Map<string, ToolRequest>()
134
+ expect(getToolRequest(map, "missing")).toBeNull()
135
+ })
136
+ })
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // listPendingToolRequests
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe("listPendingToolRequests", () => {
143
+ test("returns only pending requests for the given chatId", () => {
144
+ const map = new Map<string, ToolRequest>()
145
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "pending" }))
146
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-1", status: "answered" }))
147
+ map.set("r3", makeRequest({ id: "r3", chatId: "chat-2", status: "pending" }))
148
+ const result = listPendingToolRequests(map, "chat-1")
149
+ expect(result).toHaveLength(1)
150
+ expect(result[0].id).toBe("r1")
151
+ })
152
+
153
+ test("returns empty array when no pending requests exist for chat", () => {
154
+ const map = new Map<string, ToolRequest>()
155
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "answered" }))
156
+ expect(listPendingToolRequests(map, "chat-1")).toEqual([])
157
+ })
158
+
159
+ test("returns defensive copies", () => {
160
+ const map = new Map<string, ToolRequest>()
161
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "pending" }))
162
+ const results = listPendingToolRequests(map, "chat-1")
163
+ results[0].status = "canceled"
164
+ expect(map.get("r1")?.status).toBe("pending")
165
+ })
166
+ })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // scanAllToolRequests
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe("scanAllToolRequests", () => {
173
+ test("returns defensive copies of every entry in the map", () => {
174
+ const map = new Map<string, ToolRequest>()
175
+ map.set("r1", makeRequest({ id: "r1" }))
176
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-2", status: "answered" }))
177
+ const all = scanAllToolRequests(map)
178
+ expect(all).toHaveLength(2)
179
+ // defensive copy
180
+ all[0].status = "canceled"
181
+ expect(map.get("r1")?.status).toBe("pending")
182
+ })
183
+
184
+ test("returns empty array for an empty map", () => {
185
+ expect(scanAllToolRequests(new Map())).toEqual([])
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // deleteToolRequestsForChat
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("deleteToolRequestsForChat", () => {
194
+ test("removes all entries belonging to the chat", () => {
195
+ const map = new Map<string, ToolRequest>()
196
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1" }))
197
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-1" }))
198
+ map.set("r3", makeRequest({ id: "r3", chatId: "chat-2" }))
199
+ deleteToolRequestsForChat(map, "chat-1")
200
+ expect(map.size).toBe(1)
201
+ expect(map.has("r3")).toBe(true)
202
+ })
203
+
204
+ test("leaves the map unchanged when no entries belong to the chat", () => {
205
+ const map = new Map<string, ToolRequest>()
206
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-2" }))
207
+ deleteToolRequestsForChat(map, "chat-1")
208
+ expect(map.size).toBe(1)
209
+ })
210
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tool-request read-model and write-path layers extracted from event-store.ts.
3
+ *
4
+ * All read functions are pure folds over the in-memory `toolRequestsById` map.
5
+ * Write functions (putToolRequest, resolveToolRequest) accept a ToolRequestWriteDeps
6
+ * so they can append to the event log without importing disk IO directly.
7
+ */
8
+ import type { ToolRequest, ToolRequestDecision, ToolRequestStatus } from "../shared/permission-policy"
9
+ import type { StoreEvent, ToolRequestEvent } from "./events"
10
+ import {
11
+ buildPutToolRequestEvent,
12
+ buildResolveToolRequestEvent,
13
+ } from "./event-store-write-ops"
14
+
15
+ // ─── Write-path deps ──────────────────────────────────────────────────────
16
+
17
+ export interface ToolRequestWriteDeps {
18
+ readonly toolRequestsById: Map<string, ToolRequest>
19
+ readonly toolRequestsLogPath: string
20
+ append: (filePath: string, event: StoreEvent) => Promise<void>
21
+ }
22
+
23
+ /**
24
+ * Apply a single `ToolRequestEvent` to the in-memory `toolRequestsById` map
25
+ * (mutates in place).
26
+ *
27
+ * Mirrors the two `case "tool_request_*"` branches that previously lived
28
+ * inside `EventStore.applyEvent`.
29
+ */
30
+ export function applyToolRequestEvent(
31
+ toolRequestsById: Map<string, ToolRequest>,
32
+ event: ToolRequestEvent,
33
+ ): void {
34
+ switch (event.type) {
35
+ case "tool_request_put": {
36
+ toolRequestsById.set(event.request.id, { ...event.request })
37
+ break
38
+ }
39
+ case "tool_request_resolved": {
40
+ const existing = toolRequestsById.get(event.id)
41
+ if (!existing) break
42
+ toolRequestsById.set(event.id, {
43
+ ...existing,
44
+ status: event.status,
45
+ decision: event.decision ?? existing.decision,
46
+ resolvedAt: event.resolvedAt,
47
+ mismatchReason: event.mismatchReason,
48
+ })
49
+ break
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Return a defensive copy of the tool request with the given id, or `null`
56
+ * if not found.
57
+ */
58
+ export function getToolRequest(
59
+ toolRequestsById: Map<string, ToolRequest>,
60
+ id: string,
61
+ ): ToolRequest | null {
62
+ const req = toolRequestsById.get(id)
63
+ return req ? { ...req } : null
64
+ }
65
+
66
+ /**
67
+ * Return defensive copies of all pending tool requests for the given chat.
68
+ */
69
+ export function listPendingToolRequests(
70
+ toolRequestsById: Map<string, ToolRequest>,
71
+ chatId: string,
72
+ ): ToolRequest[] {
73
+ const out: ToolRequest[] = []
74
+ for (const req of toolRequestsById.values()) {
75
+ if (req.chatId !== chatId) continue
76
+ if (req.status !== "pending") continue
77
+ out.push({ ...req })
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Return defensive copies of every tool request in the map.
84
+ */
85
+ export function scanAllToolRequests(
86
+ toolRequestsById: Map<string, ToolRequest>,
87
+ ): ToolRequest[] {
88
+ return [...toolRequestsById.values()].map((req) => ({ ...req }))
89
+ }
90
+
91
+ /**
92
+ * Remove every tool request belonging to the given chat from the map
93
+ * (mutates in place). Called when a chat is deleted.
94
+ */
95
+ export function deleteToolRequestsForChat(
96
+ toolRequestsById: Map<string, ToolRequest>,
97
+ chatId: string,
98
+ ): void {
99
+ for (const [id, req] of toolRequestsById) {
100
+ if (req.chatId === chatId) {
101
+ toolRequestsById.delete(id)
102
+ }
103
+ }
104
+ }
105
+
106
+ // ─── Write-path functions ──────────────────────────────────────────────────
107
+
108
+ /** Persist + apply a new tool request. */
109
+ export async function putToolRequest(
110
+ deps: ToolRequestWriteDeps,
111
+ req: ToolRequest,
112
+ ): Promise<void> {
113
+ const event = buildPutToolRequestEvent(req)
114
+ applyToolRequestEvent(deps.toolRequestsById, event)
115
+ await deps.append(deps.toolRequestsLogPath, event)
116
+ }
117
+
118
+ /** Persist + apply a tool request resolution. */
119
+ export async function resolveToolRequest(
120
+ deps: ToolRequestWriteDeps,
121
+ id: string,
122
+ args: { status: ToolRequestStatus; decision?: ToolRequestDecision; resolvedAt: number; mismatchReason?: string },
123
+ ): Promise<void> {
124
+ const event = buildResolveToolRequestEvent(deps.toolRequestsById, id, args)
125
+ applyToolRequestEvent(deps.toolRequestsById, event)
126
+ await deps.append(deps.toolRequestsLogPath, event)
127
+ }
128
+
129
+ // Re-export types consumed by event-store.ts method signatures so callers
130
+ // can import them from one place.
131
+ export type { ToolRequest, ToolRequestDecision, ToolRequestStatus }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Complex chat-transcript write operations extracted from event-store.ts.
3
+ *
4
+ * Contains methods that directly manipulate the transcript JSONL file or
5
+ * perform multi-step chat mutations (fork, delete, prune, appendMessage).
6
+ * All IO is performed through the injected StorageBackend.
7
+ *
8
+ * This module is an adapter (.adapter.ts) because it performs disk IO.
9
+ * It must NOT import from event-store.ts (no circular deps).
10
+ */
11
+ import path from "node:path"
12
+ import { LOG_PREFIX } from "../shared/branding"
13
+ import { log } from "../shared/log"
14
+ import { STORE_VERSION } from "../shared/types"
15
+ import type { TranscriptEntry } from "../shared/types"
16
+ import type { ToolRequest } from "../shared/permission-policy"
17
+ import type { ChatEvent, ChatRecord, StoreEvent } from "./events"
18
+ import { cloneTranscriptEntries } from "./events"
19
+ import type { StorageBackend } from "./storage/backend"
20
+ import { getForkedChatTitle, logSendToStartingProfile } from "./event-store-helpers"
21
+ import {
22
+ buildArchiveChatEvent,
23
+ buildChatProviderEvent,
24
+ buildPendingForkSessionTokenEvent,
25
+ buildPlanModeEvent,
26
+ buildUnarchiveChatEvent,
27
+ } from "./event-store-write-ops"
28
+ import { deleteToolRequestsForChat } from "./event-store-tool-requests"
29
+ import { applyChatMessageMetadata } from "./event-store-chat-lifecycle"
30
+ import type { CachedTranscriptRef } from "./event-store-messages.adapter"
31
+
32
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
33
+
34
+ // ─── Deps interface ────────────────────────────────────────────────────────
35
+
36
+ export interface ChatTranscriptWriteDeps {
37
+ readonly storage: StorageBackend
38
+ readonly transcriptsDir: string
39
+ readonly dataDir: string
40
+ readonly cachedTranscriptRef: CachedTranscriptRef
41
+ readonly seenMessageIdsByChatId: Map<string, Set<string>>
42
+ readonly chatsById: Map<string, ChatRecord>
43
+ readonly toolRequestsById: Map<string, ToolRequest>
44
+ readonly chatsLogPath: string
45
+ readonly turnsLogPath: string
46
+ /** Read the current write-chain promise. */
47
+ getWriteChain: () => Promise<void>
48
+ /** Replace the write-chain promise. */
49
+ setWriteChain: (p: Promise<void>) => void
50
+ /** Core append: writes event to disk + applies to in-memory state. */
51
+ append: <T extends StoreEvent>(filePath: string, event: T) => Promise<void>
52
+ /** Returns transcript entries for a chat (from cache or disk). */
53
+ getMessages: (chatId: string) => TranscriptEntry[]
54
+ /** Returns (or lazily creates) the seen-messageId dedup set for a chat. */
55
+ getSeenMessageIds: (chatId: string) => Set<string>
56
+ /** Returns pending tool requests for a chat. */
57
+ listPendingToolRequests: (chatId: string) => ToolRequest[]
58
+ }
59
+
60
+ // ─── Private helpers ───────────────────────────────────────────────────────
61
+
62
+ function transcriptPath(deps: ChatTranscriptWriteDeps, chatId: string): string {
63
+ return path.join(deps.transcriptsDir, `${chatId}.jsonl`)
64
+ }
65
+
66
+ function requireChat(deps: ChatTranscriptWriteDeps, chatId: string): ChatRecord {
67
+ const chat = deps.chatsById.get(chatId)
68
+ if (!chat || chat.deletedAt) throw new Error("Chat not found")
69
+ return chat
70
+ }
71
+
72
+ // ─── Exported functions ────────────────────────────────────────────────────
73
+
74
+ /** Removes the subagent-results directory for a deleted chat (best-effort). */
75
+ export async function removeSubagentResultsDir(
76
+ deps: ChatTranscriptWriteDeps,
77
+ projectId: string,
78
+ chatId: string,
79
+ ): Promise<void> {
80
+ const dir = path.join(
81
+ deps.dataDir, "projects", projectId, "chats", chatId, "subagent-results",
82
+ )
83
+ try {
84
+ await deps.storage.remove(dir, { recursive: true })
85
+ } catch (err) {
86
+ log.warn(`${LOG_PREFIX} subagent-results cleanup failed`, { chatId, err })
87
+ }
88
+ }
89
+
90
+ /** Forks a chat: creates a new chat sharing the source transcript and session. */
91
+ export async function forkChat(
92
+ deps: ChatTranscriptWriteDeps,
93
+ sourceChatId: string,
94
+ ): Promise<ChatRecord> {
95
+ const sourceChat = requireChat(deps, sourceChatId)
96
+ const sourceProvider = sourceChat.provider
97
+ if (!sourceProvider) throw new Error("Chat cannot be forked")
98
+
99
+ const sourceSessionToken =
100
+ sourceChat.sessionTokensByProvider[sourceProvider]
101
+ ?? (sourceChat.pendingForkSessionToken?.provider === sourceProvider
102
+ ? sourceChat.pendingForkSessionToken.token
103
+ : null)
104
+ if (!sourceSessionToken) throw new Error("Chat cannot be forked")
105
+
106
+ const chatId = crypto.randomUUID()
107
+ const createdAt = Date.now()
108
+ const createEvent: ChatEvent = {
109
+ v: STORE_VERSION,
110
+ type: "chat_created",
111
+ timestamp: createdAt,
112
+ chatId,
113
+ projectId: sourceChat.projectId,
114
+ title: getForkedChatTitle(sourceChat.title),
115
+ ...(sourceChat.stackId !== undefined ? { stackId: sourceChat.stackId } : {}),
116
+ ...(sourceChat.stackBindings !== undefined
117
+ ? { stackBindings: sourceChat.stackBindings.map((b) => ({ ...b })) }
118
+ : {}),
119
+ }
120
+ await deps.append(deps.chatsLogPath, createEvent)
121
+
122
+ const providerEv = buildChatProviderEvent(deps.chatsById, chatId, sourceProvider)
123
+ if (providerEv) await deps.append(deps.chatsLogPath, providerEv)
124
+
125
+ const planEv = buildPlanModeEvent(deps.chatsById, chatId, sourceChat.planMode)
126
+ if (planEv) await deps.append(deps.chatsLogPath, planEv)
127
+
128
+ const forkTokenEv = buildPendingForkSessionTokenEvent(
129
+ deps.chatsById, chatId, { provider: sourceProvider, token: sourceSessionToken },
130
+ )
131
+ if (forkTokenEv) await deps.append(deps.turnsLogPath, forkTokenEv)
132
+
133
+ const sourceEntries = deps.getMessages(sourceChatId)
134
+ if (sourceEntries.length > 0) {
135
+ const tPath = transcriptPath(deps, chatId)
136
+ const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
137
+ const newChain = deps.getWriteChain().then(async () => {
138
+ await deps.storage.mkdir(deps.transcriptsDir)
139
+ await deps.storage.writeText(tPath, `${payload}\n`)
140
+ const chat = deps.chatsById.get(chatId)
141
+ if (chat) {
142
+ chat.hasMessages = true
143
+ chat.updatedAt = Math.max(chat.updatedAt, createdAt)
144
+ }
145
+ if (deps.cachedTranscriptRef.value?.chatId === chatId) {
146
+ deps.cachedTranscriptRef.value = { chatId, entries: cloneTranscriptEntries(sourceEntries) }
147
+ }
148
+ })
149
+ deps.setWriteChain(newChain)
150
+ await newChain
151
+ }
152
+
153
+ return deps.chatsById.get(chatId)!
154
+ }
155
+
156
+ /** Deletes a chat: appends a delete event, clears tool requests, and removes the results dir. */
157
+ export async function deleteChat(
158
+ deps: ChatTranscriptWriteDeps,
159
+ chatId: string,
160
+ ): Promise<void> {
161
+ const chat = requireChat(deps, chatId)
162
+ const projectId = chat.projectId
163
+ const event: ChatEvent = {
164
+ v: STORE_VERSION,
165
+ type: "chat_deleted",
166
+ timestamp: Date.now(),
167
+ chatId,
168
+ }
169
+ await deps.append(deps.chatsLogPath, event)
170
+ deleteToolRequestsForChat(deps.toolRequestsById, chatId)
171
+ await removeSubagentResultsDir(deps, projectId, chatId)
172
+ }
173
+
174
+ /** Archives a chat. */
175
+ export async function archiveChat(
176
+ deps: ChatTranscriptWriteDeps,
177
+ chatId: string,
178
+ ): Promise<void> {
179
+ await deps.append(deps.chatsLogPath, buildArchiveChatEvent(deps.chatsById, chatId))
180
+ }
181
+
182
+ /** Unarchives a chat. */
183
+ export async function unarchiveChat(
184
+ deps: ChatTranscriptWriteDeps,
185
+ chatId: string,
186
+ ): Promise<void> {
187
+ await deps.append(deps.chatsLogPath, buildUnarchiveChatEvent(deps.chatsById, chatId))
188
+ }
189
+
190
+ /** Prunes empty chats that have been idle past the max age. Returns pruned chat IDs. */
191
+ export async function pruneStaleEmptyChats(
192
+ deps: ChatTranscriptWriteDeps,
193
+ args?: {
194
+ now?: number
195
+ maxAgeMs?: number
196
+ activeChatIds?: Iterable<string>
197
+ protectedChatIds?: Iterable<string>
198
+ },
199
+ ): Promise<string[]> {
200
+ const now = args?.now ?? Date.now()
201
+ const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
202
+ const protectedChatIds = new Set([
203
+ ...(args?.activeChatIds ?? []),
204
+ ...(args?.protectedChatIds ?? []),
205
+ ])
206
+ const prunedChatIds: string[] = []
207
+
208
+ for (const chat of deps.chatsById.values()) {
209
+ if (chat.deletedAt || chat.archivedAt || protectedChatIds.has(chat.id)) continue
210
+ if (now - chat.createdAt < maxAgeMs) continue
211
+ if (chat.hasMessages) continue
212
+ if (deps.getMessages(chat.id).length > 0) {
213
+ chat.hasMessages = true
214
+ continue
215
+ }
216
+
217
+ const event: ChatEvent = {
218
+ v: STORE_VERSION,
219
+ type: "chat_deleted",
220
+ timestamp: now,
221
+ chatId: chat.id,
222
+ }
223
+ await deps.append(deps.chatsLogPath, event)
224
+
225
+ const tPath = transcriptPath(deps, chat.id)
226
+ await deps.storage.remove(tPath)
227
+ if (deps.cachedTranscriptRef.value?.chatId === chat.id) {
228
+ deps.cachedTranscriptRef.value = null
229
+ }
230
+ await removeSubagentResultsDir(deps, chat.projectId, chat.id)
231
+
232
+ prunedChatIds.push(chat.id)
233
+ }
234
+
235
+ return prunedChatIds
236
+ }
237
+
238
+ /** Appends a transcript entry for a chat, with deduplication by messageId. */
239
+ export async function appendMessage(
240
+ deps: ChatTranscriptWriteDeps,
241
+ chatId: string,
242
+ entry: TranscriptEntry,
243
+ ): Promise<void> {
244
+ requireChat(deps, chatId)
245
+ const payload = `${JSON.stringify(entry)}\n`
246
+ const tPath = transcriptPath(deps, chatId)
247
+ const queuedAt = performance.now()
248
+ const newChain = deps.getWriteChain().then(async () => {
249
+ const startedAt = performance.now()
250
+ const queueDelayMs = Number((startedAt - queuedAt).toFixed(1))
251
+ const mid = entry.messageId
252
+ if (typeof mid === "string" && mid.length > 0) {
253
+ deps.getMessages(chatId)
254
+ const seen = deps.getSeenMessageIds(chatId)
255
+ if (seen.has(mid)) {
256
+ logSendToStartingProfile("event_store.append_message_dedup", {
257
+ chatId,
258
+ messageId: mid,
259
+ kind: entry.kind,
260
+ })
261
+ return
262
+ }
263
+ seen.add(mid)
264
+ }
265
+ await deps.storage.mkdir(deps.transcriptsDir)
266
+ const beforeAppendAt = performance.now()
267
+ await deps.storage.appendText(tPath, payload)
268
+ const afterAppendAt = performance.now()
269
+ applyChatMessageMetadata(deps.chatsById, chatId, entry)
270
+ if (deps.cachedTranscriptRef.value?.chatId === chatId) {
271
+ deps.cachedTranscriptRef.value.entries.push({ ...entry })
272
+ }
273
+ logSendToStartingProfile("event_store.append_message", {
274
+ chatId,
275
+ entryId: entry._id,
276
+ kind: entry.kind,
277
+ payloadBytes: payload.length,
278
+ queueDelayMs,
279
+ appendMs: Number((afterAppendAt - beforeAppendAt).toFixed(1)),
280
+ totalMs: Number((afterAppendAt - queuedAt).toFixed(1)),
281
+ })
282
+ })
283
+ deps.setWriteChain(newChain)
284
+ return newChain
285
+ }