@cuongtran001/kanna 1.2.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.
- package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { QueuedChatMessage, TranscriptEntry } from "../shared/types"
|
|
3
|
+
import type { StorageBackend } from "./storage/backend"
|
|
4
|
+
import type { ToolRequest } from "../shared/permission-policy"
|
|
5
|
+
import type { ChatRecord } from "./events"
|
|
6
|
+
import {
|
|
7
|
+
getChatCount,
|
|
8
|
+
getMessages,
|
|
9
|
+
getMessagesPageBefore,
|
|
10
|
+
getQueuedMessage,
|
|
11
|
+
getQueuedMessages,
|
|
12
|
+
getRecentChatHistory,
|
|
13
|
+
getRecentMessagesPage,
|
|
14
|
+
getSeenMessageIds,
|
|
15
|
+
loadTranscriptFromDisk,
|
|
16
|
+
type CachedTranscriptRef,
|
|
17
|
+
type MessageReadDeps,
|
|
18
|
+
} from "./event-store-messages.adapter"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function makeStorage(files: Map<string, string> = new Map()): StorageBackend {
|
|
25
|
+
return {
|
|
26
|
+
mkdir: async () => {},
|
|
27
|
+
exists: async (p) => files.has(p),
|
|
28
|
+
existsSync: (p) => files.has(p),
|
|
29
|
+
size: async (p) => files.get(p)?.length ?? 0,
|
|
30
|
+
readText: async (p) => files.get(p) ?? "",
|
|
31
|
+
readTextSync: (p) => files.get(p) ?? "",
|
|
32
|
+
writeText: async (p, v) => { files.set(p, v) },
|
|
33
|
+
appendText: async (p, v) => { files.set(p, (files.get(p) ?? "") + v) },
|
|
34
|
+
rename: async () => {},
|
|
35
|
+
remove: async () => {},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeTranscriptEntry(_chatId = "chat-1", kind: "user_prompt" | "assistant_text" = "user_prompt", extra: Partial<TranscriptEntry> = {}): TranscriptEntry {
|
|
40
|
+
const _id = `${kind}-${Math.random()}`
|
|
41
|
+
if (kind === "user_prompt") {
|
|
42
|
+
return { _id, createdAt: 1000, kind: "user_prompt", content: "hello", ...extra } as TranscriptEntry
|
|
43
|
+
}
|
|
44
|
+
return { _id, createdAt: 1001, kind: "assistant_text", text: "world", ...extra } as TranscriptEntry
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeQueuedMessage(overrides: Partial<QueuedChatMessage> = {}): QueuedChatMessage {
|
|
48
|
+
return {
|
|
49
|
+
id: "qm-1",
|
|
50
|
+
content: "queued message",
|
|
51
|
+
attachments: [],
|
|
52
|
+
createdAt: 2000,
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeChatRecord(overrides: Partial<ChatRecord> = {}): ChatRecord {
|
|
58
|
+
return {
|
|
59
|
+
id: "chat-1",
|
|
60
|
+
projectId: "proj-1",
|
|
61
|
+
title: "Chat 1",
|
|
62
|
+
createdAt: 1000,
|
|
63
|
+
updatedAt: 1000,
|
|
64
|
+
unread: false,
|
|
65
|
+
provider: null,
|
|
66
|
+
planMode: false,
|
|
67
|
+
sessionTokensByProvider: {},
|
|
68
|
+
sourceHash: null,
|
|
69
|
+
lastTurnOutcome: null,
|
|
70
|
+
...overrides,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeDeps(overrides: Partial<MessageReadDeps> = {}): MessageReadDeps {
|
|
75
|
+
return {
|
|
76
|
+
storage: makeStorage(),
|
|
77
|
+
transcriptsDir: "/data/transcripts",
|
|
78
|
+
cachedTranscriptRef: { value: null },
|
|
79
|
+
legacyMessagesByChatId: new Map(),
|
|
80
|
+
seenMessageIdsByChatId: new Map(),
|
|
81
|
+
queuedMessagesByChatId: new Map(),
|
|
82
|
+
chatsById: new Map(),
|
|
83
|
+
listPendingToolRequests: (_chatId) => [],
|
|
84
|
+
...overrides,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// getSeenMessageIds
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe("getSeenMessageIds", () => {
|
|
93
|
+
test("creates an empty set for unknown chatId", () => {
|
|
94
|
+
const deps = makeDeps()
|
|
95
|
+
const set = getSeenMessageIds(deps, "chat-x")
|
|
96
|
+
expect(set.size).toBe(0)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("returns the same set on repeated calls for the same chatId", () => {
|
|
100
|
+
const deps = makeDeps()
|
|
101
|
+
const s1 = getSeenMessageIds(deps, "chat-x")
|
|
102
|
+
s1.add("msg-1")
|
|
103
|
+
const s2 = getSeenMessageIds(deps, "chat-x")
|
|
104
|
+
expect(s2.has("msg-1")).toBe(true)
|
|
105
|
+
expect(s1).toBe(s2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("returns distinct sets for different chatIds", () => {
|
|
109
|
+
const deps = makeDeps()
|
|
110
|
+
const sa = getSeenMessageIds(deps, "chat-a")
|
|
111
|
+
const sb = getSeenMessageIds(deps, "chat-b")
|
|
112
|
+
expect(sa).not.toBe(sb)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// loadTranscriptFromDisk
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe("loadTranscriptFromDisk", () => {
|
|
121
|
+
test("returns empty array when file does not exist", () => {
|
|
122
|
+
const deps = makeDeps()
|
|
123
|
+
expect(loadTranscriptFromDisk(deps, "chat-1")).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("returns empty array for empty file", () => {
|
|
127
|
+
const files = new Map([["/data/transcripts/chat-1.jsonl", ""]])
|
|
128
|
+
const deps = makeDeps({ storage: makeStorage(files) })
|
|
129
|
+
expect(loadTranscriptFromDisk(deps, "chat-1")).toEqual([])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("parses entries from JSONL file", () => {
|
|
133
|
+
const e1 = makeTranscriptEntry("chat-1", "user_prompt")
|
|
134
|
+
const e2 = makeTranscriptEntry("chat-1", "assistant_text")
|
|
135
|
+
const content = `${JSON.stringify(e1)}\n${JSON.stringify(e2)}\n`
|
|
136
|
+
const files = new Map([["/data/transcripts/chat-1.jsonl", content]])
|
|
137
|
+
const deps = makeDeps({ storage: makeStorage(files) })
|
|
138
|
+
|
|
139
|
+
const result = loadTranscriptFromDisk(deps, "chat-1")
|
|
140
|
+
expect(result.length).toBe(2)
|
|
141
|
+
expect(result[0]!._id).toBe(e1._id)
|
|
142
|
+
expect(result[1]!._id).toBe(e2._id)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("populates seenMessageIds set for entries with messageId", () => {
|
|
146
|
+
const e1: TranscriptEntry = { ...makeTranscriptEntry("chat-1"), messageId: "mid-1" } as TranscriptEntry & { messageId: string }
|
|
147
|
+
const content = `${JSON.stringify(e1)}\n`
|
|
148
|
+
const files = new Map([["/data/transcripts/chat-1.jsonl", content]])
|
|
149
|
+
const seenMessageIdsByChatId = new Map<string, Set<string>>()
|
|
150
|
+
const deps = makeDeps({ storage: makeStorage(files), seenMessageIdsByChatId })
|
|
151
|
+
|
|
152
|
+
loadTranscriptFromDisk(deps, "chat-1")
|
|
153
|
+
|
|
154
|
+
const seen = seenMessageIdsByChatId.get("chat-1")
|
|
155
|
+
expect(seen?.has("mid-1")).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// getMessages
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe("getMessages", () => {
|
|
164
|
+
test("returns from cache when chatId matches", () => {
|
|
165
|
+
const e1 = makeTranscriptEntry("chat-1")
|
|
166
|
+
const cachedTranscriptRef: CachedTranscriptRef = {
|
|
167
|
+
value: { chatId: "chat-1", entries: [e1] },
|
|
168
|
+
}
|
|
169
|
+
const deps = makeDeps({ cachedTranscriptRef })
|
|
170
|
+
|
|
171
|
+
const result = getMessages(deps, "chat-1")
|
|
172
|
+
expect(result[0]!._id).toBe(e1._id)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("reads from legacy map and populates cache", () => {
|
|
176
|
+
const e1 = makeTranscriptEntry("chat-2")
|
|
177
|
+
const legacyMessagesByChatId = new Map([["chat-2", [e1]]])
|
|
178
|
+
const cachedTranscriptRef: CachedTranscriptRef = { value: null }
|
|
179
|
+
const deps = makeDeps({ legacyMessagesByChatId, cachedTranscriptRef })
|
|
180
|
+
|
|
181
|
+
const result = getMessages(deps, "chat-2")
|
|
182
|
+
expect(result[0]!._id).toBe(e1._id)
|
|
183
|
+
expect(cachedTranscriptRef.value?.chatId).toBe("chat-2")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("loads from disk when no cache and no legacy data", () => {
|
|
187
|
+
const e1 = makeTranscriptEntry("chat-3")
|
|
188
|
+
const content = `${JSON.stringify(e1)}\n`
|
|
189
|
+
const files = new Map([["/data/transcripts/chat-3.jsonl", content]])
|
|
190
|
+
const cachedTranscriptRef: CachedTranscriptRef = { value: null }
|
|
191
|
+
const deps = makeDeps({ storage: makeStorage(files), cachedTranscriptRef })
|
|
192
|
+
|
|
193
|
+
const result = getMessages(deps, "chat-3")
|
|
194
|
+
expect(result[0]!._id).toBe(e1._id)
|
|
195
|
+
expect(cachedTranscriptRef.value?.chatId).toBe("chat-3")
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("returns a clone (not the cached reference)", () => {
|
|
199
|
+
const e1 = makeTranscriptEntry("chat-1")
|
|
200
|
+
const entries = [e1]
|
|
201
|
+
const cachedTranscriptRef: CachedTranscriptRef = {
|
|
202
|
+
value: { chatId: "chat-1", entries },
|
|
203
|
+
}
|
|
204
|
+
const deps = makeDeps({ cachedTranscriptRef })
|
|
205
|
+
|
|
206
|
+
const r1 = getMessages(deps, "chat-1")
|
|
207
|
+
const r2 = getMessages(deps, "chat-1")
|
|
208
|
+
expect(r1).not.toBe(r2)
|
|
209
|
+
expect(r1).not.toBe(entries)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// getQueuedMessages
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
describe("getQueuedMessages", () => {
|
|
218
|
+
test("returns empty array for unknown chat", () => {
|
|
219
|
+
const deps = makeDeps()
|
|
220
|
+
expect(getQueuedMessages(deps, "chat-x")).toEqual([])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test("returns messages with cloned attachments", () => {
|
|
224
|
+
const msg = makeQueuedMessage({ id: "qm-1", attachments: [] })
|
|
225
|
+
const queuedMessagesByChatId = new Map([["chat-1", [msg]]])
|
|
226
|
+
const deps = makeDeps({ queuedMessagesByChatId })
|
|
227
|
+
|
|
228
|
+
const result = getQueuedMessages(deps, "chat-1")
|
|
229
|
+
expect(result[0]!.id).toBe("qm-1")
|
|
230
|
+
expect(result[0]!.attachments).not.toBe(msg.attachments)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// getQueuedMessage
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe("getQueuedMessage", () => {
|
|
239
|
+
test("returns null for unknown queuedMessageId", () => {
|
|
240
|
+
const msg = makeQueuedMessage({ id: "qm-1" })
|
|
241
|
+
const queuedMessagesByChatId = new Map([["chat-1", [msg]]])
|
|
242
|
+
const deps = makeDeps({ queuedMessagesByChatId })
|
|
243
|
+
expect(getQueuedMessage(deps, "chat-1", "qm-99")).toBeNull()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("returns the matching message", () => {
|
|
247
|
+
const msg1 = makeQueuedMessage({ id: "qm-1", content: "first" })
|
|
248
|
+
const msg2 = makeQueuedMessage({ id: "qm-2", content: "second" })
|
|
249
|
+
const queuedMessagesByChatId = new Map([["chat-1", [msg1, msg2]]])
|
|
250
|
+
const deps = makeDeps({ queuedMessagesByChatId })
|
|
251
|
+
|
|
252
|
+
const result = getQueuedMessage(deps, "chat-1", "qm-2")
|
|
253
|
+
expect(result?.content).toBe("second")
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// getRecentMessagesPage
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe("getRecentMessagesPage", () => {
|
|
262
|
+
test("returns empty page for limit 0", () => {
|
|
263
|
+
const deps = makeDeps()
|
|
264
|
+
const page = getRecentMessagesPage(deps, "chat-1", 0)
|
|
265
|
+
expect(page.messages).toEqual([])
|
|
266
|
+
expect(page.hasOlder).toBe(false)
|
|
267
|
+
expect(page.olderCursor).toBeNull()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("returns up to limit messages", () => {
|
|
271
|
+
const entries = [1, 2, 3, 4, 5].map((i) => ({
|
|
272
|
+
_id: `e-${i}`,
|
|
273
|
+
createdAt: i * 100,
|
|
274
|
+
kind: "assistant_text" as const,
|
|
275
|
+
text: `msg ${i}`,
|
|
276
|
+
}))
|
|
277
|
+
const cachedTranscriptRef: CachedTranscriptRef = {
|
|
278
|
+
value: { chatId: "chat-1", entries },
|
|
279
|
+
}
|
|
280
|
+
const deps = makeDeps({ cachedTranscriptRef })
|
|
281
|
+
const page = getRecentMessagesPage(deps, "chat-1", 3)
|
|
282
|
+
expect(page.messages.length).toBe(3)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test("hasOlder is true when there are more messages than limit", () => {
|
|
286
|
+
const entries = [1, 2, 3, 4, 5].map((i) => ({
|
|
287
|
+
_id: `e-${i}`,
|
|
288
|
+
createdAt: i * 100,
|
|
289
|
+
kind: "assistant_text" as const,
|
|
290
|
+
text: `msg ${i}`,
|
|
291
|
+
}))
|
|
292
|
+
const cachedTranscriptRef: CachedTranscriptRef = {
|
|
293
|
+
value: { chatId: "chat-1", entries },
|
|
294
|
+
}
|
|
295
|
+
const deps = makeDeps({ cachedTranscriptRef })
|
|
296
|
+
const page = getRecentMessagesPage(deps, "chat-1", 3)
|
|
297
|
+
expect(page.hasOlder).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// getMessagesPageBefore
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
describe("getMessagesPageBefore", () => {
|
|
306
|
+
test("returns empty page for limit 0", () => {
|
|
307
|
+
const deps = makeDeps()
|
|
308
|
+
const page = getMessagesPageBefore(deps, "chat-1", "cursor:0", 0)
|
|
309
|
+
expect(page.messages).toEqual([])
|
|
310
|
+
expect(page.hasOlder).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// getRecentChatHistory
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe("getRecentChatHistory", () => {
|
|
319
|
+
test("includes pending tool request entries", () => {
|
|
320
|
+
const req = {
|
|
321
|
+
id: "req-1",
|
|
322
|
+
chatId: "chat-1",
|
|
323
|
+
toolName: "Bash",
|
|
324
|
+
arguments: { command: "ls" },
|
|
325
|
+
createdAt: 9000,
|
|
326
|
+
} as unknown as ToolRequest
|
|
327
|
+
const listPendingToolRequests = (_chatId: string) => [req]
|
|
328
|
+
const deps = makeDeps({ listPendingToolRequests })
|
|
329
|
+
|
|
330
|
+
const { messages } = getRecentChatHistory(deps, "chat-1", 50)
|
|
331
|
+
const pendingEntry = messages.find((m) => m.kind === "pending_tool_request")
|
|
332
|
+
expect(pendingEntry).toBeDefined()
|
|
333
|
+
expect(pendingEntry?.kind === "pending_tool_request" && pendingEntry.toolName).toBe("Bash")
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test("returns history snapshot", () => {
|
|
337
|
+
const deps = makeDeps()
|
|
338
|
+
const result = getRecentChatHistory(deps, "chat-1", 50)
|
|
339
|
+
expect(result.history).toBeDefined()
|
|
340
|
+
expect(result.messages).toBeDefined()
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// getChatCount
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("getChatCount", () => {
|
|
349
|
+
test("returns 0 for project with no chats", () => {
|
|
350
|
+
const deps = makeDeps()
|
|
351
|
+
expect(getChatCount(deps, "proj-1")).toBe(0)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test("counts only non-deleted, non-archived chats for the project", () => {
|
|
355
|
+
const chatsById = new Map([
|
|
356
|
+
["c1", makeChatRecord({ id: "c1", projectId: "proj-1" })],
|
|
357
|
+
["c2", makeChatRecord({ id: "c2", projectId: "proj-1", deletedAt: 1000 })],
|
|
358
|
+
["c3", makeChatRecord({ id: "c3", projectId: "proj-1", archivedAt: 1000 })],
|
|
359
|
+
["c4", makeChatRecord({ id: "c4", projectId: "proj-2" })],
|
|
360
|
+
])
|
|
361
|
+
const deps = makeDeps({ chatsById })
|
|
362
|
+
expect(getChatCount(deps, "proj-1")).toBe(1)
|
|
363
|
+
expect(getChatCount(deps, "proj-2")).toBe(1)
|
|
364
|
+
})
|
|
365
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message and transcript read operations extracted from event-store.ts.
|
|
3
|
+
*
|
|
4
|
+
* `loadTranscriptFromDisk` performs synchronous disk IO, so this module is an
|
|
5
|
+
* adapter (`.adapter.ts`). All other functions in this file are pure in-memory
|
|
6
|
+
* reads — they are co-located here for cohesion because they share the same
|
|
7
|
+
* deps interface and depend on `loadTranscriptFromDisk`.
|
|
8
|
+
*
|
|
9
|
+
* This module must NOT import from event-store.ts (no circular deps).
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path"
|
|
12
|
+
import type { ChatHistoryPage, QueuedChatMessage, TranscriptEntry } from "../shared/types"
|
|
13
|
+
import type { StorageBackend } from "./storage/backend"
|
|
14
|
+
import type { ToolRequest } from "../shared/permission-policy"
|
|
15
|
+
import type { ChatRecord, StoreState } from "./events"
|
|
16
|
+
import { cloneTranscriptEntries } from "./events"
|
|
17
|
+
import {
|
|
18
|
+
coalesceContextWindowUpdates,
|
|
19
|
+
decodeCursor,
|
|
20
|
+
getHistorySnapshot,
|
|
21
|
+
getMessagesPageFromEntries,
|
|
22
|
+
} from "./event-store-helpers"
|
|
23
|
+
|
|
24
|
+
// ─── Mutable ref for the transcript cache ────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** Wraps the mutable `cachedTranscript` field so extracted functions can read and replace it. */
|
|
27
|
+
export interface CachedTranscriptRef {
|
|
28
|
+
value: { chatId: string; entries: TranscriptEntry[] } | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Deps interface ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface MessageReadDeps {
|
|
34
|
+
readonly storage: StorageBackend
|
|
35
|
+
readonly transcriptsDir: string
|
|
36
|
+
readonly cachedTranscriptRef: CachedTranscriptRef
|
|
37
|
+
readonly legacyMessagesByChatId: Map<string, TranscriptEntry[]>
|
|
38
|
+
readonly seenMessageIdsByChatId: Map<string, Set<string>>
|
|
39
|
+
readonly queuedMessagesByChatId: StoreState["queuedMessagesByChatId"]
|
|
40
|
+
readonly chatsById: Map<string, ChatRecord>
|
|
41
|
+
listPendingToolRequests: (chatId: string) => ToolRequest[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Private helpers ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function transcriptPath(deps: MessageReadDeps, chatId: string): string {
|
|
47
|
+
return path.join(deps.transcriptsDir, `${chatId}.jsonl`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Exported functions ────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Returns (or lazily creates) the seen-messageId dedup set for a chat. */
|
|
53
|
+
export function getSeenMessageIds(deps: MessageReadDeps, chatId: string): Set<string> {
|
|
54
|
+
let set = deps.seenMessageIdsByChatId.get(chatId)
|
|
55
|
+
if (!set) {
|
|
56
|
+
set = new Set<string>()
|
|
57
|
+
deps.seenMessageIdsByChatId.set(chatId, set)
|
|
58
|
+
}
|
|
59
|
+
return set
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads transcript entries from disk synchronously.
|
|
64
|
+
* Populates the seenMessageIds set as a side-effect.
|
|
65
|
+
*/
|
|
66
|
+
export function loadTranscriptFromDisk(
|
|
67
|
+
deps: MessageReadDeps,
|
|
68
|
+
chatId: string,
|
|
69
|
+
): TranscriptEntry[] {
|
|
70
|
+
const tPath = transcriptPath(deps, chatId)
|
|
71
|
+
if (!deps.storage.existsSync(tPath)) {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const text = deps.storage.readTextSync(tPath)
|
|
76
|
+
if (!text.trim()) return []
|
|
77
|
+
|
|
78
|
+
const entries: TranscriptEntry[] = []
|
|
79
|
+
const seen = getSeenMessageIds(deps, chatId)
|
|
80
|
+
for (const rawLine of text.split("\n")) {
|
|
81
|
+
const line = rawLine.trim()
|
|
82
|
+
if (!line) continue
|
|
83
|
+
const entry: TranscriptEntry & { messageId?: string } = JSON.parse(line)
|
|
84
|
+
entries.push(entry)
|
|
85
|
+
const mid = entry.messageId
|
|
86
|
+
if (typeof mid === "string" && mid.length > 0) {
|
|
87
|
+
seen.add(mid)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return entries
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns cloned transcript entries for `chatId`, using the in-memory cache
|
|
95
|
+
* or loading from disk as needed.
|
|
96
|
+
*/
|
|
97
|
+
export function getMessages(deps: MessageReadDeps, chatId: string): TranscriptEntry[] {
|
|
98
|
+
if (deps.cachedTranscriptRef.value?.chatId === chatId) {
|
|
99
|
+
return cloneTranscriptEntries(deps.cachedTranscriptRef.value.entries)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const legacyEntries = deps.legacyMessagesByChatId.get(chatId)
|
|
103
|
+
if (legacyEntries) {
|
|
104
|
+
deps.cachedTranscriptRef.value = { chatId, entries: cloneTranscriptEntries(legacyEntries) }
|
|
105
|
+
return cloneTranscriptEntries(deps.cachedTranscriptRef.value.entries)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const entries = loadTranscriptFromDisk(deps, chatId)
|
|
109
|
+
deps.cachedTranscriptRef.value = { chatId, entries }
|
|
110
|
+
return cloneTranscriptEntries(entries)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Returns queued messages for a chat, with attachment arrays cloned. */
|
|
114
|
+
export function getQueuedMessages(
|
|
115
|
+
deps: MessageReadDeps,
|
|
116
|
+
chatId: string,
|
|
117
|
+
): QueuedChatMessage[] {
|
|
118
|
+
const entries = deps.queuedMessagesByChatId.get(chatId) ?? []
|
|
119
|
+
return entries.map((entry) => ({
|
|
120
|
+
...entry,
|
|
121
|
+
attachments: [...entry.attachments],
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Returns a single queued message by id, or null. */
|
|
126
|
+
export function getQueuedMessage(
|
|
127
|
+
deps: MessageReadDeps,
|
|
128
|
+
chatId: string,
|
|
129
|
+
queuedMessageId: string,
|
|
130
|
+
): QueuedChatMessage | null {
|
|
131
|
+
return getQueuedMessages(deps, chatId).find((entry) => entry.id === queuedMessageId) ?? null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Returns the most recent page of transcript messages. */
|
|
135
|
+
export function getRecentMessagesPage(
|
|
136
|
+
deps: MessageReadDeps,
|
|
137
|
+
chatId: string,
|
|
138
|
+
limit: number,
|
|
139
|
+
): ChatHistoryPage {
|
|
140
|
+
if (limit <= 0) {
|
|
141
|
+
return { messages: [], hasOlder: false, olderCursor: null }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const entries = coalesceContextWindowUpdates(getMessages(deps, chatId))
|
|
145
|
+
const page = getMessagesPageFromEntries(entries, limit)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
messages: page.entries,
|
|
149
|
+
hasOlder: page.hasOlder,
|
|
150
|
+
olderCursor: page.olderCursor,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Returns a page of transcript messages before the given cursor. */
|
|
155
|
+
export function getMessagesPageBefore(
|
|
156
|
+
deps: MessageReadDeps,
|
|
157
|
+
chatId: string,
|
|
158
|
+
beforeCursor: string,
|
|
159
|
+
limit: number,
|
|
160
|
+
): ChatHistoryPage {
|
|
161
|
+
if (limit <= 0) {
|
|
162
|
+
return { messages: [], hasOlder: false, olderCursor: null }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Coalesce identically to getRecentMessagesPage so cursors (which index the
|
|
166
|
+
// coalesced array) stay consistent across recent + load-older paging.
|
|
167
|
+
const beforeIndex = decodeCursor(beforeCursor)
|
|
168
|
+
const entries = coalesceContextWindowUpdates(getMessages(deps, chatId))
|
|
169
|
+
const page = getMessagesPageFromEntries(entries, limit, beforeIndex)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
messages: page.entries,
|
|
173
|
+
hasOlder: page.hasOlder,
|
|
174
|
+
olderCursor: page.olderCursor,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Returns merged transcript + pending tool request entries, plus a history snapshot. */
|
|
179
|
+
export function getRecentChatHistory(
|
|
180
|
+
deps: MessageReadDeps,
|
|
181
|
+
chatId: string,
|
|
182
|
+
recentLimit: number,
|
|
183
|
+
) {
|
|
184
|
+
const page = getRecentMessagesPage(deps, chatId, recentLimit)
|
|
185
|
+
const pending = deps.listPendingToolRequests(chatId)
|
|
186
|
+
const pendingEntries: TranscriptEntry[] = pending.map((req) => ({
|
|
187
|
+
_id: `pending-tool-request-${req.id}`,
|
|
188
|
+
createdAt: req.createdAt,
|
|
189
|
+
kind: "pending_tool_request",
|
|
190
|
+
toolRequestId: req.id,
|
|
191
|
+
toolName: req.toolName,
|
|
192
|
+
arguments: req.arguments,
|
|
193
|
+
}))
|
|
194
|
+
const merged = [...page.messages, ...pendingEntries]
|
|
195
|
+
return {
|
|
196
|
+
messages: merged,
|
|
197
|
+
history: getHistorySnapshot(
|
|
198
|
+
{
|
|
199
|
+
entries: merged,
|
|
200
|
+
hasOlder: page.hasOlder,
|
|
201
|
+
olderCursor: page.olderCursor,
|
|
202
|
+
},
|
|
203
|
+
recentLimit,
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Returns the count of active (non-deleted, non-archived) chats for a project. */
|
|
209
|
+
export function getChatCount(deps: MessageReadDeps, projectId: string): number {
|
|
210
|
+
return [...deps.chatsById.values()].filter(
|
|
211
|
+
(chat) => chat.projectId === projectId && !chat.deletedAt && !chat.archivedAt,
|
|
212
|
+
).length
|
|
213
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration event write-path + subscription management extracted from
|
|
3
|
+
* event-store.ts.
|
|
4
|
+
*
|
|
5
|
+
* Wraps the pure read functions from event-store-orch.ts and adds:
|
|
6
|
+
* - `appendOrchestrationEvent` — sync apply + async disk enqueue
|
|
7
|
+
* - `subscribeOrchRuns` / `notifyOrchRunsChanged` — observer pattern
|
|
8
|
+
*
|
|
9
|
+
* All functions use injected deps so they carry no direct IO. EventStore
|
|
10
|
+
* is the sole owner of the underlying maps and write-chain.
|
|
11
|
+
*
|
|
12
|
+
* Must NOT import from event-store.ts (no circular deps).
|
|
13
|
+
*/
|
|
14
|
+
import { LOG_PREFIX } from "../shared/branding"
|
|
15
|
+
import { log } from "../shared/log"
|
|
16
|
+
import type { OrchRunSnapshot } from "../shared/orchestration-types"
|
|
17
|
+
import type { OrchestrationEvent, OrchRunRecord } from "./events"
|
|
18
|
+
import {
|
|
19
|
+
gatedOrchTasks as gatedOrchTasksFn,
|
|
20
|
+
getAllOrchRunSnapshots,
|
|
21
|
+
getOrchLastPhaseOutput as getOrchLastPhaseOutputFn,
|
|
22
|
+
getOrchRunEvents as getOrchRunEventsFn,
|
|
23
|
+
getOrchRunSnapshot,
|
|
24
|
+
getOrchTaskSpec as getOrchTaskSpecFn,
|
|
25
|
+
nonTerminalOrchTasks as nonTerminalOrchTasksFn,
|
|
26
|
+
} from "./event-store-orch"
|
|
27
|
+
|
|
28
|
+
// ─── Deps interface ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface OrchSubscriptionDeps {
|
|
31
|
+
readonly orchRunsById: Map<string, OrchRunRecord>
|
|
32
|
+
readonly orchLogPath: string
|
|
33
|
+
readonly orchRunsSubscribers: Set<() => void>
|
|
34
|
+
applyEvent: (event: OrchestrationEvent) => void
|
|
35
|
+
enqueueDiskAppend: (filePath: string, payload: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Subscription state ───────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Create the mutable subscription state held by EventStore. */
|
|
41
|
+
export function createOrchSubscriptionState(): { orchRunsSubscribers: Set<() => void> } {
|
|
42
|
+
return { orchRunsSubscribers: new Set() }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Write path ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Apply synchronously, then enqueue the disk append — the sync apply is what
|
|
49
|
+
* makes an orchestration claim atomic within one event-loop turn (same
|
|
50
|
+
* pattern as appendSubagentEvent).
|
|
51
|
+
*/
|
|
52
|
+
export function appendOrchestrationEvent(
|
|
53
|
+
deps: OrchSubscriptionDeps,
|
|
54
|
+
event: OrchestrationEvent,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
deps.applyEvent(event)
|
|
57
|
+
deps.enqueueDiskAppend(deps.orchLogPath, `${JSON.stringify(event)}\n`)
|
|
58
|
+
notifyOrchRunsChanged(deps)
|
|
59
|
+
return Promise.resolve()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Observe orchestration read-model changes. The callback fires after each
|
|
64
|
+
* live orch event is applied (not during boot replay — no subscribers yet).
|
|
65
|
+
* Returns an unsubscribe fn.
|
|
66
|
+
*/
|
|
67
|
+
export function subscribeOrchRuns(deps: OrchSubscriptionDeps, cb: () => void): () => void {
|
|
68
|
+
deps.orchRunsSubscribers.add(cb)
|
|
69
|
+
return () => { deps.orchRunsSubscribers.delete(cb) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function notifyOrchRunsChanged(deps: OrchSubscriptionDeps): void {
|
|
73
|
+
for (const cb of deps.orchRunsSubscribers) {
|
|
74
|
+
try { cb() } catch (err) { log.warn(`${LOG_PREFIX} orch-runs subscriber threw`, { err }) }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Read-model thin wrappers ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export function getOrchRun(
|
|
81
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
82
|
+
runId: string,
|
|
83
|
+
): OrchRunSnapshot | null {
|
|
84
|
+
return getOrchRunSnapshot(orchRunsById, runId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getOrchRuns(orchRunsById: Map<string, OrchRunRecord>): OrchRunSnapshot[] {
|
|
88
|
+
return getAllOrchRunSnapshots(orchRunsById)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function nonTerminalOrchTasks(
|
|
92
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
93
|
+
): Iterable<{ runId: string; taskId: string; state: "claimed" | "running" }> {
|
|
94
|
+
return nonTerminalOrchTasksFn(orchRunsById)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function gatedOrchTasks(
|
|
98
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
99
|
+
): Iterable<{ runId: string; taskId: string; phaseIndex: number }> {
|
|
100
|
+
return gatedOrchTasksFn(orchRunsById)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getOrchTaskSpec(
|
|
104
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
105
|
+
runId: string,
|
|
106
|
+
taskId: string,
|
|
107
|
+
): { prompt: string; scopePaths: string[] } | null {
|
|
108
|
+
return getOrchTaskSpecFn(orchRunsById, runId, taskId)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getOrchLastPhaseOutput(
|
|
112
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
113
|
+
runId: string,
|
|
114
|
+
taskId: string,
|
|
115
|
+
): string | null {
|
|
116
|
+
return getOrchLastPhaseOutputFn(orchRunsById, runId, taskId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getOrchRunEvents(
|
|
120
|
+
orchRunsById: Map<string, OrchRunRecord>,
|
|
121
|
+
runId: string,
|
|
122
|
+
): OrchestrationEvent[] {
|
|
123
|
+
return getOrchRunEventsFn(orchRunsById, runId)
|
|
124
|
+
}
|