@cuongtran001/kanna 1.3.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 (186) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.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-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
  29. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  33. package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
  34. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
  35. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  41. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  43. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  44. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  45. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  46. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  47. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  48. package/dist/client/index.html +1 -1
  49. package/package.json +1 -1
  50. package/src/server/agent-coordinator-types.ts +158 -0
  51. package/src/server/agent-coordinator.ts +919 -0
  52. package/src/server/agent-deps-builders.ts +425 -0
  53. package/src/server/agent.test.ts +22 -6
  54. package/src/server/agent.ts +35 -4987
  55. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  56. package/src/server/claude-autocontinue-commands.ts +256 -0
  57. package/src/server/claude-cancel-handler.test.ts +472 -0
  58. package/src/server/claude-cancel-handler.ts +241 -0
  59. package/src/server/claude-chat-management.test.ts +395 -0
  60. package/src/server/claude-chat-management.ts +251 -0
  61. package/src/server/claude-harness-stream.test.ts +273 -0
  62. package/src/server/claude-harness-stream.ts +158 -0
  63. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  64. package/src/server/claude-loop-orch-commands.ts +466 -0
  65. package/src/server/claude-message-normalizer.test.ts +213 -0
  66. package/src/server/claude-message-normalizer.ts +436 -0
  67. package/src/server/claude-prompt-helpers.test.ts +248 -0
  68. package/src/server/claude-prompt-helpers.ts +139 -0
  69. package/src/server/claude-sdk-queue.test.ts +169 -0
  70. package/src/server/claude-sdk-queue.ts +82 -0
  71. package/src/server/claude-send-command.test.ts +414 -0
  72. package/src/server/claude-send-command.ts +420 -0
  73. package/src/server/claude-session-config-helpers.test.ts +279 -0
  74. package/src/server/claude-session-config-helpers.ts +151 -0
  75. package/src/server/claude-session-config.test.ts +250 -0
  76. package/src/server/claude-session-config.ts +159 -0
  77. package/src/server/claude-session-error-handler.test.ts +498 -0
  78. package/src/server/claude-session-error-handler.ts +361 -0
  79. package/src/server/claude-session-lifecycle.test.ts +669 -0
  80. package/src/server/claude-session-lifecycle.ts +283 -0
  81. package/src/server/claude-session-rebuild.test.ts +224 -0
  82. package/src/server/claude-session-rebuild.ts +101 -0
  83. package/src/server/claude-session-runner.test.ts +523 -0
  84. package/src/server/claude-session-runner.ts +521 -0
  85. package/src/server/claude-session-spawner.test.ts +401 -0
  86. package/src/server/claude-session-spawner.ts +401 -0
  87. package/src/server/claude-session-start.test.ts +205 -0
  88. package/src/server/claude-session-start.ts +277 -0
  89. package/src/server/claude-session-state-queries.test.ts +344 -0
  90. package/src/server/claude-session-state-queries.ts +160 -0
  91. package/src/server/claude-session-state.ts +104 -0
  92. package/src/server/claude-slash-commands.test.ts +277 -0
  93. package/src/server/claude-slash-commands.ts +234 -0
  94. package/src/server/claude-spawn-helpers.test.ts +221 -0
  95. package/src/server/claude-spawn-helpers.ts +179 -0
  96. package/src/server/claude-steer-log.test.ts +243 -0
  97. package/src/server/claude-steer-log.ts +58 -0
  98. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  99. package/src/server/claude-subagent-tool-response.ts +165 -0
  100. package/src/server/claude-subagent-wiring.test.ts +263 -0
  101. package/src/server/claude-subagent-wiring.ts +324 -0
  102. package/src/server/claude-tool-respond.test.ts +321 -0
  103. package/src/server/claude-tool-respond.ts +142 -0
  104. package/src/server/claude-turn-runner.test.ts +310 -0
  105. package/src/server/claude-turn-runner.ts +228 -0
  106. package/src/server/claude-turn-starter.test.ts +266 -0
  107. package/src/server/claude-turn-starter.ts +566 -0
  108. package/src/server/claude-usage-math.test.ts +169 -0
  109. package/src/server/claude-usage-math.ts +132 -0
  110. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  111. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  112. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  113. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  114. package/src/server/diff-store-errors.ts +85 -0
  115. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  116. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  117. package/src/server/diff-store-io.adapter.ts +27 -0
  118. package/src/server/diff-store-parse.test.ts +212 -0
  119. package/src/server/diff-store-parse.ts +122 -0
  120. package/src/server/diff-store-state.ts +82 -0
  121. package/src/server/diff-store.ts +76 -1854
  122. package/src/server/event-store-apply.test.ts +280 -0
  123. package/src/server/event-store-apply.ts +132 -0
  124. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  125. package/src/server/event-store-chat-lifecycle.ts +441 -0
  126. package/src/server/event-store-entity-write.ts +356 -0
  127. package/src/server/event-store-helpers.test.ts +230 -0
  128. package/src/server/event-store-helpers.ts +235 -0
  129. package/src/server/event-store-init.ts +276 -0
  130. package/src/server/event-store-messages.adapter.test.ts +365 -0
  131. package/src/server/event-store-messages.adapter.ts +213 -0
  132. package/src/server/event-store-orch-subscription.ts +124 -0
  133. package/src/server/event-store-orch.test.ts +462 -0
  134. package/src/server/event-store-orch.ts +312 -0
  135. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  136. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  137. package/src/server/event-store-snapshot.test.ts +597 -0
  138. package/src/server/event-store-snapshot.ts +658 -0
  139. package/src/server/event-store-subagent.test.ts +408 -0
  140. package/src/server/event-store-subagent.ts +198 -0
  141. package/src/server/event-store-tool-requests.test.ts +210 -0
  142. package/src/server/event-store-tool-requests.ts +131 -0
  143. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  144. package/src/server/event-store-write-ops.test.ts +554 -0
  145. package/src/server/event-store-write-ops.ts +529 -0
  146. package/src/server/event-store.test.ts +35 -0
  147. package/src/server/event-store.ts +251 -2285
  148. package/src/server/harness-types.ts +15 -1
  149. package/src/server/oauth-errors.ts +19 -0
  150. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  151. package/src/server/ws-router-agent-ctrl.ts +137 -0
  152. package/src/server/ws-router-broadcast.ts +488 -0
  153. package/src/server/ws-router-chat.test.ts +461 -0
  154. package/src/server/ws-router-chat.ts +279 -0
  155. package/src/server/ws-router-defaults.test.ts +283 -0
  156. package/src/server/ws-router-defaults.ts +434 -0
  157. package/src/server/ws-router-diff.test.ts +167 -0
  158. package/src/server/ws-router-diff.ts +250 -0
  159. package/src/server/ws-router-envelope.ts +329 -0
  160. package/src/server/ws-router-misc.test.ts +422 -0
  161. package/src/server/ws-router-misc.ts +265 -0
  162. package/src/server/ws-router-orch.test.ts +190 -0
  163. package/src/server/ws-router-orch.ts +93 -0
  164. package/src/server/ws-router-project.test.ts +403 -0
  165. package/src/server/ws-router-project.ts +256 -0
  166. package/src/server/ws-router-push.test.ts +193 -0
  167. package/src/server/ws-router-push.ts +120 -0
  168. package/src/server/ws-router-settings.test.ts +173 -0
  169. package/src/server/ws-router-settings.ts +416 -0
  170. package/src/server/ws-router-skills.test.ts +194 -0
  171. package/src/server/ws-router-skills.ts +204 -0
  172. package/src/server/ws-router-utils.ts +224 -0
  173. package/src/server/ws-router.ts +339 -2254
  174. package/src/shared/app-settings-types.ts +365 -0
  175. package/src/shared/core-types.ts +27 -0
  176. package/src/shared/git-diff-types.ts +165 -0
  177. package/src/shared/mcp-types.ts +105 -0
  178. package/src/shared/provider-model-types.ts +437 -0
  179. package/src/shared/subagent-types.ts +198 -0
  180. package/src/shared/tool-call-types.ts +314 -0
  181. package/src/shared/transcript-types.ts +270 -0
  182. package/src/shared/types.ts +25 -1711
  183. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  184. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  185. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  186. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,597 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { StorageBackend } from "./storage/backend"
