kanna-code 0.26.5 → 0.26.7
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.
- package/dist/client/assets/index-CH3ZbXse.js +2583 -0
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/agent.ts +128 -0
- package/src/server/event-store.test.ts +102 -5
- package/src/server/event-store.ts +73 -17
- package/src/server/ws-router.test.ts +80 -0
- package/src/server/ws-router.ts +63 -2
- package/src/shared/protocol.ts +2 -0
- package/dist/client/assets/index-BaEB4jie.js +0 -2583
package/dist/client/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>Kanna</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CH3ZbXse.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-C5RBxQW4.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
package/src/server/agent.ts
CHANGED
|
@@ -63,6 +63,8 @@ interface ActiveTurn {
|
|
|
63
63
|
hasFinalResult: boolean
|
|
64
64
|
cancelRequested: boolean
|
|
65
65
|
cancelRecorded: boolean
|
|
66
|
+
clientTraceId?: string
|
|
67
|
+
profilingStartedAt?: number
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
interface ClaudeSessionHandle {
|
|
@@ -102,6 +104,11 @@ interface AgentCoordinatorArgs {
|
|
|
102
104
|
}) => Promise<ClaudeSessionHandle>
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
interface SendToStartingProfile {
|
|
108
|
+
traceId: string
|
|
109
|
+
startedAt: number
|
|
110
|
+
}
|
|
111
|
+
|
|
105
112
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
106
113
|
entry: T,
|
|
107
114
|
createdAt = Date.now()
|
|
@@ -138,6 +145,31 @@ function escapeXmlAttribute(value: string) {
|
|
|
138
145
|
.replaceAll(">", ">")
|
|
139
146
|
}
|
|
140
147
|
|
|
148
|
+
function isSendToStartingProfilingEnabled() {
|
|
149
|
+
return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function elapsedProfileMs(startedAt: number) {
|
|
153
|
+
return Number((performance.now() - startedAt).toFixed(1))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function logSendToStartingProfile(
|
|
157
|
+
profile: SendToStartingProfile | null | undefined,
|
|
158
|
+
stage: string,
|
|
159
|
+
details?: Record<string, unknown>
|
|
160
|
+
) {
|
|
161
|
+
if (!profile || !isSendToStartingProfilingEnabled()) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("[kanna/send->starting][server]", JSON.stringify({
|
|
166
|
+
traceId: profile.traceId,
|
|
167
|
+
stage,
|
|
168
|
+
elapsedMs: elapsedProfileMs(profile.startedAt),
|
|
169
|
+
...details,
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
export function buildAttachmentHintText(attachments: ChatAttachment[]) {
|
|
142
174
|
if (attachments.length === 0) return ""
|
|
143
175
|
|
|
@@ -636,6 +668,18 @@ export class AgentCoordinator {
|
|
|
636
668
|
return new Set(this.drainingStreams.keys())
|
|
637
669
|
}
|
|
638
670
|
|
|
671
|
+
getActiveTurnProfile(chatId: string): SendToStartingProfile | null {
|
|
672
|
+
const active = this.activeTurns.get(chatId)
|
|
673
|
+
if (!active?.clientTraceId || active.profilingStartedAt === undefined) {
|
|
674
|
+
return null
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
traceId: active.clientTraceId,
|
|
679
|
+
startedAt: active.profilingStartedAt,
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
639
683
|
async stopDraining(chatId: string) {
|
|
640
684
|
const draining = this.drainingStreams.get(chatId)
|
|
641
685
|
if (!draining) return
|
|
@@ -691,7 +735,15 @@ export class AgentCoordinator {
|
|
|
691
735
|
serviceTier?: "fast"
|
|
692
736
|
planMode: boolean
|
|
693
737
|
appendUserPrompt: boolean
|
|
738
|
+
profile?: SendToStartingProfile | null
|
|
694
739
|
}) {
|
|
740
|
+
logSendToStartingProfile(args.profile, "start_turn.begin", {
|
|
741
|
+
chatId: args.chatId,
|
|
742
|
+
provider: args.provider,
|
|
743
|
+
appendUserPrompt: args.appendUserPrompt,
|
|
744
|
+
planMode: args.planMode,
|
|
745
|
+
})
|
|
746
|
+
|
|
695
747
|
// Close any lingering draining stream before starting a new turn.
|
|
696
748
|
const draining = this.drainingStreams.get(args.chatId)
|
|
697
749
|
if (draining) {
|
|
@@ -706,8 +758,16 @@ export class AgentCoordinator {
|
|
|
706
758
|
|
|
707
759
|
if (!chat.provider) {
|
|
708
760
|
await this.store.setChatProvider(args.chatId, args.provider)
|
|
761
|
+
logSendToStartingProfile(args.profile, "start_turn.provider_set", {
|
|
762
|
+
chatId: args.chatId,
|
|
763
|
+
provider: args.provider,
|
|
764
|
+
})
|
|
709
765
|
}
|
|
710
766
|
await this.store.setPlanMode(args.chatId, args.planMode)
|
|
767
|
+
logSendToStartingProfile(args.profile, "start_turn.plan_mode_set", {
|
|
768
|
+
chatId: args.chatId,
|
|
769
|
+
planMode: args.planMode,
|
|
770
|
+
})
|
|
711
771
|
|
|
712
772
|
const existingMessages = this.store.getMessages(args.chatId)
|
|
713
773
|
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
@@ -715,6 +775,10 @@ export class AgentCoordinator {
|
|
|
715
775
|
|
|
716
776
|
if (optimisticTitle) {
|
|
717
777
|
await this.store.renameChat(args.chatId, optimisticTitle)
|
|
778
|
+
logSendToStartingProfile(args.profile, "start_turn.optimistic_title_set", {
|
|
779
|
+
chatId: args.chatId,
|
|
780
|
+
title: optimisticTitle,
|
|
781
|
+
})
|
|
718
782
|
}
|
|
719
783
|
|
|
720
784
|
const project = this.store.getProject(chat.projectId)
|
|
@@ -728,8 +792,15 @@ export class AgentCoordinator {
|
|
|
728
792
|
Date.now()
|
|
729
793
|
)
|
|
730
794
|
await this.store.appendMessage(args.chatId, userPromptEntry)
|
|
795
|
+
logSendToStartingProfile(args.profile, "start_turn.user_prompt_appended", {
|
|
796
|
+
chatId: args.chatId,
|
|
797
|
+
entryId: userPromptEntry._id,
|
|
798
|
+
})
|
|
731
799
|
}
|
|
732
800
|
await this.store.recordTurnStarted(args.chatId)
|
|
801
|
+
logSendToStartingProfile(args.profile, "start_turn.turn_started_recorded", {
|
|
802
|
+
chatId: args.chatId,
|
|
803
|
+
})
|
|
733
804
|
|
|
734
805
|
if (shouldGenerateTitle) {
|
|
735
806
|
void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
|
|
@@ -755,6 +826,11 @@ export class AgentCoordinator {
|
|
|
755
826
|
|
|
756
827
|
let turn: HarnessTurn
|
|
757
828
|
if (args.provider === "claude") {
|
|
829
|
+
logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
|
|
830
|
+
chatId: args.chatId,
|
|
831
|
+
provider: args.provider,
|
|
832
|
+
model: args.model,
|
|
833
|
+
})
|
|
758
834
|
turn = await this.startClaudeTurn({
|
|
759
835
|
chatId: args.chatId,
|
|
760
836
|
localPath: project.localPath,
|
|
@@ -764,7 +840,17 @@ export class AgentCoordinator {
|
|
|
764
840
|
sessionToken: chat.sessionToken,
|
|
765
841
|
onToolRequest,
|
|
766
842
|
})
|
|
843
|
+
logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
|
|
844
|
+
chatId: args.chatId,
|
|
845
|
+
provider: args.provider,
|
|
846
|
+
model: args.model,
|
|
847
|
+
})
|
|
767
848
|
} else {
|
|
849
|
+
logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
|
|
850
|
+
chatId: args.chatId,
|
|
851
|
+
provider: args.provider,
|
|
852
|
+
model: args.model,
|
|
853
|
+
})
|
|
768
854
|
await this.codexManager.startSession({
|
|
769
855
|
chatId: args.chatId,
|
|
770
856
|
cwd: project.localPath,
|
|
@@ -772,6 +858,11 @@ export class AgentCoordinator {
|
|
|
772
858
|
serviceTier: args.serviceTier,
|
|
773
859
|
sessionToken: chat.sessionToken,
|
|
774
860
|
})
|
|
861
|
+
logSendToStartingProfile(args.profile, "start_turn.session_ready", {
|
|
862
|
+
chatId: args.chatId,
|
|
863
|
+
provider: args.provider,
|
|
864
|
+
model: args.model,
|
|
865
|
+
})
|
|
775
866
|
turn = await this.codexManager.startTurn({
|
|
776
867
|
chatId: args.chatId,
|
|
777
868
|
content: buildPromptText(args.content, args.attachments),
|
|
@@ -781,6 +872,11 @@ export class AgentCoordinator {
|
|
|
781
872
|
planMode: args.planMode,
|
|
782
873
|
onToolRequest,
|
|
783
874
|
})
|
|
875
|
+
logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
|
|
876
|
+
chatId: args.chatId,
|
|
877
|
+
provider: args.provider,
|
|
878
|
+
model: args.model,
|
|
879
|
+
})
|
|
784
880
|
}
|
|
785
881
|
|
|
786
882
|
const active: ActiveTurn = {
|
|
@@ -797,9 +893,19 @@ export class AgentCoordinator {
|
|
|
797
893
|
hasFinalResult: false,
|
|
798
894
|
cancelRequested: false,
|
|
799
895
|
cancelRecorded: false,
|
|
896
|
+
clientTraceId: args.profile?.traceId,
|
|
897
|
+
profilingStartedAt: args.profile?.startedAt,
|
|
800
898
|
}
|
|
801
899
|
this.activeTurns.set(args.chatId, active)
|
|
900
|
+
logSendToStartingProfile(args.profile, "start_turn.active_turn_registered", {
|
|
901
|
+
chatId: args.chatId,
|
|
902
|
+
status: active.status,
|
|
903
|
+
})
|
|
802
904
|
this.onStateChange()
|
|
905
|
+
logSendToStartingProfile(args.profile, "start_turn.state_change_emitted", {
|
|
906
|
+
chatId: args.chatId,
|
|
907
|
+
status: active.status,
|
|
908
|
+
})
|
|
803
909
|
|
|
804
910
|
if (turn.getAccountInfo) {
|
|
805
911
|
void turn.getAccountInfo()
|
|
@@ -826,6 +932,9 @@ export class AgentCoordinator {
|
|
|
826
932
|
throw new Error("Claude session was not initialized")
|
|
827
933
|
}
|
|
828
934
|
await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
|
|
935
|
+
logSendToStartingProfile(args.profile, "start_turn.claude_prompt_sent", {
|
|
936
|
+
chatId: args.chatId,
|
|
937
|
+
})
|
|
829
938
|
return
|
|
830
939
|
}
|
|
831
940
|
|
|
@@ -893,14 +1002,26 @@ export class AgentCoordinator {
|
|
|
893
1002
|
}
|
|
894
1003
|
|
|
895
1004
|
async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
|
|
1005
|
+
const profile = command.clientTraceId
|
|
1006
|
+
? { traceId: command.clientTraceId, startedAt: performance.now() }
|
|
1007
|
+
: null
|
|
896
1008
|
let chatId = command.chatId
|
|
897
1009
|
|
|
1010
|
+
logSendToStartingProfile(profile, "chat_send.received", {
|
|
1011
|
+
existingChatId: command.chatId ?? null,
|
|
1012
|
+
projectId: command.projectId ?? null,
|
|
1013
|
+
})
|
|
1014
|
+
|
|
898
1015
|
if (!chatId) {
|
|
899
1016
|
if (!command.projectId) {
|
|
900
1017
|
throw new Error("Missing projectId for new chat")
|
|
901
1018
|
}
|
|
902
1019
|
const created = await this.store.createChat(command.projectId)
|
|
903
1020
|
chatId = created.id
|
|
1021
|
+
logSendToStartingProfile(profile, "chat_send.chat_created", {
|
|
1022
|
+
chatId,
|
|
1023
|
+
projectId: command.projectId,
|
|
1024
|
+
})
|
|
904
1025
|
}
|
|
905
1026
|
|
|
906
1027
|
const chat = this.store.requireChat(chatId)
|
|
@@ -916,6 +1037,13 @@ export class AgentCoordinator {
|
|
|
916
1037
|
serviceTier: settings.serviceTier,
|
|
917
1038
|
planMode: settings.planMode,
|
|
918
1039
|
appendUserPrompt: true,
|
|
1040
|
+
profile,
|
|
1041
|
+
})
|
|
1042
|
+
|
|
1043
|
+
logSendToStartingProfile(profile, "chat_send.ready_for_ack", {
|
|
1044
|
+
chatId,
|
|
1045
|
+
provider,
|
|
1046
|
+
model: settings.model,
|
|
919
1047
|
})
|
|
920
1048
|
|
|
921
1049
|
return { chatId }
|
|
@@ -191,6 +191,86 @@ describe("EventStore", () => {
|
|
|
191
191
|
expect(reloaded.getChat(chat.id)?.unread).toBe(true)
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
+
test("preserves read state after a finished turn across restart", async () => {
|
|
195
|
+
const dataDir = await createTempDataDir()
|
|
196
|
+
const store = new EventStore(dataDir)
|
|
197
|
+
await store.initialize()
|
|
198
|
+
|
|
199
|
+
const project = await store.openProject("/tmp/project")
|
|
200
|
+
const chat = await store.createChat(project.id)
|
|
201
|
+
|
|
202
|
+
await store.recordTurnFinished(chat.id)
|
|
203
|
+
await store.setChatReadState(chat.id, false)
|
|
204
|
+
|
|
205
|
+
expect(store.getChat(chat.id)?.unread).toBe(false)
|
|
206
|
+
|
|
207
|
+
const reloaded = new EventStore(dataDir)
|
|
208
|
+
await reloaded.initialize()
|
|
209
|
+
|
|
210
|
+
expect(reloaded.getChat(chat.id)?.unread).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("preserves read state after a failed turn across restart", async () => {
|
|
214
|
+
const dataDir = await createTempDataDir()
|
|
215
|
+
const store = new EventStore(dataDir)
|
|
216
|
+
await store.initialize()
|
|
217
|
+
|
|
218
|
+
const project = await store.openProject("/tmp/project")
|
|
219
|
+
const chat = await store.createChat(project.id)
|
|
220
|
+
|
|
221
|
+
await store.recordTurnFailed(chat.id, "boom")
|
|
222
|
+
await store.setChatReadState(chat.id, false)
|
|
223
|
+
|
|
224
|
+
expect(store.getChat(chat.id)?.unread).toBe(false)
|
|
225
|
+
|
|
226
|
+
const reloaded = new EventStore(dataDir)
|
|
227
|
+
await reloaded.initialize()
|
|
228
|
+
|
|
229
|
+
expect(reloaded.getChat(chat.id)?.unread).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("prefers mark-read over turn completion when replay timestamps tie", async () => {
|
|
233
|
+
const dataDir = await createTempDataDir()
|
|
234
|
+
const chatsLogPath = join(dataDir, "chats.jsonl")
|
|
235
|
+
const turnsLogPath = join(dataDir, "turns.jsonl")
|
|
236
|
+
const projectId = "project-1"
|
|
237
|
+
const chatId = "chat-1"
|
|
238
|
+
const timestamp = 100
|
|
239
|
+
|
|
240
|
+
await writeFile(chatsLogPath, [
|
|
241
|
+
JSON.stringify({
|
|
242
|
+
v: 2,
|
|
243
|
+
type: "chat_created",
|
|
244
|
+
timestamp,
|
|
245
|
+
chatId,
|
|
246
|
+
projectId,
|
|
247
|
+
title: "Chat",
|
|
248
|
+
}),
|
|
249
|
+
JSON.stringify({
|
|
250
|
+
v: 2,
|
|
251
|
+
type: "chat_read_state_set",
|
|
252
|
+
timestamp,
|
|
253
|
+
chatId,
|
|
254
|
+
unread: false,
|
|
255
|
+
}),
|
|
256
|
+
"",
|
|
257
|
+
].join("\n"), "utf8")
|
|
258
|
+
await writeFile(turnsLogPath, [
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
v: 2,
|
|
261
|
+
type: "turn_finished",
|
|
262
|
+
timestamp,
|
|
263
|
+
chatId,
|
|
264
|
+
}),
|
|
265
|
+
"",
|
|
266
|
+
].join("\n"), "utf8")
|
|
267
|
+
|
|
268
|
+
const store = new EventStore(dataDir)
|
|
269
|
+
await store.initialize()
|
|
270
|
+
|
|
271
|
+
expect(store.getChat(chatId)?.unread).toBe(false)
|
|
272
|
+
})
|
|
273
|
+
|
|
194
274
|
test("loads chats without unread from older snapshots as read", async () => {
|
|
195
275
|
const dataDir = await createTempDataDir()
|
|
196
276
|
const snapshotPath = join(dataDir, "snapshot.json")
|
|
@@ -226,14 +306,14 @@ describe("EventStore", () => {
|
|
|
226
306
|
expect(store.getChat("chat-1")?.unread).toBe(false)
|
|
227
307
|
})
|
|
228
308
|
|
|
229
|
-
test("prunes stale empty chats after
|
|
309
|
+
test("prunes stale empty chats after thirty minutes", async () => {
|
|
230
310
|
const dataDir = await createTempDataDir()
|
|
231
311
|
const store = new EventStore(dataDir)
|
|
232
312
|
await store.initialize()
|
|
233
313
|
|
|
234
314
|
const project = await store.openProject("/tmp/project")
|
|
235
315
|
const chat = await store.createChat(project.id)
|
|
236
|
-
const staleNow = chat.createdAt +
|
|
316
|
+
const staleNow = chat.createdAt + 30 * 60 * 1000
|
|
237
317
|
|
|
238
318
|
const pruned = await store.pruneStaleEmptyChats({ now: staleNow })
|
|
239
319
|
|
|
@@ -248,7 +328,7 @@ describe("EventStore", () => {
|
|
|
248
328
|
|
|
249
329
|
const project = await store.openProject("/tmp/project")
|
|
250
330
|
const chat = await store.createChat(project.id)
|
|
251
|
-
const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt +
|
|
331
|
+
const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 - 1 })
|
|
252
332
|
|
|
253
333
|
expect(pruned).toEqual([])
|
|
254
334
|
expect(store.getChat(chat.id)?.id).toBe(chat.id)
|
|
@@ -263,7 +343,7 @@ describe("EventStore", () => {
|
|
|
263
343
|
const chat = await store.createChat(project.id)
|
|
264
344
|
await store.appendMessage(chat.id, entry("user_prompt", chat.createdAt + 1, { content: "hello" }))
|
|
265
345
|
|
|
266
|
-
const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt +
|
|
346
|
+
const pruned = await store.pruneStaleEmptyChats({ now: chat.createdAt + 30 * 60 * 1000 })
|
|
267
347
|
|
|
268
348
|
expect(pruned).toEqual([])
|
|
269
349
|
expect(store.getChat(chat.id)?.id).toBe(chat.id)
|
|
@@ -278,11 +358,28 @@ describe("EventStore", () => {
|
|
|
278
358
|
const chat = await store.createChat(project.id)
|
|
279
359
|
|
|
280
360
|
const pruned = await store.pruneStaleEmptyChats({
|
|
281
|
-
now: chat.createdAt +
|
|
361
|
+
now: chat.createdAt + 30 * 60 * 1000,
|
|
282
362
|
activeChatIds: [chat.id],
|
|
283
363
|
})
|
|
284
364
|
|
|
285
365
|
expect(pruned).toEqual([])
|
|
286
366
|
expect(store.getChat(chat.id)?.id).toBe(chat.id)
|
|
287
367
|
})
|
|
368
|
+
|
|
369
|
+
test("does not prune stale chats with protected draft state", async () => {
|
|
370
|
+
const dataDir = await createTempDataDir()
|
|
371
|
+
const store = new EventStore(dataDir)
|
|
372
|
+
await store.initialize()
|
|
373
|
+
|
|
374
|
+
const project = await store.openProject("/tmp/project")
|
|
375
|
+
const chat = await store.createChat(project.id)
|
|
376
|
+
|
|
377
|
+
const pruned = await store.pruneStaleEmptyChats({
|
|
378
|
+
now: chat.createdAt + 30 * 60 * 1000,
|
|
379
|
+
protectedChatIds: [chat.id],
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
expect(pruned).toEqual([])
|
|
383
|
+
expect(store.getChat(chat.id)?.id).toBe(chat.id)
|
|
384
|
+
})
|
|
288
385
|
})
|
|
@@ -7,7 +7,6 @@ import type { AgentProvider, ChatHistoryPage, ChatHistorySnapshot, TranscriptEnt
|
|
|
7
7
|
import { STORE_VERSION } from "../shared/types"
|
|
8
8
|
import {
|
|
9
9
|
type ChatEvent,
|
|
10
|
-
type MessageEvent,
|
|
11
10
|
type ProjectEvent,
|
|
12
11
|
type SnapshotFile,
|
|
13
12
|
type StoreEvent,
|
|
@@ -19,7 +18,7 @@ import {
|
|
|
19
18
|
import { resolveLocalPath } from "./paths"
|
|
20
19
|
|
|
21
20
|
const COMPACTION_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
22
|
-
const STALE_EMPTY_CHAT_MAX_AGE_MS =
|
|
21
|
+
const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
|
|
23
22
|
interface LegacyTranscriptStats {
|
|
24
23
|
hasLegacyData: boolean
|
|
25
24
|
sources: Array<"snapshot" | "messages_log">
|
|
@@ -33,6 +32,41 @@ interface TranscriptPageResult {
|
|
|
33
32
|
olderCursor: string | null
|
|
34
33
|
}
|
|
35
34
|
|
|
35
|
+
interface ParsedReplayEvent {
|
|
36
|
+
event: StoreEvent
|
|
37
|
+
sourceIndex: number
|
|
38
|
+
lineIndex: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getReplayEventPriority(event: StoreEvent) {
|
|
42
|
+
switch (event.type) {
|
|
43
|
+
case "project_opened":
|
|
44
|
+
case "project_removed":
|
|
45
|
+
return 0
|
|
46
|
+
case "chat_created":
|
|
47
|
+
return 1
|
|
48
|
+
case "chat_renamed":
|
|
49
|
+
case "chat_provider_set":
|
|
50
|
+
case "chat_plan_mode_set":
|
|
51
|
+
return 2
|
|
52
|
+
case "message_appended":
|
|
53
|
+
return 3
|
|
54
|
+
case "turn_started":
|
|
55
|
+
return 4
|
|
56
|
+
case "session_token_set":
|
|
57
|
+
return 5
|
|
58
|
+
case "turn_cancelled":
|
|
59
|
+
return 6
|
|
60
|
+
case "turn_finished":
|
|
61
|
+
case "turn_failed":
|
|
62
|
+
return 7
|
|
63
|
+
case "chat_read_state_set":
|
|
64
|
+
return 8
|
|
65
|
+
case "chat_deleted":
|
|
66
|
+
return 9
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
function encodeHistoryCursor(index: number) {
|
|
37
71
|
return `idx:${index}`
|
|
38
72
|
}
|
|
@@ -166,21 +200,33 @@ export class EventStore {
|
|
|
166
200
|
|
|
167
201
|
private async replayLogs() {
|
|
168
202
|
if (this.storageReset) return
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
203
|
+
const replayEvents = [
|
|
204
|
+
...await this.loadReplayEvents(this.projectsLogPath, 0),
|
|
205
|
+
...await this.loadReplayEvents(this.chatsLogPath, 1),
|
|
206
|
+
...await this.loadReplayEvents(this.messagesLogPath, 2),
|
|
207
|
+
...await this.loadReplayEvents(this.turnsLogPath, 3),
|
|
208
|
+
]
|
|
174
209
|
if (this.storageReset) return
|
|
175
|
-
|
|
210
|
+
|
|
211
|
+
replayEvents
|
|
212
|
+
.sort((left, right) => (
|
|
213
|
+
left.event.timestamp - right.event.timestamp
|
|
214
|
+
|| getReplayEventPriority(left.event) - getReplayEventPriority(right.event)
|
|
215
|
+
|| left.sourceIndex - right.sourceIndex
|
|
216
|
+
|| left.lineIndex - right.lineIndex
|
|
217
|
+
))
|
|
218
|
+
.forEach(({ event }) => {
|
|
219
|
+
this.applyEvent(event)
|
|
220
|
+
})
|
|
176
221
|
}
|
|
177
222
|
|
|
178
|
-
private async
|
|
223
|
+
private async loadReplayEvents(filePath: string, sourceIndex: number): Promise<ParsedReplayEvent[]> {
|
|
179
224
|
const file = Bun.file(filePath)
|
|
180
|
-
if (!(await file.exists())) return
|
|
225
|
+
if (!(await file.exists())) return []
|
|
181
226
|
const text = await file.text()
|
|
182
|
-
if (!text.trim()) return
|
|
227
|
+
if (!text.trim()) return []
|
|
183
228
|
|
|
229
|
+
const parsedEvents: ParsedReplayEvent[] = []
|
|
184
230
|
const lines = text.split("\n")
|
|
185
231
|
let lastNonEmpty = -1
|
|
186
232
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
@@ -198,19 +244,25 @@ export class EventStore {
|
|
|
198
244
|
if (event.v !== STORE_VERSION) {
|
|
199
245
|
console.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
|
|
200
246
|
await this.clearStorage()
|
|
201
|
-
return
|
|
247
|
+
return []
|
|
202
248
|
}
|
|
203
|
-
|
|
249
|
+
parsedEvents.push({
|
|
250
|
+
event: event as StoreEvent,
|
|
251
|
+
sourceIndex,
|
|
252
|
+
lineIndex: index,
|
|
253
|
+
})
|
|
204
254
|
} catch (error) {
|
|
205
255
|
if (index === lastNonEmpty) {
|
|
206
256
|
console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
|
|
207
|
-
return
|
|
257
|
+
return parsedEvents
|
|
208
258
|
}
|
|
209
259
|
console.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, error)
|
|
210
260
|
await this.clearStorage()
|
|
211
|
-
return
|
|
261
|
+
return []
|
|
212
262
|
}
|
|
213
263
|
}
|
|
264
|
+
|
|
265
|
+
return parsedEvents
|
|
214
266
|
}
|
|
215
267
|
|
|
216
268
|
private applyEvent(event: StoreEvent) {
|
|
@@ -459,14 +511,18 @@ export class EventStore {
|
|
|
459
511
|
now?: number
|
|
460
512
|
maxAgeMs?: number
|
|
461
513
|
activeChatIds?: Iterable<string>
|
|
514
|
+
protectedChatIds?: Iterable<string>
|
|
462
515
|
}) {
|
|
463
516
|
const now = args?.now ?? Date.now()
|
|
464
517
|
const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
|
|
465
|
-
const
|
|
518
|
+
const protectedChatIds = new Set([
|
|
519
|
+
...(args?.activeChatIds ?? []),
|
|
520
|
+
...(args?.protectedChatIds ?? []),
|
|
521
|
+
])
|
|
466
522
|
const prunedChatIds: string[] = []
|
|
467
523
|
|
|
468
524
|
for (const chat of this.state.chatsById.values()) {
|
|
469
|
-
if (chat.deletedAt ||
|
|
525
|
+
if (chat.deletedAt || protectedChatIds.has(chat.id)) continue
|
|
470
526
|
if (now - chat.createdAt < maxAgeMs) continue
|
|
471
527
|
if (this.getMessages(chat.id).length > 0) continue
|
|
472
528
|
|
|
@@ -8,6 +8,7 @@ class FakeWebSocket {
|
|
|
8
8
|
readonly sent: unknown[] = []
|
|
9
9
|
readonly data = {
|
|
10
10
|
subscriptions: new Map(),
|
|
11
|
+
protectedDraftChatIds: new Set<string>(),
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
send(message: string) {
|
|
@@ -742,6 +743,85 @@ describe("ws-router", () => {
|
|
|
742
743
|
})
|
|
743
744
|
})
|
|
744
745
|
|
|
746
|
+
test("protects draft-bearing chats from stale pruning before sidebar snapshots", async () => {
|
|
747
|
+
const state = createEmptyState()
|
|
748
|
+
state.projectsById.set("project-1", {
|
|
749
|
+
id: "project-1",
|
|
750
|
+
localPath: "/tmp/project",
|
|
751
|
+
title: "Project",
|
|
752
|
+
createdAt: 1,
|
|
753
|
+
updatedAt: 1,
|
|
754
|
+
})
|
|
755
|
+
state.projectIdsByPath.set("/tmp/project", "project-1")
|
|
756
|
+
state.chatsById.set("chat-stale", {
|
|
757
|
+
id: "chat-stale",
|
|
758
|
+
projectId: "project-1",
|
|
759
|
+
title: "New Chat",
|
|
760
|
+
createdAt: 1,
|
|
761
|
+
updatedAt: 1,
|
|
762
|
+
unread: false,
|
|
763
|
+
provider: null,
|
|
764
|
+
planMode: false,
|
|
765
|
+
sessionToken: null,
|
|
766
|
+
lastTurnOutcome: null,
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
let capturedProtectedChatIds: string[] = []
|
|
770
|
+
const router = createWsRouter({
|
|
771
|
+
store: {
|
|
772
|
+
state,
|
|
773
|
+
async pruneStaleEmptyChats(args?: { protectedChatIds?: Iterable<string> }) {
|
|
774
|
+
capturedProtectedChatIds = [...(args?.protectedChatIds ?? [])]
|
|
775
|
+
return []
|
|
776
|
+
},
|
|
777
|
+
} as never,
|
|
778
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
779
|
+
terminals: {
|
|
780
|
+
getSnapshot: () => null,
|
|
781
|
+
onEvent: () => () => {},
|
|
782
|
+
} as never,
|
|
783
|
+
keybindings: {
|
|
784
|
+
getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
|
|
785
|
+
onChange: () => () => {},
|
|
786
|
+
} as never,
|
|
787
|
+
refreshDiscovery: async () => [],
|
|
788
|
+
getDiscoveredProjects: () => [],
|
|
789
|
+
machineDisplayName: "Local Machine",
|
|
790
|
+
updateManager: null,
|
|
791
|
+
})
|
|
792
|
+
const ws = new FakeWebSocket()
|
|
793
|
+
|
|
794
|
+
await router.handleMessage(
|
|
795
|
+
ws as never,
|
|
796
|
+
JSON.stringify({
|
|
797
|
+
v: 1,
|
|
798
|
+
type: "command",
|
|
799
|
+
id: "draft-protection-1",
|
|
800
|
+
command: {
|
|
801
|
+
type: "chat.setDraftProtection",
|
|
802
|
+
chatIds: ["chat-stale"],
|
|
803
|
+
},
|
|
804
|
+
})
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
await router.handleMessage(
|
|
808
|
+
ws as never,
|
|
809
|
+
JSON.stringify({
|
|
810
|
+
v: 1,
|
|
811
|
+
type: "subscribe",
|
|
812
|
+
id: "sidebar-sub-1",
|
|
813
|
+
topic: { type: "sidebar" },
|
|
814
|
+
})
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
expect(capturedProtectedChatIds).toEqual(["chat-stale"])
|
|
818
|
+
expect(ws.sent[0]).toEqual({
|
|
819
|
+
v: PROTOCOL_VERSION,
|
|
820
|
+
type: "ack",
|
|
821
|
+
id: "draft-protection-1",
|
|
822
|
+
})
|
|
823
|
+
})
|
|
824
|
+
|
|
745
825
|
test("broadcasts background title-generation errors to connected clients", () => {
|
|
746
826
|
let reportBackgroundError: ((message: string) => void) | null | undefined
|
|
747
827
|
const router = createWsRouter({
|