kanna-code 0.26.8 → 0.26.10

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.
@@ -37,6 +37,53 @@ function logSendToStartingProfile(
37
37
  }))
38
38
  }
39
39
 
40
+ function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>) {
41
+ let sidebar = 0
42
+ let chat = 0
43
+ let projectGit = 0
44
+ let localProjects = 0
45
+ let update = 0
46
+ let keybindings = 0
47
+ let terminal = 0
48
+
49
+ for (const topic of ws.data.subscriptions.values()) {
50
+ switch (topic.type) {
51
+ case "sidebar":
52
+ sidebar += 1
53
+ break
54
+ case "chat":
55
+ chat += 1
56
+ break
57
+ case "project-git":
58
+ projectGit += 1
59
+ break
60
+ case "local-projects":
61
+ localProjects += 1
62
+ break
63
+ case "update":
64
+ update += 1
65
+ break
66
+ case "keybindings":
67
+ keybindings += 1
68
+ break
69
+ case "terminal":
70
+ terminal += 1
71
+ break
72
+ }
73
+ }
74
+
75
+ return {
76
+ total: ws.data.subscriptions.size,
77
+ sidebar,
78
+ chat,
79
+ projectGit,
80
+ localProjects,
81
+ update,
82
+ keybindings,
83
+ terminal,
84
+ }
85
+ }
86
+
40
87
  export interface ClientState {
41
88
  subscriptions: Map<string, SubscriptionTopic>
42
89
  snapshotSignatures: Map<string, string>
@@ -55,6 +102,16 @@ interface CreateWsRouterArgs {
55
102
  updateManager: UpdateManager | null
56
103
  }
57
104
 
105
+ interface SnapshotBroadcastFilter {
106
+ includeSidebar?: boolean
107
+ includeLocalProjects?: boolean
108
+ includeUpdate?: boolean
109
+ includeKeybindings?: boolean
110
+ chatIds?: Set<string>
111
+ projectIds?: Set<string>
112
+ terminalIds?: Set<string>
113
+ }
114
+
58
115
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
59
116
  const payload = JSON.stringify(message)
60
117
  ws.send(payload)
@@ -81,6 +138,9 @@ export function createWsRouter({
81
138
  updateManager,
82
139
  }: CreateWsRouterArgs) {
83
140
  const sockets = new Set<ServerWebSocket<ClientState>>()
141
+ let pendingBroadcastTimer: ReturnType<typeof setTimeout> | null = null
142
+ let pendingBroadcastAll = false
143
+ const pendingBroadcastChatIds = new Set<string>()
84
144
  const resolvedDiffStore = diffStore ?? {
85
145
  getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, hasOriginRemote: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
86
146
  refreshSnapshot: async () => false,
@@ -102,9 +162,13 @@ export function createWsRouter({
102
162
  }
103
163
 
104
164
  function getProtectedChatIds() {
165
+ const activeStatuses = agent.getActiveStatuses()
166
+ const drainingChatIds = typeof agent.getDrainingChatIds === "function"
167
+ ? agent.getDrainingChatIds()
168
+ : new Set<string>()
105
169
  return new Set([
106
- ...agent.getActiveStatuses().keys(),
107
- ...agent.getDrainingChatIds().values(),
170
+ ...activeStatuses.keys(),
171
+ ...drainingChatIds.values(),
108
172
  ])
109
173
  }
110
174
 
@@ -127,21 +191,78 @@ export function createWsRouter({
127
191
  }
128
192
 
129
193
  async function maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
130
- await store.pruneStaleEmptyChats?.({
131
- activeChatIds: getProtectedChatIds(),
132
- protectedChatIds: getProtectedDraftChatIds(extraSockets),
194
+ const startedAt = performance.now()
195
+ const activeChatIds = getProtectedChatIds()
196
+ const protectedDraftChatIds = getProtectedDraftChatIds(extraSockets)
197
+ const prunedChatIds = await store.pruneStaleEmptyChats?.({
198
+ activeChatIds,
199
+ protectedChatIds: protectedDraftChatIds,
133
200
  })
201
+ if (isSendToStartingProfilingEnabled()) {
202
+ console.log("[kanna/send->starting][server]", JSON.stringify({
203
+ stage: "ws.prune_stale_empty_chats",
204
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
205
+ activeChatCount: activeChatIds.size,
206
+ protectedDraftChatCount: protectedDraftChatIds.size,
207
+ prunedCount: prunedChatIds?.length ?? 0,
208
+ totalChatCount: store.state.chatsById.size,
209
+ totalProjectCount: store.state.projectsById.size,
210
+ }))
211
+ }
212
+ }
213
+
214
+ function shouldIncludeTopic(topic: SubscriptionTopic, filter?: SnapshotBroadcastFilter) {
215
+ if (!filter) {
216
+ return true
217
+ }
218
+
219
+ if (topic.type === "sidebar") {
220
+ return Boolean(filter.includeSidebar)
221
+ }
222
+ if (topic.type === "local-projects") {
223
+ return Boolean(filter.includeLocalProjects)
224
+ }
225
+ if (topic.type === "update") {
226
+ return Boolean(filter.includeUpdate)
227
+ }
228
+ if (topic.type === "keybindings") {
229
+ return Boolean(filter.includeKeybindings)
230
+ }
231
+ if (topic.type === "chat") {
232
+ return filter.chatIds?.has(topic.chatId) ?? false
233
+ }
234
+ if (topic.type === "project-git") {
235
+ return filter.projectIds?.has(topic.projectId) ?? false
236
+ }
237
+ if (topic.type === "terminal") {
238
+ return filter.terminalIds?.has(topic.terminalId) ?? false
239
+ }
240
+
241
+ return true
134
242
  }
135
243
 
136
244
  function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
137
245
  if (topic.type === "sidebar") {
246
+ const startedAt = performance.now()
247
+ const data = deriveSidebarData(store.state, agent.getActiveStatuses())
248
+ if (isSendToStartingProfilingEnabled()) {
249
+ const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
250
+ console.log("[kanna/send->starting][server]", JSON.stringify({
251
+ stage: "ws.sidebar_snapshot_built",
252
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
253
+ projectGroupCount: data.projectGroups.length,
254
+ chatCount: totalChats,
255
+ totalChatCount: store.state.chatsById.size,
256
+ totalProjectCount: store.state.projectsById.size,
257
+ }))
258
+ }
138
259
  return {
139
260
  v: PROTOCOL_VERSION,
140
261
  type: "snapshot",
141
262
  id,
142
263
  snapshot: {
143
264
  type: "sidebar",
144
- data: deriveSidebarData(store.state, agent.getActiveStatuses()),
265
+ data,
145
266
  },
146
267
  }
147
268
  }
@@ -236,12 +357,21 @@ export function createWsRouter({
236
357
  }
237
358
  }
238
359
 
239
- async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
360
+ async function pushSnapshots(
361
+ ws: ServerWebSocket<ClientState>,
362
+ options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
363
+ ) {
364
+ const pushStartedAt = performance.now()
240
365
  if (!options?.skipPrune) {
241
366
  await maybePruneStaleEmptyChats([ws])
242
367
  }
243
368
  const snapshotSignatures = ensureSnapshotSignatures(ws)
369
+ let sentCount = 0
370
+ let skippedCount = 0
244
371
  for (const [id, topic] of ws.data.subscriptions.entries()) {
372
+ if (!shouldIncludeTopic(topic, options?.filter)) {
373
+ continue
374
+ }
245
375
  const envelopeStartedAt = performance.now()
246
376
  const envelope = createEnvelope(id, topic)
247
377
  const createdAt = performance.now()
@@ -249,6 +379,7 @@ export function createWsRouter({
249
379
  const signature = JSON.stringify(envelope.snapshot)
250
380
  const signatureReadyAt = performance.now()
251
381
  if (snapshotSignatures.get(id) === signature) {
382
+ skippedCount += 1
252
383
  continue
253
384
  }
254
385
  snapshotSignatures.set(id, signature)
@@ -264,6 +395,7 @@ export function createWsRouter({
264
395
  })
265
396
  }
266
397
  const payloadBytes = send(ws, envelope)
398
+ sentCount += 1
267
399
  if (topic.type === "chat" && envelope.snapshot.type === "chat" && envelope.snapshot.data?.runtime.status === "starting") {
268
400
  const profile = agent.getActiveTurnProfile(topic.chatId)
269
401
  logSendToStartingProfile(profile?.traceId, profile?.startedAt, "ws.snapshot_send_completed", {
@@ -272,13 +404,112 @@ export function createWsRouter({
272
404
  })
273
405
  }
274
406
  }
407
+ if (isSendToStartingProfilingEnabled()) {
408
+ console.log("[kanna/send->starting][server]", JSON.stringify({
409
+ stage: "ws.push_snapshots_completed",
410
+ elapsedMs: Number((performance.now() - pushStartedAt).toFixed(1)),
411
+ skipPrune: Boolean(options?.skipPrune),
412
+ sentCount,
413
+ skippedCount,
414
+ ...countSubscriptionsByTopic(ws),
415
+ }))
416
+ }
275
417
  }
276
418
 
277
419
  async function broadcastSnapshots() {
278
- await maybePruneStaleEmptyChats()
420
+ const startedAt = performance.now()
421
+ let socketCount = 0
279
422
  for (const ws of sockets) {
423
+ socketCount += 1
280
424
  await pushSnapshots(ws, { skipPrune: true })
281
425
  }
426
+ if (isSendToStartingProfilingEnabled()) {
427
+ console.log("[kanna/send->starting][server]", JSON.stringify({
428
+ stage: "ws.broadcast_snapshots_completed",
429
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
430
+ pruneMs: 0,
431
+ socketCount,
432
+ totalChatCount: store.state.chatsById.size,
433
+ totalProjectCount: store.state.projectsById.size,
434
+ }))
435
+ }
436
+ }
437
+
438
+ async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
439
+ const startedAt = performance.now()
440
+ let socketCount = 0
441
+ for (const ws of sockets) {
442
+ socketCount += 1
443
+ await pushSnapshots(ws, { skipPrune: true, filter })
444
+ }
445
+ if (isSendToStartingProfilingEnabled()) {
446
+ console.log("[kanna/send->starting][server]", JSON.stringify({
447
+ stage: "ws.broadcast_filtered_snapshots_completed",
448
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
449
+ socketCount,
450
+ includeSidebar: Boolean(filter.includeSidebar),
451
+ chatCount: filter.chatIds?.size ?? 0,
452
+ projectCount: filter.projectIds?.size ?? 0,
453
+ }))
454
+ }
455
+ }
456
+
457
+ function scheduleBroadcast() {
458
+ pendingBroadcastAll = true
459
+ pendingBroadcastChatIds.clear()
460
+ if (pendingBroadcastTimer) {
461
+ return
462
+ }
463
+ pendingBroadcastTimer = setTimeout(() => {
464
+ pendingBroadcastTimer = null
465
+ const shouldBroadcastAll = pendingBroadcastAll
466
+ const chatIds = new Set(pendingBroadcastChatIds)
467
+ pendingBroadcastAll = false
468
+ pendingBroadcastChatIds.clear()
469
+ if (shouldBroadcastAll) {
470
+ void broadcastSnapshots()
471
+ return
472
+ }
473
+ if (chatIds.size > 0) {
474
+ void broadcastFilteredSnapshots({
475
+ includeSidebar: true,
476
+ chatIds,
477
+ })
478
+ }
479
+ }, 16)
480
+ }
481
+
482
+ function scheduleChatStateBroadcast(chatId: string) {
483
+ if (!pendingBroadcastAll) {
484
+ pendingBroadcastChatIds.add(chatId)
485
+ }
486
+ if (pendingBroadcastTimer) {
487
+ return
488
+ }
489
+ pendingBroadcastTimer = setTimeout(() => {
490
+ pendingBroadcastTimer = null
491
+ const shouldBroadcastAll = pendingBroadcastAll
492
+ const chatIds = new Set(pendingBroadcastChatIds)
493
+ pendingBroadcastAll = false
494
+ pendingBroadcastChatIds.clear()
495
+ if (shouldBroadcastAll) {
496
+ void broadcastSnapshots()
497
+ return
498
+ }
499
+ if (chatIds.size > 0) {
500
+ void broadcastFilteredSnapshots({
501
+ includeSidebar: true,
502
+ chatIds,
503
+ })
504
+ }
505
+ }, 16)
506
+ }
507
+
508
+ async function broadcastChatAndSidebar(chatId: string) {
509
+ await broadcastFilteredSnapshots({
510
+ includeSidebar: true,
511
+ chatIds: new Set([chatId]),
512
+ })
282
513
  }
283
514
 
284
515
  function broadcastError(message: string) {
@@ -456,24 +687,28 @@ export function createWsRouter({
456
687
  case "chat.create": {
457
688
  const chat = await store.createChat(command.projectId)
458
689
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
459
- break
690
+ await broadcastChatAndSidebar(chat.id)
691
+ return
460
692
  }
461
693
  case "chat.rename": {
462
694
  await store.renameChat(command.chatId, command.title)
463
695
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
464
- break
696
+ await broadcastChatAndSidebar(command.chatId)
697
+ return
465
698
  }
466
699
  case "chat.delete": {
467
700
  await agent.cancel(command.chatId)
468
701
  await agent.closeChat(command.chatId)
469
702
  await store.deleteChat(command.chatId)
470
703
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
471
- break
704
+ await broadcastFilteredSnapshots({ includeSidebar: true })
705
+ return
472
706
  }
473
707
  case "chat.markRead": {
474
708
  await store.setChatReadState(command.chatId, false)
475
709
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
476
- break
710
+ await broadcastChatAndSidebar(command.chatId)
711
+ return
477
712
  }
478
713
  case "chat.setDraftProtection": {
479
714
  ws.data.protectedDraftChatIds = new Set(command.chatIds)
@@ -493,7 +728,7 @@ export function createWsRouter({
493
728
  chatId: result.chatId ?? null,
494
729
  payloadBytes,
495
730
  })
496
- break
731
+ return
497
732
  }
498
733
  case "chat.refreshDiffs": {
499
734
  const { project } = resolveChatProject(command.chatId)
@@ -673,12 +908,12 @@ export function createWsRouter({
673
908
  case "chat.cancel": {
674
909
  await agent.cancel(command.chatId)
675
910
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
676
- break
911
+ return
677
912
  }
678
913
  case "chat.stopDraining": {
679
914
  await agent.stopDraining(command.chatId)
680
915
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
681
- break
916
+ return
682
917
  }
683
918
  case "chat.loadHistory": {
684
919
  const chat = store.getChat(command.chatId)
@@ -690,7 +925,7 @@ export function createWsRouter({
690
925
  case "chat.respondTool": {
691
926
  await agent.respondTool(command)
692
927
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
693
- break
928
+ return
694
929
  }
695
930
  case "terminal.create": {
696
931
  const project = store.getProject(command.projectId)
@@ -745,6 +980,9 @@ export function createWsRouter({
745
980
  sockets.delete(ws)
746
981
  },
747
982
  broadcastSnapshots,
983
+ scheduleBroadcast,
984
+ scheduleChatStateBroadcast,
985
+ pruneStaleEmptyChats: () => maybePruneStaleEmptyChats(),
748
986
  async handleMessage(ws: ServerWebSocket<ClientState>, raw: string | Buffer | ArrayBuffer | Uint8Array) {
749
987
  let parsed: unknown
750
988
  try {
@@ -766,12 +1004,12 @@ export function createWsRouter({
766
1004
  if (parsed.topic.type === "local-projects") {
767
1005
  void refreshDiscovery().then(() => {
768
1006
  if (ws.data.subscriptions.has(parsed.id)) {
769
- void pushSnapshots(ws)
1007
+ void pushSnapshots(ws, { skipPrune: true })
770
1008
  }
771
1009
  })
772
1010
  return
773
1011
  }
774
- await pushSnapshots(ws)
1012
+ await pushSnapshots(ws, { skipPrune: true })
775
1013
  return
776
1014
  }
777
1015
 
@@ -786,6 +1024,9 @@ export function createWsRouter({
786
1024
  await handleCommand(ws, parsed)
787
1025
  },
788
1026
  dispose() {
1027
+ if (pendingBroadcastTimer) {
1028
+ clearTimeout(pendingBroadcastTimer)
1029
+ }
789
1030
  agent.setBackgroundErrorReporter?.(null)
790
1031
  disposeTerminalEvents()
791
1032
  disposeKeybindingEvents()