kanna-code 0.26.4 → 0.26.6
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-DDI3pFzk.js → index-CcdP0kaK.js} +195 -195
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/diff-store.test.ts +46 -0
- package/src/server/diff-store.ts +29 -2
- 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 +28 -3
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.ts +1 -0
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-CcdP0kaK.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-C5RBxQW4.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -148,6 +148,52 @@ describe("DiffStore", () => {
|
|
|
148
148
|
expect((await run(["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], repoRoot)).trim()).toBe("origin/feature/publish-me")
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
+
test("commit_and_push degrades to a local commit when origin is missing", async () => {
|
|
152
|
+
const repoRoot = await createRepo()
|
|
153
|
+
tempDirs.push(repoRoot)
|
|
154
|
+
await writeFile(path.join(repoRoot, "app.txt"), "base\n", "utf8")
|
|
155
|
+
await run(["git", "add", "."], repoRoot)
|
|
156
|
+
await run(["git", "commit", "-m", "init"], repoRoot)
|
|
157
|
+
await writeFile(path.join(repoRoot, "app.txt"), "changed\n", "utf8")
|
|
158
|
+
|
|
159
|
+
const store = new DiffStore(repoRoot)
|
|
160
|
+
await store.initialize()
|
|
161
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
162
|
+
|
|
163
|
+
const result = await store.commitFiles({
|
|
164
|
+
projectId: "project-1",
|
|
165
|
+
projectPath: repoRoot,
|
|
166
|
+
paths: ["app.txt"],
|
|
167
|
+
summary: "Local only",
|
|
168
|
+
mode: "commit_and_push",
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(result).toMatchObject({
|
|
172
|
+
ok: true,
|
|
173
|
+
mode: "commit_and_push",
|
|
174
|
+
pushed: false,
|
|
175
|
+
})
|
|
176
|
+
expect((await run(["git", "log", "-1", "--pretty=%s"], repoRoot)).trim()).toBe("Local only")
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("refreshSnapshot reports origin presence before the first commit", async () => {
|
|
180
|
+
const repoRoot = await createRepo()
|
|
181
|
+
tempDirs.push(repoRoot)
|
|
182
|
+
await run(["git", "remote", "add", "origin", "https://github.com/jakemor/test224.git"], repoRoot)
|
|
183
|
+
await writeFile(path.join(repoRoot, "poem.md"), "rose\n", "utf8")
|
|
184
|
+
|
|
185
|
+
const store = new DiffStore(repoRoot)
|
|
186
|
+
await store.initialize()
|
|
187
|
+
await store.refreshSnapshot("project-1", repoRoot)
|
|
188
|
+
|
|
189
|
+
expect(store.getProjectSnapshot("project-1")).toMatchObject({
|
|
190
|
+
status: "ready",
|
|
191
|
+
branchName: "main",
|
|
192
|
+
hasOriginRemote: true,
|
|
193
|
+
originRepoSlug: "jakemor/test224",
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
151
197
|
test("detects renamed files", async () => {
|
|
152
198
|
const repoRoot = await createRepo()
|
|
153
199
|
tempDirs.push(repoRoot)
|
package/src/server/diff-store.ts
CHANGED
|
@@ -37,6 +37,7 @@ function createEmptyState(): StoredChatDiffState {
|
|
|
37
37
|
status: "unknown",
|
|
38
38
|
branchName: undefined,
|
|
39
39
|
defaultBranchName: undefined,
|
|
40
|
+
hasOriginRemote: undefined,
|
|
40
41
|
originRepoSlug: undefined,
|
|
41
42
|
hasUpstream: undefined,
|
|
42
43
|
aheadCount: undefined,
|
|
@@ -50,6 +51,7 @@ function createEmptyState(): StoredChatDiffState {
|
|
|
50
51
|
function branchMetadataEqual(left: BranchMetadata, right: BranchMetadata) {
|
|
51
52
|
return left.branchName === right.branchName
|
|
52
53
|
&& left.defaultBranchName === right.defaultBranchName
|
|
54
|
+
&& left.hasOriginRemote === right.hasOriginRemote
|
|
53
55
|
&& left.originRepoSlug === right.originRepoSlug
|
|
54
56
|
&& left.hasUpstream === right.hasUpstream
|
|
55
57
|
}
|
|
@@ -196,6 +198,9 @@ function createPushFailure(mode: DiffCommitMode, detail: string, snapshotChanged
|
|
|
196
198
|
if (normalized.includes("non-fast-forward") || normalized.includes("fetch first")) {
|
|
197
199
|
title = "Branch is not up to date"
|
|
198
200
|
message = "Your branch is behind its remote. Pull or rebase, then try pushing again."
|
|
201
|
+
} else if (normalized.includes("does not appear to be a git repository")) {
|
|
202
|
+
title = "No origin remote configured"
|
|
203
|
+
message = "This repository does not have an origin remote configured."
|
|
199
204
|
} else if (normalized.includes("has no upstream branch") || normalized.includes("set-upstream")) {
|
|
200
205
|
title = "No upstream branch configured"
|
|
201
206
|
message = "This branch does not have an upstream remote branch configured yet."
|
|
@@ -1317,6 +1322,7 @@ export class DiffStore {
|
|
|
1317
1322
|
status: state.status,
|
|
1318
1323
|
branchName: state.branchName,
|
|
1319
1324
|
defaultBranchName: state.defaultBranchName,
|
|
1325
|
+
hasOriginRemote: state.hasOriginRemote,
|
|
1320
1326
|
originRepoSlug: state.originRepoSlug,
|
|
1321
1327
|
hasUpstream: state.hasUpstream,
|
|
1322
1328
|
aheadCount: state.aheadCount,
|
|
@@ -1339,6 +1345,7 @@ export class DiffStore {
|
|
|
1339
1345
|
status: "no_repo",
|
|
1340
1346
|
branchName: undefined,
|
|
1341
1347
|
defaultBranchName: undefined,
|
|
1348
|
+
hasOriginRemote: undefined,
|
|
1342
1349
|
originRepoSlug: undefined,
|
|
1343
1350
|
hasUpstream: undefined,
|
|
1344
1351
|
aheadCount: undefined,
|
|
@@ -1356,6 +1363,7 @@ export class DiffStore {
|
|
|
1356
1363
|
const branchName = await getBranchName(repo.repoRoot)
|
|
1357
1364
|
const defaultBranchName = await resolveDefaultBranchName(repo.repoRoot)
|
|
1358
1365
|
const originRemoteUrl = await getOriginRemoteUrl(repo.repoRoot)
|
|
1366
|
+
const hasOriginRemote = originRemoteUrl !== null
|
|
1359
1367
|
const originRepoSlug = extractGitHubRepoSlug(originRemoteUrl) ?? undefined
|
|
1360
1368
|
const hasUpstream = await hasUpstreamBranch(repo.repoRoot)
|
|
1361
1369
|
const { aheadCount, behindCount } = hasUpstream
|
|
@@ -1373,6 +1381,7 @@ export class DiffStore {
|
|
|
1373
1381
|
status: "ready",
|
|
1374
1382
|
branchName,
|
|
1375
1383
|
defaultBranchName,
|
|
1384
|
+
hasOriginRemote,
|
|
1376
1385
|
originRepoSlug,
|
|
1377
1386
|
hasUpstream,
|
|
1378
1387
|
aheadCount,
|
|
@@ -1808,7 +1817,11 @@ export class DiffStore {
|
|
|
1808
1817
|
throw new Error("Project is not in a git repository")
|
|
1809
1818
|
}
|
|
1810
1819
|
|
|
1811
|
-
const hasUpstream = await
|
|
1820
|
+
const [hasUpstream, originRemoteUrl] = await Promise.all([
|
|
1821
|
+
hasUpstreamBranch(repo.repoRoot),
|
|
1822
|
+
getOriginRemoteUrl(repo.repoRoot),
|
|
1823
|
+
])
|
|
1824
|
+
const hasOriginRemote = originRemoteUrl !== null
|
|
1812
1825
|
if (args.action === "publish") {
|
|
1813
1826
|
const publishResult = await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
|
|
1814
1827
|
if (publishResult.exitCode !== 0) {
|
|
@@ -2002,7 +2015,11 @@ export class DiffStore {
|
|
|
2002
2015
|
if (!repo) {
|
|
2003
2016
|
throw new Error("Project is not in a git repository")
|
|
2004
2017
|
}
|
|
2005
|
-
const hasUpstream = await
|
|
2018
|
+
const [hasUpstream, originRemoteUrl] = await Promise.all([
|
|
2019
|
+
hasUpstreamBranch(repo.repoRoot),
|
|
2020
|
+
getOriginRemoteUrl(repo.repoRoot),
|
|
2021
|
+
])
|
|
2022
|
+
const hasOriginRemote = originRemoteUrl !== null
|
|
2006
2023
|
|
|
2007
2024
|
const currentDirtyPaths = new Set((await listDirtyPaths(repo.repoRoot)).map((entry) => entry.path))
|
|
2008
2025
|
const missingPaths = normalizedPaths.filter((relativePath) => !currentDirtyPaths.has(relativePath))
|
|
@@ -2039,6 +2056,16 @@ export class DiffStore {
|
|
|
2039
2056
|
} satisfies DiffCommitResult
|
|
2040
2057
|
}
|
|
2041
2058
|
|
|
2059
|
+
if (!hasUpstream && !hasOriginRemote) {
|
|
2060
|
+
return {
|
|
2061
|
+
ok: true,
|
|
2062
|
+
mode: args.mode,
|
|
2063
|
+
branchName,
|
|
2064
|
+
pushed: false,
|
|
2065
|
+
snapshotChanged,
|
|
2066
|
+
} satisfies DiffCommitResult
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2042
2069
|
const pushResult = hasUpstream
|
|
2043
2070
|
? await runGit(["push"], repo.repoRoot)
|
|
2044
2071
|
: await runGit(["push", "-u", "origin", "HEAD"], repo.repoRoot)
|
|
@@ -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({
|
package/src/server/ws-router.ts
CHANGED
|
@@ -18,6 +18,7 @@ const DEFAULT_CHAT_RECENT_LIMIT = 200
|
|
|
18
18
|
export interface ClientState {
|
|
19
19
|
subscriptions: Map<string, SubscriptionTopic>
|
|
20
20
|
snapshotSignatures: Map<string, string>
|
|
21
|
+
protectedDraftChatIds?: Set<string>
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface CreateWsRouterArgs {
|
|
@@ -57,7 +58,7 @@ export function createWsRouter({
|
|
|
57
58
|
}: CreateWsRouterArgs) {
|
|
58
59
|
const sockets = new Set<ServerWebSocket<ClientState>>()
|
|
59
60
|
const resolvedDiffStore = diffStore ?? {
|
|
60
|
-
getProjectSnapshot: () => ({ status: "unknown", branchName: undefined, defaultBranchName: undefined, originRepoSlug: undefined, hasUpstream: undefined, aheadCount: undefined, behindCount: undefined, lastFetchedAt: undefined, files: [] as const, branchHistory: { entries: [] as const } }),
|
|
61
|
+
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 } }),
|
|
61
62
|
refreshSnapshot: async () => false,
|
|
62
63
|
initializeGit: async () => ({ ok: true, branchName: undefined, snapshotChanged: false }),
|
|
63
64
|
getGitHubPublishInfo: async () => ({ ghInstalled: false, authenticated: false, activeAccountLogin: undefined, owners: [], suggestedRepoName: "my-repo" }),
|
|
@@ -83,9 +84,28 @@ export function createWsRouter({
|
|
|
83
84
|
])
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
function getProtectedDraftChatIds(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
|
|
88
|
+
const protectedChatIds = new Set<string>()
|
|
89
|
+
|
|
90
|
+
for (const socket of sockets) {
|
|
91
|
+
for (const chatId of socket.data.protectedDraftChatIds ?? []) {
|
|
92
|
+
protectedChatIds.add(chatId)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const socket of extraSockets ?? []) {
|
|
97
|
+
for (const chatId of socket.data.protectedDraftChatIds ?? []) {
|
|
98
|
+
protectedChatIds.add(chatId)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return protectedChatIds
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function maybePruneStaleEmptyChats(extraSockets?: Iterable<ServerWebSocket<ClientState>>) {
|
|
87
106
|
await store.pruneStaleEmptyChats?.({
|
|
88
107
|
activeChatIds: getProtectedChatIds(),
|
|
108
|
+
protectedChatIds: getProtectedDraftChatIds(extraSockets),
|
|
89
109
|
})
|
|
90
110
|
}
|
|
91
111
|
|
|
@@ -194,7 +214,7 @@ export function createWsRouter({
|
|
|
194
214
|
|
|
195
215
|
async function pushSnapshots(ws: ServerWebSocket<ClientState>, options?: { skipPrune?: boolean }) {
|
|
196
216
|
if (!options?.skipPrune) {
|
|
197
|
-
await maybePruneStaleEmptyChats()
|
|
217
|
+
await maybePruneStaleEmptyChats([ws])
|
|
198
218
|
}
|
|
199
219
|
const snapshotSignatures = ensureSnapshotSignatures(ws)
|
|
200
220
|
for (const [id, topic] of ws.data.subscriptions.entries()) {
|
|
@@ -410,6 +430,11 @@ export function createWsRouter({
|
|
|
410
430
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
411
431
|
break
|
|
412
432
|
}
|
|
433
|
+
case "chat.setDraftProtection": {
|
|
434
|
+
ws.data.protectedDraftChatIds = new Set(command.chatIds)
|
|
435
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
436
|
+
break
|
|
437
|
+
}
|
|
413
438
|
case "chat.send": {
|
|
414
439
|
const result = await agent.send(command)
|
|
415
440
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
|
package/src/shared/protocol.ts
CHANGED
|
@@ -67,6 +67,7 @@ export type ClientCommand =
|
|
|
67
67
|
| { type: "chat.create"; projectId: string }
|
|
68
68
|
| { type: "chat.rename"; chatId: string; title: string }
|
|
69
69
|
| { type: "chat.delete"; chatId: string }
|
|
70
|
+
| { type: "chat.setDraftProtection"; chatIds: string[] }
|
|
70
71
|
| { type: "chat.markRead"; chatId: string }
|
|
71
72
|
| {
|
|
72
73
|
type: "chat.send"
|
package/src/shared/types.ts
CHANGED