3
+ import type { SnapshotFile } from "./events"
4
+ import { createEmptyState } from "./events"
5
+ import { STORE_VERSION } from "../shared/types"
6
+ import {
7
+ buildSnapshotFile,
8
+ calcShouldTruncateLogs,
9
+ computeLegacyTranscriptStats,
10
+ loadAndReplayLogs,
11
+ loadSidebarOrder,
12
+ loadSnapshotIntoState,
13
+ migrateLegacyTranscripts,
14
+ readSidebarOrderFromProjectsLog,
15
+ truncateLogsAfterSnapshot,
16
+ writeSidebarOrderFile,
17
+ type LegacyTranscriptStats,
18
+ type SnapshotLogPaths,
19
+ } from "./event-store-snapshot"
20
+ import type { TranscriptEntry } from "../shared/types"
21
+ import type { StoreEvent } from "./events"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Mock StorageBackend
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function makeStorage(initial: Record<string, string> = {}): StorageBackend & {
28
+ written: Map<string, string>
29
+ appended: Map<string, string[]>
30
+ renamed: Array<[string, string]>
31
+ } {
32
+ const files = new Map<string, string>(Object.entries(initial))
33
+ const written = new Map<string, string>()
34
+ const appended = new Map<string, string[]>()
35
+ const renamed: Array<[string, string]> = []
36
+
37
+ return {
38
+ written,
39
+ appended,
40
+ renamed,
41
+ mkdir: async () => {},
42
+ exists: async (p) => files.has(p),
43
+ existsSync: (p) => files.has(p),
44
+ size: async (p) => {
45
+ const content = files.get(p)
46
+ return content ? content.length : 0
47
+ },
48
+ readText: async (p) => {
49
+ const content = files.get(p)
50
+ if (content === undefined) throw new Error(`File not found: ${p}`)
51
+ return content
52
+ },
53
+ readTextSync: (p) => {
54
+ const content = files.get(p)
55
+ if (content === undefined) throw new Error(`File not found: ${p}`)
56
+ return content
57
+ },
58
+ writeText: async (p, content) => {
59
+ files.set(p, content)
60
+ written.set(p, content)
61
+ },
62
+ appendText: async (p, content) => {
63
+ const existing = files.get(p) ?? ""
64
+ files.set(p, existing + content)
65
+ const list = appended.get(p) ?? []
66
+ list.push(content)
67
+ appended.set(p, list)
68
+ },
69
+ rename: async (from, to) => {
70
+ const content = files.get(from)
71
+ if (content !== undefined) {
72
+ files.set(to, content)
73
+ files.delete(from)
74
+ }
75
+ renamed.push([from, to])
76
+ },
77
+ remove: async (p) => { files.delete(p) },
78
+ }
79
+ }
80
+
81
+ const PATHS: SnapshotLogPaths = {
82
+ snapshotPath: "/data/snapshot.json",
83
+ projectsLogPath: "/data/projects.jsonl",
84
+ chatsLogPath: "/data/chats.jsonl",
85
+ messagesLogPath: "/data/messages.jsonl",
86
+ queuedMessagesLogPath: "/data/queued-messages.jsonl",
87
+ turnsLogPath: "/data/turns.jsonl",
88
+ schedulesLogPath: "/data/schedules.jsonl",
89
+ stacksLogPath: "/data/stacks.jsonl",
90
+ toolRequestsLogPath: "/data/tool-requests.jsonl",
91
+ orchLogPath: "/data/orch.jsonl",
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // loadSnapshotIntoState
96
+ // ---------------------------------------------------------------------------
97
+ describe("loadSnapshotIntoState", () => {
98
+ test("returns empty result when snapshot file does not exist", async () => {
99
+ const storage = makeStorage()
100
+ const state = createEmptyState()
101
+ const legacyMessages = new Map<string, TranscriptEntry[]>()
102
+ let clearCalled = false
103
+ const result = await loadSnapshotIntoState(
104
+ storage, "/data/snapshot.json", state, legacyMessages,
105
+ async () => { clearCalled = true },
106
+ )
107
+ expect(result).toEqual({ snapshotHasLegacyMessages: false, legacySidebarProjectOrder: [] })
108
+ expect(clearCalled).toBe(false)
109
+ expect(state.projectsById.size).toBe(0)
110
+ })
111
+
112
+ test("returns empty result when snapshot file is empty", async () => {
113
+ const storage = makeStorage({ "/data/snapshot.json": " " })
114
+ const state = createEmptyState()
115
+ const result = await loadSnapshotIntoState(
116
+ storage, "/data/snapshot.json", state, new Map(), async () => {},
117
+ )
118
+ expect(result).toEqual({ snapshotHasLegacyMessages: false, legacySidebarProjectOrder: [] })
119
+ })
120
+
121
+ test("calls clearStorage and returns empty when version mismatches", async () => {
122
+ const snapshot = { v: 999, generatedAt: 0, projects: [], chats: [] } as unknown as SnapshotFile
123
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
124
+ const state = createEmptyState()
125
+ let clearCalled = false
126
+ const result = await loadSnapshotIntoState(
127
+ storage, "/data/snapshot.json", state, new Map(),
128
+ async () => { clearCalled = true },
129
+ )
130
+ expect(clearCalled).toBe(true)
131
+ expect(result).toEqual({ snapshotHasLegacyMessages: false, legacySidebarProjectOrder: [] })
132
+ })
133
+
134
+ test("populates state.projectsById and projectIdsByPath from snapshot", async () => {
135
+ const snapshot: SnapshotFile = {
136
+ v: STORE_VERSION,
137
+ generatedAt: 100,
138
+ projects: [{
139
+ id: "proj-1",
140
+ localPath: "/home/user/project",
141
+ title: "My Project",
142
+ createdAt: 1,
143
+ updatedAt: 1,
144
+ }],
145
+ chats: [],
146
+ }
147
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
148
+ const state = createEmptyState()
149
+ await loadSnapshotIntoState(storage, "/data/snapshot.json", state, new Map(), async () => {})
150
+ expect(state.projectsById.has("proj-1")).toBe(true)
151
+ expect(state.projectIdsByPath.get("/home/user/project")).toBe("proj-1")
152
+ })
153
+
154
+ test("populates chatsById from snapshot", async () => {
155
+ const snapshot: SnapshotFile = {
156
+ v: STORE_VERSION,
157
+ generatedAt: 100,
158
+ projects: [],
159
+ chats: [{
160
+ id: "chat-1",
161
+ projectId: "proj-1",
162
+ title: "Test Chat",
163
+ createdAt: 1,
164
+ updatedAt: 1,
165
+ unread: false,
166
+ provider: null,
167
+ planMode: false,
168
+ sourceHash: null,
169
+ lastTurnOutcome: null,
170
+ sessionTokensByProvider: {},
171
+ pendingForkSessionToken: null,
172
+ }],
173
+ }
174
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
175
+ const state = createEmptyState()
176
+ await loadSnapshotIntoState(storage, "/data/snapshot.json", state, new Map(), async () => {})
177
+ expect(state.chatsById.has("chat-1")).toBe(true)
178
+ expect(state.chatsById.get("chat-1")?.unread).toBe(false)
179
+ })
180
+
181
+ test("sets snapshotHasLegacyMessages when messages field present", async () => {
182
+ const snapshot: SnapshotFile = {
183
+ v: STORE_VERSION,
184
+ generatedAt: 100,
185
+ projects: [],
186
+ chats: [],
187
+ messages: [{ chatId: "c1", entries: [] }],
188
+ }
189
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
190
+ const legacyMessages = new Map<string, TranscriptEntry[]>()
191
+ const result = await loadSnapshotIntoState(
192
+ storage, "/data/snapshot.json", createEmptyState(), legacyMessages, async () => {},
193
+ )
194
+ expect(result.snapshotHasLegacyMessages).toBe(true)
195
+ expect(legacyMessages.has("c1")).toBe(true)
196
+ })
197
+
198
+ test("returns legacySidebarProjectOrder from snapshot", async () => {
199
+ const snapshot: SnapshotFile = {
200
+ v: STORE_VERSION,
201
+ generatedAt: 100,
202
+ projects: [],
203
+ chats: [],
204
+ sidebarProjectOrder: ["proj-2", "proj-1"],
205
+ }
206
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
207
+ const result = await loadSnapshotIntoState(
208
+ storage, "/data/snapshot.json", createEmptyState(), new Map(), async () => {},
209
+ )
210
+ expect(result.legacySidebarProjectOrder).toEqual(["proj-2", "proj-1"])
211
+ })
212
+
213
+ test("calls clearStorage and returns empty on JSON parse error", async () => {
214
+ const storage = makeStorage({ "/data/snapshot.json": "{ invalid json" })
215
+ const state = createEmptyState()
216
+ let clearCalled = false
217
+ const result = await loadSnapshotIntoState(
218
+ storage, "/data/snapshot.json", state, new Map(),
219
+ async () => { clearCalled = true },
220
+ )
221
+ expect(clearCalled).toBe(true)
222
+ expect(result).toEqual({ snapshotHasLegacyMessages: false, legacySidebarProjectOrder: [] })
223
+ })
224
+
225
+ test("migrates legacy sessionToken to sessionTokensByProvider", async () => {
226
+ const legacyChat = {
227
+ id: "chat-1",
228
+ projectId: "proj-1",
229
+ title: "T",
230
+ createdAt: 1,
231
+ updatedAt: 1,
232
+ unread: false,
233
+ provider: "claude" as const,
234
+ sessionToken: "tok-123",
235
+ sessionTokensByProvider: undefined,
236
+ pendingForkSessionToken: null,
237
+ }
238
+ const snapshot = { v: STORE_VERSION, generatedAt: 1, projects: [], chats: [legacyChat] }
239
+ const storage = makeStorage({ "/data/snapshot.json": JSON.stringify(snapshot) })
240
+ const state = createEmptyState()
241
+ await loadSnapshotIntoState(storage, "/data/snapshot.json", state, new Map(), async () => {})
242
+ const chat = state.chatsById.get("chat-1")
243
+ expect(chat?.sessionTokensByProvider?.claude).toBe("tok-123")
244
+ })
245
+ })
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // buildSnapshotFile
249
+ // ---------------------------------------------------------------------------
250
+ describe("buildSnapshotFile", () => {
251
+ test("returns snapshot with correct version and empty arrays", () => {
252
+ const state = createEmptyState()
253
+ const result = buildSnapshotFile(state, [])
254
+ expect(result.v).toBe(STORE_VERSION)
255
+ expect(result.projects).toEqual([])
256
+ expect(result.chats).toEqual([])
257
+ expect(result.queuedMessages).toEqual([])
258
+ expect(result.autoContinueEvents).toEqual([])
259
+ expect(result.stacks).toEqual([])
260
+ })
261
+
262
+ test("includes only non-deleted chats", () => {
263
+ const state = createEmptyState()
264
+ state.chatsById.set("c1", {
265
+ id: "c1", projectId: "p1", title: "A", createdAt: 1, updatedAt: 1, unread: false,
266
+ provider: null, planMode: false, sourceHash: null, lastTurnOutcome: null,
267
+ sessionTokensByProvider: {}, pendingForkSessionToken: null,
268
+ })
269
+ state.chatsById.set("c2", {
270
+ id: "c2", projectId: "p1", title: "B", createdAt: 1, updatedAt: 1, unread: false,
271
+ provider: null, planMode: false, sourceHash: null, lastTurnOutcome: null,
272
+ sessionTokensByProvider: {}, pendingForkSessionToken: null,
273
+ deletedAt: 99,
274
+ })
275
+ const result = buildSnapshotFile(state, [])
276
+ expect(result.chats.map((c) => c.id)).toEqual(["c1"])
277
+ })
278
+
279
+ test("serializes stacks correctly", () => {
280
+ const state = createEmptyState()
281
+ state.stacksById.set("stack-1", {
282
+ id: "stack-1",
283
+ title: "My Stack",
284
+ projectIds: ["p1", "p2"],
285
+ createdAt: 1,
286
+ updatedAt: 1,
287
+ })
288
+ const result = buildSnapshotFile(state, [])
289
+ expect(result.stacks).toHaveLength(1)
290
+ expect(result.stacks![0].projectIds).toEqual(["p1", "p2"])
291
+ })
292
+ })
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // truncateLogsAfterSnapshot
296
+ // ---------------------------------------------------------------------------
297
+ describe("truncateLogsAfterSnapshot", () => {
298
+ test("writes snapshot JSON and clears all compactable log files", async () => {
299
+ const storage = makeStorage({
300
+ "/data/snapshot.json": "old",
301
+ "/data/projects.jsonl": "data",
302
+ "/data/chats.jsonl": "data",
303
+ "/data/messages.jsonl": "data",
304
+ "/data/queued-messages.jsonl": "data",
305
+ "/data/turns.jsonl": "data",
306
+ "/data/schedules.jsonl": "data",
307
+ "/data/stacks.jsonl": "data",
308
+ "/data/tool-requests.jsonl": "data",
309
+ })
310
+ await truncateLogsAfterSnapshot(storage, PATHS, '{"v":3}')
311
+ expect(storage.written.get("/data/snapshot.json")).toBe('{"v":3}')
312
+ expect(storage.written.get("/data/projects.jsonl")).toBe("")
313
+ expect(storage.written.get("/data/chats.jsonl")).toBe("")
314
+ expect(storage.written.get("/data/messages.jsonl")).toBe("")
315
+ expect(storage.written.get("/data/tool-requests.jsonl")).toBe("")
316
+ })
317
+ })
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // calcShouldTruncateLogs
321
+ // ---------------------------------------------------------------------------
322
+ describe("calcShouldTruncateLogs", () => {
323
+ test("returns false when combined size is below threshold", async () => {
324
+ const storage = makeStorage(
325
+ Object.fromEntries(Object.values(PATHS).map((p) => [p, "small"])),
326
+ )
327
+ const result = await calcShouldTruncateLogs(storage, PATHS)
328
+ expect(result).toBe(false)
329
+ })
330
+
331
+ test("returns true when combined size meets 2 MiB threshold", async () => {
332
+ // 2 MiB spread across files: put all in the projects log
333
+ const bigData = "x".repeat(2 * 1024 * 1024)
334
+ const storage = makeStorage({ "/data/projects.jsonl": bigData })
335
+ const result = await calcShouldTruncateLogs(storage, PATHS)
336
+ expect(result).toBe(true)
337
+ })
338
+ })
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // readSidebarOrderFromProjectsLog
342
+ // ---------------------------------------------------------------------------
343
+ describe("readSidebarOrderFromProjectsLog", () => {
344
+ test("returns empty array if file does not exist", async () => {
345
+ const storage = makeStorage()
346
+ const result = await readSidebarOrderFromProjectsLog(storage, "/data/projects.jsonl")
347
+ expect(result).toEqual([])
348
+ })
349
+
350
+ test("returns empty array if file is empty", async () => {
351
+ const storage = makeStorage({ "/data/projects.jsonl": "" })
352
+ const result = await readSidebarOrderFromProjectsLog(storage, "/data/projects.jsonl")
353
+ expect(result).toEqual([])
354
+ })
355
+
356
+ test("extracts projectIds from sidebar_project_order_set events", async () => {
357
+ const event = JSON.stringify({
358
+ v: STORE_VERSION, type: "sidebar_project_order_set",
359
+ timestamp: 1, projectIds: ["p2", "p1"],
360
+ })
361
+ const storage = makeStorage({ "/data/projects.jsonl": `${event}\n` })
362
+ const result = await readSidebarOrderFromProjectsLog(storage, "/data/projects.jsonl")
363
+ expect(result).toEqual(["p2", "p1"])
364
+ })
365
+
366
+ test("returns last seen order if multiple events present", async () => {
367
+ const e1 = JSON.stringify({ v: STORE_VERSION, type: "sidebar_project_order_set", timestamp: 1, projectIds: ["p1"] })
368
+ const e2 = JSON.stringify({ v: STORE_VERSION, type: "sidebar_project_order_set", timestamp: 2, projectIds: ["p2", "p1"] })
369
+ const storage = makeStorage({ "/data/projects.jsonl": `${e1}\n${e2}\n` })
370
+ const result = await readSidebarOrderFromProjectsLog(storage, "/data/projects.jsonl")
371
+ expect(result).toEqual(["p2", "p1"])
372
+ })
373
+
374
+ test("skips non-sidebar events", async () => {
375
+ const projectEvent = JSON.stringify({ v: STORE_VERSION, type: "project_opened", timestamp: 1, projectId: "p1", localPath: "/", title: "t" })
376
+ const sidebarEvent = JSON.stringify({ v: STORE_VERSION, type: "sidebar_project_order_set", timestamp: 2, projectIds: ["p1"] })
377
+ const storage = makeStorage({ "/data/projects.jsonl": `${projectEvent}\n${sidebarEvent}\n` })
378
+ const result = await readSidebarOrderFromProjectsLog(storage, "/data/projects.jsonl")
379
+ expect(result).toEqual(["p1"])
380
+ })
381
+ })
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // writeSidebarOrderFile
385
+ // ---------------------------------------------------------------------------
386
+ describe("writeSidebarOrderFile", () => {
387
+ test("writes projectIds as JSON to the sidebar order file", async () => {
388
+ const storage = makeStorage()
389
+ await writeSidebarOrderFile(storage, "/data", "/data/sidebar-order.json", ["p1", "p2"])
390
+ const written = storage.written.get("/data/sidebar-order.json")
391
+ expect(written).toBeDefined()
392
+ const parsed = JSON.parse(written!)
393
+ expect(parsed).toEqual(["p1", "p2"])
394
+ })
395
+ })
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // loadSidebarOrder
399
+ // ---------------------------------------------------------------------------
400
+ describe("loadSidebarOrder", () => {
401
+ test("reads from dedicated sidebar-order.json when it exists", async () => {
402
+ const storage = makeStorage({
403
+ "/data/sidebar-order.json": JSON.stringify(["p3", "p1", "p2"]),
404
+ })
405
+ const result = await loadSidebarOrder(storage, "/data/sidebar-order.json", "/data/projects.jsonl", "/data", [])
406
+ expect(result).toEqual(["p3", "p1", "p2"])
407
+ })
408
+
409
+ test("returns empty array if sidebar-order.json is blank", async () => {
410
+ const storage = makeStorage({ "/data/sidebar-order.json": " " })
411
+ const result = await loadSidebarOrder(storage, "/data/sidebar-order.json", "/data/projects.jsonl", "/data", [])
412
+ expect(result).toEqual([])
413
+ })
414
+
415
+ test("migrates from projects.jsonl when sidebar-order.json absent", async () => {
416
+ const sidebarEvent = JSON.stringify({
417
+ v: STORE_VERSION, type: "sidebar_project_order_set", timestamp: 1, projectIds: ["p2", "p1"],
418
+ })
419
+ const storage = makeStorage({ "/data/projects.jsonl": `${sidebarEvent}\n` })
420
+ const result = await loadSidebarOrder(storage, "/data/sidebar-order.json", "/data/projects.jsonl", "/data", [])
421
+ expect(result).toEqual(["p2", "p1"])
422
+ // Should have written the sidebar-order.json file as a migration
423
+ expect(storage.written.has("/data/sidebar-order.json")).toBe(true)
424
+ })
425
+
426
+ test("falls back to legacySidebarProjectOrder when no file or log events", async () => {
427
+ const storage = makeStorage()
428
+ const result = await loadSidebarOrder(storage, "/data/sidebar-order.json", "/data/projects.jsonl", "/data", ["p4"])
429
+ expect(result).toEqual(["p4"])
430
+ })
431
+ })
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // computeLegacyTranscriptStats
435
+ // ---------------------------------------------------------------------------
436
+ describe("computeLegacyTranscriptStats", () => {
437
+ test("returns hasLegacyData false when nothing present", async () => {
438
+ const storage = makeStorage({ "/data/messages.jsonl": "" })
439
+ const result = await computeLegacyTranscriptStats(storage, "/data/messages.jsonl", false, new Map())
440
+ expect(result.hasLegacyData).toBe(false)
441
+ expect(result.sources).toEqual([])
442
+ })
443
+
444
+ test("includes snapshot source when snapshotHasLegacyMessages is true", async () => {
445
+ const storage = makeStorage({ "/data/messages.jsonl": "" })
446
+ const result = await computeLegacyTranscriptStats(storage, "/data/messages.jsonl", true, new Map())
447
+ expect(result.hasLegacyData).toBe(true)
448
+ expect(result.sources).toContain("snapshot")
449
+ })
450
+
451
+ test("includes messages_log source when messages.jsonl is non-empty", async () => {
452
+ const storage = makeStorage({ "/data/messages.jsonl": "some content" })
453
+ const result = await computeLegacyTranscriptStats(storage, "/data/messages.jsonl", false, new Map())
454
+ expect(result.hasLegacyData).toBe(true)
455
+ expect(result.sources).toContain("messages_log")
456
+ })
457
+
458
+ test("counts entries and chats from legacyMessages map", async () => {
459
+ const storage = makeStorage({ "/data/messages.jsonl": "" })
460
+ const legacyMessages = new Map<string, TranscriptEntry[]>([
461
+ ["c1", [{ _id: "a", createdAt: 1, kind: "user_prompt", content: "" }]],
462
+ ["c2", [
463
+ { _id: "b", createdAt: 2, kind: "user_prompt", content: "" },
464
+ { _id: "c", createdAt: 3, kind: "user_prompt", content: "" },
465
+ ]],
466
+ ])
467
+ const result = await computeLegacyTranscriptStats(storage, "/data/messages.jsonl", false, legacyMessages)
468
+ expect(result.chatCount).toBe(2)
469
+ expect(result.entryCount).toBe(3)
470
+ expect(result.hasLegacyData).toBe(true)
471
+ })
472
+ })
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // migrateLegacyTranscripts
476
+ // ---------------------------------------------------------------------------
477
+ describe("migrateLegacyTranscripts", () => {
478
+ test("returns false immediately when no legacy data", async () => {
479
+ const storage = makeStorage()
480
+ const stats: LegacyTranscriptStats = { hasLegacyData: false, sources: [], chatCount: 0, entryCount: 0 }
481
+ const result = await migrateLegacyTranscripts(
482
+ storage, "/data/transcripts", stats,
483
+ new Map(), (_id) => `/data/transcripts/${_id}.jsonl`,
484
+ () => {}, async () => {}, () => {},
485
+ )
486
+ expect(result).toBe(false)
487
+ })
488
+
489
+ test("writes per-chat transcript files and calls callbacks on success", async () => {
490
+ const storage = makeStorage()
491
+ const legacyMessages = new Map<string, TranscriptEntry[]>([
492
+ ["chat-1", [{ _id: "e1", createdAt: 1, kind: "user_prompt", content: "hello" }]],
493
+ ])
494
+ const stats: LegacyTranscriptStats = {
495
+ hasLegacyData: true,
496
+ sources: ["snapshot"],
497
+ chatCount: 1,
498
+ entryCount: 1,
499
+ }
500
+ let clearCalled = false
501
+ let snapshotCalled = false
502
+ let cacheCalled = false
503
+ const messages: string[] = []
504
+
505
+ const result = await migrateLegacyTranscripts(
506
+ storage,
507
+ "/data/transcripts",
508
+ stats,
509
+ legacyMessages,
510
+ (id) => `/data/transcripts/${id}.jsonl`,
511
+ () => { clearCalled = true },
512
+ async () => { snapshotCalled = true },
513
+ () => { cacheCalled = true },
514
+ (msg) => messages.push(msg),
515
+ )
516
+
517
+ expect(result).toBe(true)
518
+ expect(clearCalled).toBe(true)
519
+ expect(snapshotCalled).toBe(true)
520
+ expect(cacheCalled).toBe(true)
521
+
522
+ // The renamed file (tmp→final) should exist
523
+ expect(storage.renamed).toHaveLength(1)
524
+ expect(storage.renamed[0][1]).toBe("/data/transcripts/chat-1.jsonl")
525
+ expect(messages.some((m) => m.includes("migration complete"))).toBe(true)
526
+ })
527
+ })
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // loadAndReplayLogs
531
+ // ---------------------------------------------------------------------------
532
+ describe("loadAndReplayLogs", () => {
533
+ test("exits early when storageReset is true before loading", async () => {
534
+ const storage = makeStorage()
535
+ let applied = 0
536
+ await loadAndReplayLogs(
537
+ storage, PATHS,
538
+ () => true,
539
+ () => { applied++ },
540
+ async () => {},
541
+ () => {},
542
+ )
543
+ expect(applied).toBe(0)
544
+ })
545
+
546
+ test("applies events from multiple log files in timestamp order", async () => {
547
+ const e1 = JSON.stringify({ v: STORE_VERSION, type: "project_opened", timestamp: 200, projectId: "p1", localPath: "/", title: "T" })
548
+ const e2 = JSON.stringify({ v: STORE_VERSION, type: "project_opened", timestamp: 100, projectId: "p2", localPath: "/2", title: "T2" })
549
+ const storage = makeStorage({
550
+ "/data/projects.jsonl": `${e1}\n`,
551
+ "/data/stacks.jsonl": `${e2}\n`,
552
+ })
553
+
554
+ const applied: StoreEvent[] = []
555
+ await loadAndReplayLogs(
556
+ storage, PATHS,
557
+ () => false,
558
+ (event) => { applied.push(event) },
559
+ async () => {},
560
+ () => {},
561
+ )
562
+
563
+ // Should be sorted by timestamp: p2 (100) before p1 (200)
564
+ expect(applied).toHaveLength(2)
565
+ const first = applied[0] as Extract<typeof applied[0], { type: "project_opened" }>
566
+ expect(first.type).toBe("project_opened")
567
+ expect((first as { projectId?: string }).projectId).toBe("p2")
568
+ })
569
+
570
+ test("calls clearStorage and exits on version mismatch in a log file", async () => {
571
+ const badEvent = JSON.stringify({ v: 999, type: "project_opened", timestamp: 1 })
572
+ const storage = makeStorage({ "/data/projects.jsonl": `${badEvent}\n` })
573
+ let clearCalled = false
574
+ let storageReset = false
575
+ await loadAndReplayLogs(
576
+ storage, PATHS,
577
+ () => storageReset,
578
+ () => {},
579
+ async () => { clearCalled = true; storageReset = true },
580
+ () => {},
581
+ )
582
+ expect(clearCalled).toBe(true)
583
+ })
584
+
585
+ test("calls onReplayChatProviderClear after successful replay", async () => {
586
+ const storage = makeStorage()
587
+ let cleared = false
588
+ await loadAndReplayLogs(
589
+ storage, PATHS,
590
+ () => false,
591
+ () => {},
592
+ async () => {},
593
+ () => { cleared = true },
594
+ )
595
+ expect(cleared).toBe(true)
596
+ })
597
+ })