@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,658 @@
1
+ /**
2
+ * Snapshot persistence, log-replay, sidebar-order, and event-log-load helpers
3
+ * for EventStore.
4
+ *
5
+ * Extracted from event-store.ts to reduce file size. All IO is performed
6
+ * through the injected StorageBackend abstraction; the sealed side-effect
7
+ * lives in FsStorageBackend.adapter.ts, not here.
8
+ */
9
+
10
+ import path from "node:path"
11
+ import { LOG_PREFIX } from "../shared/branding"
12
+ import type { AnyValue } from "../shared/errors"
13
+ import { log } from "../shared/log"
14
+ import type { AgentProvider, TranscriptEntry } from "../shared/types"
15
+ import { STORE_VERSION } from "../shared/types"
16
+ import type { StorageBackend } from "./storage/backend"
17
+ import type { CloudflareTunnelEvent } from "./cloudflare-tunnel/events"
18
+ import type { PushEvent } from "./push/events"
19
+ import type { ShareEvent } from "./session-share/share-projection"
20
+ import {
21
+ type ChatRecord,
22
+ type ProjectRecord,
23
+ type SnapshotFile,
24
+ type StoreEvent,
25
+ type StoreState,
26
+ cloneTranscriptEntries,
27
+ } from "./events"
28
+ import {
29
+ getReplayEventPriority,
30
+ normalizeSidebarProjectOrder,
31
+ } from "./event-store-helpers"
32
+
33
+ // ─── Types ─────────────────────────────────────────────────────────────────
34
+
35
+ export interface LegacyTranscriptStats {
36
+ hasLegacyData: boolean
37
+ sources: Array<"snapshot" | "messages_log">
38
+ chatCount: number
39
+ entryCount: number
40
+ }
41
+
42
+ /** Paths for the log files involved in snapshot/replay. */
43
+ export interface SnapshotLogPaths {
44
+ snapshotPath: string
45
+ projectsLogPath: string
46
+ chatsLogPath: string
47
+ messagesLogPath: string
48
+ queuedMessagesLogPath: string
49
+ turnsLogPath: string
50
+ schedulesLogPath: string
51
+ stacksLogPath: string
52
+ toolRequestsLogPath: string
53
+ orchLogPath: string
54
+ }
55
+
56
+ /** Returned by {@link loadSnapshotIntoState}. */
57
+ export interface LoadSnapshotResult {
58
+ snapshotHasLegacyMessages: boolean
59
+ legacySidebarProjectOrder: string[]
60
+ }
61
+
62
+ // Internal only — used by loadAndReplayLogs.
63
+ interface ParsedReplayEvent {
64
+ event: StoreEvent
65
+ sourceIndex: number
66
+ lineIndex: number
67
+ }
68
+
69
+ // ─── Snapshot loading ──────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Read `snapshotPath` and populate the in-memory state maps from its content.
73
+ * Calls `clearStorage` when the snapshot version does not match STORE_VERSION
74
+ * or when parsing fails (fail-closed reset).
75
+ *
76
+ * @returns scalar fields that cannot be set through the mutated Maps.
77
+ */
78
+ export async function loadSnapshotIntoState(
79
+ storage: StorageBackend,
80
+ snapshotPath: string,
81
+ state: StoreState,
82
+ legacyMessagesByChatId: Map<string, TranscriptEntry[]>,
83
+ clearStorage: () => Promise<void>,
84
+ ): Promise<LoadSnapshotResult> {
85
+ const empty: LoadSnapshotResult = { snapshotHasLegacyMessages: false, legacySidebarProjectOrder: [] }
86
+
87
+ if (!(await storage.exists(snapshotPath))) return empty
88
+
89
+ try {
90
+ const text = await storage.readText(snapshotPath)
91
+ if (!text.trim()) return empty
92
+
93
+ const parsed: SnapshotFile = JSON.parse(text)
94
+ if (parsed.v !== STORE_VERSION) {
95
+ log.warn(`${LOG_PREFIX} Resetting local chat history for store version ${STORE_VERSION}`)
96
+ await clearStorage()
97
+ return empty
98
+ }
99
+
100
+ for (const project of parsed.projects) {
101
+ state.projectsById.set(project.id, { ...project })
102
+ state.projectIdsByPath.set(project.localPath, project.id)
103
+ }
104
+
105
+ for (const chat of parsed.chats) {
106
+ // Access legacy fields from old snapshot data via Reflect.get (avoids `as` casts).
107
+ const legacySessionToken: string | null | undefined = Reflect.get(chat, "sessionToken")
108
+ const legacyPendingFork: string | null | { provider: AgentProvider; token: string } | undefined =
109
+ Reflect.get(chat, "pendingForkSessionToken")
110
+ const legacyTokensByProvider: Partial<Record<AgentProvider, string | null>> | undefined =
111
+ Reflect.get(chat, "sessionTokensByProvider")
112
+
113
+ const sessionTokensByProvider: Partial<Record<AgentProvider, string | null>> =
114
+ legacyTokensByProvider ? { ...legacyTokensByProvider } : {}
115
+ if (
116
+ typeof legacySessionToken === "string"
117
+ && chat.provider
118
+ && sessionTokensByProvider[chat.provider] == null
119
+ ) {
120
+ sessionTokensByProvider[chat.provider] = legacySessionToken
121
+ }
122
+
123
+ let pendingForkSessionToken: ChatRecord["pendingForkSessionToken"] = null
124
+ if (legacyPendingFork && typeof legacyPendingFork === "object" && "token" in legacyPendingFork) {
125
+ pendingForkSessionToken = legacyPendingFork
126
+ } else if (typeof legacyPendingFork === "string" && chat.provider) {
127
+ pendingForkSessionToken = { provider: chat.provider, token: legacyPendingFork }
128
+ }
129
+
130
+ state.chatsById.set(chat.id, {
131
+ ...chat,
132
+ unread: chat.unread ?? false,
133
+ sessionTokensByProvider,
134
+ pendingForkSessionToken,
135
+ })
136
+ // Mirror the chat_created handler: every live chat needs a subagent-run
137
+ // map, or subagent_run_started events after a reboot are silently
138
+ // dropped from the read model (the runs never reach the UI).
139
+ if (!chat.deletedAt) {
140
+ state.subagentRunsByChatId.set(chat.id, new Map())
141
+ }
142
+ }
143
+
144
+ const legacySidebarProjectOrder = normalizeSidebarProjectOrder(
145
+ parsed.sidebarProjectOrder,
146
+ )
147
+
148
+ if (parsed.queuedMessages?.length) {
149
+ for (const queuedSet of parsed.queuedMessages) {
150
+ state.queuedMessagesByChatId.set(
151
+ queuedSet.chatId,
152
+ queuedSet.entries.map((entry) => ({
153
+ ...entry,
154
+ attachments: [...entry.attachments],
155
+ })),
156
+ )
157
+ }
158
+ }
159
+
160
+ let snapshotHasLegacyMessages = false
161
+ if (parsed.messages?.length) {
162
+ snapshotHasLegacyMessages = true
163
+ for (const messageSet of parsed.messages) {
164
+ legacyMessagesByChatId.set(
165
+ messageSet.chatId,
166
+ cloneTranscriptEntries(messageSet.entries),
167
+ )
168
+ }
169
+ }
170
+
171
+ if (parsed.autoContinueEvents?.length) {
172
+ for (const entry of parsed.autoContinueEvents) {
173
+ state.autoContinueEventsByChatId.set(entry.chatId, [...entry.events])
174
+ }
175
+ }
176
+
177
+ if (parsed.stacks?.length) {
178
+ for (const stack of parsed.stacks) {
179
+ state.stacksById.set(stack.id, { ...stack, projectIds: [...stack.projectIds] })
180
+ }
181
+ }
182
+
183
+ return { snapshotHasLegacyMessages, legacySidebarProjectOrder }
184
+ } catch (error) {
185
+ log.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, String(error))
186
+ await clearStorage()
187
+ return empty
188
+ }
189
+ }
190
+
191
+ // ─── Snapshot persistence ──────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Build a `SnapshotFile` from the current in-memory state (pure — no IO).
195
+ */
196
+ export function buildSnapshotFile(
197
+ state: StoreState,
198
+ projects: ProjectRecord[],
199
+ ): SnapshotFile {
200
+ return {
201
+ v: STORE_VERSION,
202
+ generatedAt: Date.now(),
203
+ projects: projects.map((project) => ({ ...project })),
204
+ chats: [...state.chatsById.values()]
205
+ .filter((chat) => !chat.deletedAt)
206
+ .map((chat) => ({ ...chat })),
207
+ queuedMessages: [...state.queuedMessagesByChatId.entries()].map(([chatId, entries]) => ({
208
+ chatId,
209
+ entries: entries.map((entry) => ({
210
+ ...entry,
211
+ attachments: [...entry.attachments],
212
+ })),
213
+ })),
214
+ autoContinueEvents: [...state.autoContinueEventsByChatId.entries()].map(
215
+ ([chatId, events]) => ({ chatId, events: [...events] }),
216
+ ),
217
+ stacks: [...state.stacksById.values()]
218
+ .filter((stack) => !stack.deletedAt)
219
+ .map((stack) => ({ ...stack, projectIds: [...stack.projectIds] })),
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Write `snapshotJson` to disk and truncate all compactable log files to empty.
225
+ * (tunnels.jsonl, push.jsonl, tool-requests.jsonl are intentionally excluded;
226
+ * see comments in the original EventStore implementation.)
227
+ */
228
+ export async function truncateLogsAfterSnapshot(
229
+ storage: StorageBackend,
230
+ paths: SnapshotLogPaths,
231
+ snapshotJson: string,
232
+ ): Promise<void> {
233
+ await storage.writeText(paths.snapshotPath, snapshotJson)
234
+ await Promise.all([
235
+ storage.writeText(paths.projectsLogPath, ""),
236
+ storage.writeText(paths.chatsLogPath, ""),
237
+ storage.writeText(paths.messagesLogPath, ""),
238
+ storage.writeText(paths.queuedMessagesLogPath, ""),
239
+ storage.writeText(paths.turnsLogPath, ""),
240
+ storage.writeText(paths.schedulesLogPath, ""),
241
+ storage.writeText(paths.stacksLogPath, ""),
242
+ storage.writeText(paths.toolRequestsLogPath, ""),
243
+ ])
244
+ }
245
+
246
+ const SNAPSHOT_THRESHOLD_BYTES = 2 * 1024 * 1024
247
+
248
+ /**
249
+ * Returns true when the combined size of compactable log files exceeds
250
+ * {@link SNAPSHOT_THRESHOLD_BYTES} (2 MiB).
251
+ */
252
+ export async function calcShouldTruncateLogs(
253
+ storage: StorageBackend,
254
+ paths: SnapshotLogPaths,
255
+ ): Promise<boolean> {
256
+ const sizes = await Promise.all([
257
+ storage.size(paths.projectsLogPath),
258
+ storage.size(paths.chatsLogPath),
259
+ storage.size(paths.messagesLogPath),
260
+ storage.size(paths.queuedMessagesLogPath),
261
+ storage.size(paths.turnsLogPath),
262
+ storage.size(paths.schedulesLogPath),
263
+ storage.size(paths.stacksLogPath),
264
+ storage.size(paths.toolRequestsLogPath),
265
+ ])
266
+ return sizes.reduce((total, size) => total + size, 0) >= SNAPSHOT_THRESHOLD_BYTES
267
+ }
268
+
269
+ // ─── Log replay ────────────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Load a single JSONL log file and return the parsed events, skipping
273
+ * `sidebar_project_order_set` events (handled separately). On a corrupt
274
+ * trailing line, returns whatever was parsed so far. On an incompatible
275
+ * store version or a corrupt mid-file line, calls `clearStorage` and returns
276
+ * an empty array.
277
+ */
278
+ export async function loadReplayEventsFromFile(
279
+ storage: StorageBackend,
280
+ filePath: string,
281
+ sourceIndex: number,
282
+ clearStorage: () => Promise<void>,
283
+ ): Promise<ParsedReplayEvent[]> {
284
+ if (!(await storage.exists(filePath))) return []
285
+
286
+ const text = await storage.readText(filePath)
287
+ if (!text.trim()) return []
288
+
289
+ const parsedEvents: ParsedReplayEvent[] = []
290
+ const lines = text.split("\n")
291
+ let lastNonEmpty = -1
292
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
293
+ if (lines[index].trim()) {
294
+ lastNonEmpty = index
295
+ break
296
+ }
297
+ }
298
+
299
+ for (let index = 0; index < lines.length; index += 1) {
300
+ const line = lines[index].trim()
301
+ if (!line) continue
302
+ try {
303
+ const event: StoreEvent & { v?: number; type?: string } = JSON.parse(line)
304
+ if (event.v !== STORE_VERSION) {
305
+ log.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
306
+ await clearStorage()
307
+ return []
308
+ }
309
+ if (event.type === "sidebar_project_order_set") {
310
+ continue
311
+ }
312
+ parsedEvents.push({ event, sourceIndex, lineIndex: index })
313
+ } catch (error) {
314
+ if (index === lastNonEmpty) {
315
+ log.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
316
+ return parsedEvents
317
+ }
318
+ log.warn(
319
+ `${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`,
320
+ String(error),
321
+ )
322
+ await clearStorage()
323
+ return []
324
+ }
325
+ }
326
+
327
+ return parsedEvents
328
+ }
329
+
330
+ /**
331
+ * Load all log files, sort events by timestamp, and apply them in order.
332
+ * Short-circuits if `isStorageReset()` returns true before or after loading
333
+ * (a version mismatch in any file triggers `clearStorage` in
334
+ * {@link loadReplayEventsFromFile}).
335
+ */
336
+ export async function loadAndReplayLogs(
337
+ storage: StorageBackend,
338
+ paths: SnapshotLogPaths,
339
+ isStorageReset: () => boolean,
340
+ applyEvent: (event: StoreEvent) => void,
341
+ clearStorage: () => Promise<void>,
342
+ onReplayChatProviderClear: () => void,
343
+ ): Promise<void> {
344
+ if (isStorageReset()) return
345
+
346
+ const replayEvents = [
347
+ ...await loadReplayEventsFromFile(storage, paths.projectsLogPath, 0, clearStorage),
348
+ ...await loadReplayEventsFromFile(storage, paths.stacksLogPath, 1, clearStorage),
349
+ ...await loadReplayEventsFromFile(storage, paths.chatsLogPath, 2, clearStorage),
350
+ ...await loadReplayEventsFromFile(storage, paths.messagesLogPath, 3, clearStorage),
351
+ ...await loadReplayEventsFromFile(storage, paths.queuedMessagesLogPath, 4, clearStorage),
352
+ ...await loadReplayEventsFromFile(storage, paths.turnsLogPath, 5, clearStorage),
353
+ ...await loadReplayEventsFromFile(storage, paths.schedulesLogPath, 6, clearStorage),
354
+ ...await loadReplayEventsFromFile(storage, paths.toolRequestsLogPath, 7, clearStorage),
355
+ ...await loadReplayEventsFromFile(storage, paths.orchLogPath, 8, clearStorage),
356
+ ]
357
+
358
+ if (isStorageReset()) return
359
+
360
+ replayEvents
361
+ .sort(
362
+ (left, right) =>
363
+ left.event.timestamp - right.event.timestamp
364
+ || getReplayEventPriority(left.event) - getReplayEventPriority(right.event)
365
+ || left.sourceIndex - right.sourceIndex
366
+ || left.lineIndex - right.lineIndex,
367
+ )
368
+ .forEach(({ event }) => { applyEvent(event) })
369
+
370
+ onReplayChatProviderClear()
371
+ }
372
+
373
+ // ─── Sidebar project order ─────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Write `projectIds` as JSON to the sidebar order file.
377
+ */
378
+ export async function writeSidebarOrderFile(
379
+ storage: StorageBackend,
380
+ dataDir: string,
381
+ sidebarProjectOrderPath: string,
382
+ projectIds: string[],
383
+ ): Promise<void> {
384
+ await storage.mkdir(dataDir)
385
+ await storage.writeText(sidebarProjectOrderPath, `${JSON.stringify(projectIds, null, 2)}\n`)
386
+ }
387
+
388
+ /**
389
+ * Scan `projectsLogPath` for the latest `sidebar_project_order_set` event and
390
+ * return the project IDs from it. Returns `[]` if the file is absent, empty,
391
+ * or corrupt.
392
+ */
393
+ export async function readSidebarOrderFromProjectsLog(
394
+ storage: StorageBackend,
395
+ projectsLogPath: string,
396
+ ): Promise<string[]> {
397
+ if (!(await storage.exists(projectsLogPath))) return []
398
+
399
+ const text = await storage.readText(projectsLogPath)
400
+ if (!text.trim()) return []
401
+
402
+ const lines = text.split("\n")
403
+ let lastNonEmpty = -1
404
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
405
+ if (lines[index].trim()) {
406
+ lastNonEmpty = index
407
+ break
408
+ }
409
+ }
410
+
411
+ let projectIds: string[] = []
412
+ for (let index = 0; index < lines.length; index += 1) {
413
+ const line = lines[index].trim()
414
+ if (!line) continue
415
+ try {
416
+ const event: { v?: number; type?: string; projectIds?: AnyValue } = JSON.parse(line)
417
+ if (event.v !== STORE_VERSION || event.type !== "sidebar_project_order_set") {
418
+ continue
419
+ }
420
+ projectIds = normalizeSidebarProjectOrder(event.projectIds)
421
+ } catch (error) {
422
+ if (index === lastNonEmpty) {
423
+ log.warn(
424
+ `${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(projectsLogPath)} while migrating sidebar order`,
425
+ )
426
+ return projectIds
427
+ }
428
+ log.warn(
429
+ `${LOG_PREFIX} Failed to migrate sidebar order from ${path.basename(projectsLogPath)}:`,
430
+ String(error),
431
+ )
432
+ return []
433
+ }
434
+ }
435
+
436
+ return projectIds
437
+ }
438
+
439
+ /**
440
+ * Load the authoritative sidebar project order from disk.
441
+ *
442
+ * Resolution order:
443
+ * 1. Dedicated `sidebar-order.json` file.
444
+ * 2. Latest `sidebar_project_order_set` event from `projects.jsonl`.
445
+ * 3. `legacySidebarProjectOrder` from the last loaded snapshot.
446
+ *
447
+ * If the dedicated file does not exist but a legacy order is found, the
448
+ * legacy order is written to the dedicated file as a one-time migration.
449
+ *
450
+ * Returns the resolved project IDs array.
451
+ */
452
+ export async function loadSidebarOrder(
453
+ storage: StorageBackend,
454
+ sidebarProjectOrderPath: string,
455
+ projectsLogPath: string,
456
+ dataDir: string,
457
+ legacySidebarProjectOrder: string[],
458
+ ): Promise<string[]> {
459
+ if (await storage.exists(sidebarProjectOrderPath)) {
460
+ try {
461
+ const text = await storage.readText(sidebarProjectOrderPath)
462
+ if (!text.trim()) return []
463
+ return normalizeSidebarProjectOrder(JSON.parse(text))
464
+ } catch (error) {
465
+ log.warn(
466
+ `${LOG_PREFIX} Failed to load sidebar-order.json, ignoring saved order:`,
467
+ String(error),
468
+ )
469
+ return []
470
+ }
471
+ }
472
+
473
+ // No dedicated file yet — migrate from the legacy source.
474
+ const fromProjectsLog = await readSidebarOrderFromProjectsLog(storage, projectsLogPath)
475
+ const order = fromProjectsLog.length > 0 ? fromProjectsLog : [...legacySidebarProjectOrder]
476
+
477
+ if (order.length > 0) {
478
+ await writeSidebarOrderFile(storage, dataDir, sidebarProjectOrderPath, order)
479
+ }
480
+ return order
481
+ }
482
+
483
+ // ─── Legacy transcript helpers ─────────────────────────────────────────────
484
+
485
+ /**
486
+ * Compute legacy-transcript statistics from the current in-memory legacy
487
+ * maps and the storage size of `messages.jsonl`.
488
+ */
489
+ export async function computeLegacyTranscriptStats(
490
+ storage: StorageBackend,
491
+ messagesLogPath: string,
492
+ snapshotHasLegacyMessages: boolean,
493
+ legacyMessagesByChatId: Map<string, TranscriptEntry[]>,
494
+ ): Promise<LegacyTranscriptStats> {
495
+ const messagesLogSize = await storage.size(messagesLogPath)
496
+ const sources: LegacyTranscriptStats["sources"] = []
497
+ if (snapshotHasLegacyMessages) {
498
+ sources.push("snapshot")
499
+ }
500
+ if (messagesLogSize > 0) {
501
+ sources.push("messages_log")
502
+ }
503
+
504
+ let entryCount = 0
505
+ for (const entries of legacyMessagesByChatId.values()) {
506
+ entryCount += entries.length
507
+ }
508
+
509
+ return {
510
+ hasLegacyData: sources.length > 0 || legacyMessagesByChatId.size > 0,
511
+ sources,
512
+ chatCount: legacyMessagesByChatId.size,
513
+ entryCount,
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Write per-chat transcript files from `legacyMessagesByChatId`, clear
519
+ * the legacy state, snapshot + truncate logs, and invalidate the cache.
520
+ *
521
+ * Returns `false` if no legacy data exists; `true` on success.
522
+ */
523
+ export async function migrateLegacyTranscripts(
524
+ storage: StorageBackend,
525
+ transcriptsDir: string,
526
+ legacyStats: LegacyTranscriptStats,
527
+ legacyMessagesByChatId: Map<string, TranscriptEntry[]>,
528
+ transcriptPath: (chatId: string) => string,
529
+ onClearLegacyState: () => void,
530
+ onSnapshotAndTruncate: () => Promise<void>,
531
+ onCacheInvalidate: () => void,
532
+ onProgress?: (message: string) => void,
533
+ ): Promise<boolean> {
534
+ if (!legacyStats.hasLegacyData) return false
535
+
536
+ const sourceSummary = legacyStats.sources
537
+ .map((source) => (source === "messages_log" ? "messages.jsonl" : "snapshot.json"))
538
+ .join(", ")
539
+ onProgress?.(
540
+ `${LOG_PREFIX} transcript migration detected: ${legacyStats.chatCount} chats, ${legacyStats.entryCount} entries from ${sourceSummary}`,
541
+ )
542
+
543
+ const messageSets = [...legacyMessagesByChatId.entries()]
544
+ onProgress?.(
545
+ `${LOG_PREFIX} transcript migration: writing ${messageSets.length} per-chat transcript files`,
546
+ )
547
+
548
+ await storage.mkdir(transcriptsDir)
549
+ const logEveryChat = messageSets.length <= 10
550
+ for (let index = 0; index < messageSets.length; index += 1) {
551
+ const [chatId, entries] = messageSets[index]
552
+ const chatTranscriptPath = transcriptPath(chatId)
553
+ const tempPath = `${chatTranscriptPath}.tmp`
554
+ const payload = entries.map((entry) => JSON.stringify(entry)).join("\n")
555
+ await storage.writeText(tempPath, payload ? `${payload}\n` : "")
556
+ await storage.rename(tempPath, chatTranscriptPath)
557
+ if (logEveryChat || (index + 1) % 25 === 0 || index === messageSets.length - 1) {
558
+ onProgress?.(`${LOG_PREFIX} transcript migration: ${index + 1}/${messageSets.length} chats`)
559
+ }
560
+ }
561
+
562
+ onClearLegacyState()
563
+ await onSnapshotAndTruncate()
564
+ onCacheInvalidate()
565
+ onProgress?.(`${LOG_PREFIX} transcript migration complete`)
566
+ return true
567
+ }
568
+
569
+ // ─── Tunnel event apply + load ─────────────────────────────────────────────
570
+
571
+ /**
572
+ * Apply a single CloudflareTunnelEvent to the in-memory map (pure, no IO).
573
+ */
574
+ export function applyTunnelEventToMap(
575
+ tunnelEventsByChatId: Map<string, CloudflareTunnelEvent[]>,
576
+ event: CloudflareTunnelEvent,
577
+ ): void {
578
+ const existing = tunnelEventsByChatId.get(event.chatId) ?? []
579
+ existing.push(event)
580
+ tunnelEventsByChatId.set(event.chatId, existing)
581
+ }
582
+
583
+ /**
584
+ * Load `tunnels.jsonl` from disk and populate `tunnelEventsByChatId`.
585
+ * Malformed lines are skipped with a warning.
586
+ */
587
+ export async function loadTunnelEventsFromLog(
588
+ storage: StorageBackend,
589
+ tunnelLogPath: string,
590
+ tunnelEventsByChatId: Map<string, CloudflareTunnelEvent[]>,
591
+ ): Promise<void> {
592
+ if (!(await storage.exists(tunnelLogPath))) return
593
+ const text = await storage.readText(tunnelLogPath)
594
+ if (!text.trim()) return
595
+ for (const rawLine of text.split("\n")) {
596
+ const line = rawLine.trim()
597
+ if (!line) continue
598
+ try {
599
+ const event: CloudflareTunnelEvent = JSON.parse(line)
600
+ applyTunnelEventToMap(tunnelEventsByChatId, event)
601
+ } catch {
602
+ log.warn(`${LOG_PREFIX} Ignoring malformed line in tunnels.jsonl`)
603
+ }
604
+ }
605
+ }
606
+
607
+ // ─── Share event load ──────────────────────────────────────────────────────
608
+
609
+ /**
610
+ * Load `shares.jsonl` from disk and append to `shareEventsAll`.
611
+ * Malformed lines are skipped with a warning.
612
+ */
613
+ export async function loadShareEventsFromLog(
614
+ storage: StorageBackend,
615
+ sharesLogPath: string,
616
+ shareEventsAll: ShareEvent[],
617
+ ): Promise<void> {
618
+ if (!(await storage.exists(sharesLogPath))) return
619
+ const text = await storage.readText(sharesLogPath)
620
+ if (!text.trim()) return
621
+ for (const rawLine of text.split("\n")) {
622
+ const line = rawLine.trim()
623
+ if (!line) continue
624
+ try {
625
+ const event: ShareEvent = JSON.parse(line)
626
+ shareEventsAll.push(event)
627
+ } catch {
628
+ log.warn(`${LOG_PREFIX} Ignoring malformed line in shares.jsonl`)
629
+ }
630
+ }
631
+ }
632
+
633
+ // ─── Push event load ───────────────────────────────────────────────────────
634
+
635
+ /**
636
+ * Load `push.jsonl` from disk and return all parsed PushEvents.
637
+ * Malformed lines are skipped with a warning.
638
+ */
639
+ export async function loadPushEventsFromLog(
640
+ storage: StorageBackend,
641
+ pushLogPath: string,
642
+ ): Promise<PushEvent[]> {
643
+ if (!(await storage.exists(pushLogPath))) return []
644
+ const text = await storage.readText(pushLogPath)
645
+ if (!text.trim()) return []
646
+ const events: PushEvent[] = []
647
+ for (const rawLine of text.split("\n")) {
648
+ const line = rawLine.trim()
649
+ if (!line) continue
650
+ try {
651
+ const pushEvent: PushEvent = JSON.parse(line)
652
+ events.push(pushEvent)
653
+ } catch {
654
+ log.warn(`${LOG_PREFIX} Ignoring malformed line in push.jsonl`)
655
+ }
656
+ }
657
+ return events
658
+ }