zozul-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.env.example +44 -0
  2. package/.github/workflows/publish.yml +26 -0
  3. package/DEVELOPMENT.md +288 -0
  4. package/LICENSE +201 -0
  5. package/README.md +178 -0
  6. package/dist/cli/commands.d.ts +3 -0
  7. package/dist/cli/commands.d.ts.map +1 -0
  8. package/dist/cli/commands.js +307 -0
  9. package/dist/cli/commands.js.map +1 -0
  10. package/dist/cli/format.d.ts +5 -0
  11. package/dist/cli/format.d.ts.map +1 -0
  12. package/dist/cli/format.js +115 -0
  13. package/dist/cli/format.js.map +1 -0
  14. package/dist/context/index.d.ts +8 -0
  15. package/dist/context/index.d.ts.map +1 -0
  16. package/dist/context/index.js +37 -0
  17. package/dist/context/index.js.map +1 -0
  18. package/dist/dashboard/html.d.ts +17 -0
  19. package/dist/dashboard/html.d.ts.map +1 -0
  20. package/dist/dashboard/html.js +79 -0
  21. package/dist/dashboard/html.js.map +1 -0
  22. package/dist/dashboard/index.html +1245 -0
  23. package/dist/hooks/config.d.ts +19 -0
  24. package/dist/hooks/config.d.ts.map +1 -0
  25. package/dist/hooks/config.js +106 -0
  26. package/dist/hooks/config.js.map +1 -0
  27. package/dist/hooks/git.d.ts +6 -0
  28. package/dist/hooks/git.d.ts.map +1 -0
  29. package/dist/hooks/git.js +73 -0
  30. package/dist/hooks/git.js.map +1 -0
  31. package/dist/hooks/index.d.ts +4 -0
  32. package/dist/hooks/index.d.ts.map +1 -0
  33. package/dist/hooks/index.js +3 -0
  34. package/dist/hooks/index.js.map +1 -0
  35. package/dist/hooks/server.d.ts +16 -0
  36. package/dist/hooks/server.d.ts.map +1 -0
  37. package/dist/hooks/server.js +349 -0
  38. package/dist/hooks/server.js.map +1 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +6 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/otel/config.d.ts +36 -0
  44. package/dist/otel/config.d.ts.map +1 -0
  45. package/dist/otel/config.js +109 -0
  46. package/dist/otel/config.js.map +1 -0
  47. package/dist/otel/index.d.ts +4 -0
  48. package/dist/otel/index.d.ts.map +1 -0
  49. package/dist/otel/index.js +3 -0
  50. package/dist/otel/index.js.map +1 -0
  51. package/dist/otel/receiver.d.ts +10 -0
  52. package/dist/otel/receiver.d.ts.map +1 -0
  53. package/dist/otel/receiver.js +155 -0
  54. package/dist/otel/receiver.js.map +1 -0
  55. package/dist/parser/index.d.ts +4 -0
  56. package/dist/parser/index.d.ts.map +1 -0
  57. package/dist/parser/index.js +3 -0
  58. package/dist/parser/index.js.map +1 -0
  59. package/dist/parser/ingest.d.ts +20 -0
  60. package/dist/parser/ingest.d.ts.map +1 -0
  61. package/dist/parser/ingest.js +98 -0
  62. package/dist/parser/ingest.js.map +1 -0
  63. package/dist/parser/jsonl.d.ts +14 -0
  64. package/dist/parser/jsonl.d.ts.map +1 -0
  65. package/dist/parser/jsonl.js +202 -0
  66. package/dist/parser/jsonl.js.map +1 -0
  67. package/dist/parser/types.d.ts +81 -0
  68. package/dist/parser/types.d.ts.map +1 -0
  69. package/dist/parser/types.js +9 -0
  70. package/dist/parser/types.js.map +1 -0
  71. package/dist/parser/watcher.d.ts +16 -0
  72. package/dist/parser/watcher.d.ts.map +1 -0
  73. package/dist/parser/watcher.js +103 -0
  74. package/dist/parser/watcher.js.map +1 -0
  75. package/dist/pricing/index.d.ts +2 -0
  76. package/dist/pricing/index.d.ts.map +1 -0
  77. package/dist/pricing/index.js +37 -0
  78. package/dist/pricing/index.js.map +1 -0
  79. package/dist/service/index.d.ts +31 -0
  80. package/dist/service/index.d.ts.map +1 -0
  81. package/dist/service/index.js +252 -0
  82. package/dist/service/index.js.map +1 -0
  83. package/dist/storage/db.d.ts +75 -0
  84. package/dist/storage/db.d.ts.map +1 -0
  85. package/dist/storage/db.js +117 -0
  86. package/dist/storage/db.js.map +1 -0
  87. package/dist/storage/index.d.ts +4 -0
  88. package/dist/storage/index.d.ts.map +1 -0
  89. package/dist/storage/index.js +3 -0
  90. package/dist/storage/index.js.map +1 -0
  91. package/dist/storage/repo.d.ts +162 -0
  92. package/dist/storage/repo.d.ts.map +1 -0
  93. package/dist/storage/repo.js +472 -0
  94. package/dist/storage/repo.js.map +1 -0
  95. package/dist/sync/client.d.ts +24 -0
  96. package/dist/sync/client.d.ts.map +1 -0
  97. package/dist/sync/client.js +41 -0
  98. package/dist/sync/client.js.map +1 -0
  99. package/dist/sync/index.d.ts +18 -0
  100. package/dist/sync/index.d.ts.map +1 -0
  101. package/dist/sync/index.js +135 -0
  102. package/dist/sync/index.js.map +1 -0
  103. package/dist/sync/sync.test.d.ts +2 -0
  104. package/dist/sync/sync.test.d.ts.map +1 -0
  105. package/dist/sync/sync.test.js +412 -0
  106. package/dist/sync/sync.test.js.map +1 -0
  107. package/dist/sync/transform.d.ts +80 -0
  108. package/dist/sync/transform.d.ts.map +1 -0
  109. package/dist/sync/transform.js +90 -0
  110. package/dist/sync/transform.js.map +1 -0
  111. package/package.json +50 -0
  112. package/src/cli/commands.ts +332 -0
  113. package/src/cli/format.ts +133 -0
  114. package/src/context/index.ts +42 -0
  115. package/src/dashboard/html.ts +97 -0
  116. package/src/dashboard/index.html +1245 -0
  117. package/src/hooks/config.ts +119 -0
  118. package/src/hooks/git.ts +77 -0
  119. package/src/hooks/index.ts +7 -0
  120. package/src/hooks/server.ts +397 -0
  121. package/src/index.ts +6 -0
  122. package/src/otel/config.ts +141 -0
  123. package/src/otel/index.ts +8 -0
  124. package/src/otel/receiver.ts +183 -0
  125. package/src/parser/index.ts +3 -0
  126. package/src/parser/ingest.ts +119 -0
  127. package/src/parser/jsonl.ts +241 -0
  128. package/src/parser/types.ts +89 -0
  129. package/src/parser/watcher.ts +116 -0
  130. package/src/pricing/index.ts +51 -0
  131. package/src/service/index.ts +272 -0
  132. package/src/storage/db.ts +198 -0
  133. package/src/storage/index.ts +3 -0
  134. package/src/storage/repo.ts +601 -0
  135. package/src/sync/client.ts +63 -0
  136. package/src/sync/index.ts +207 -0
  137. package/src/sync/sync.test.ts +447 -0
  138. package/src/sync/transform.ts +184 -0
  139. package/tsconfig.json +19 -0
@@ -0,0 +1,207 @@
1
+ import type { SessionRow } from "../storage/db.js";
2
+ import type { SessionRepo } from "../storage/repo.js";
3
+ import type { ZozulApiClient } from "./client.js";
4
+ import {
5
+ transformSession, transformTurn, transformToolUse,
6
+ transformHookEvent, transformTaskTag,
7
+ transformOtelMetric, transformOtelEvent,
8
+ type TurnLookup, type SessionSyncPayload,
9
+ } from "./transform.js";
10
+
11
+ const BATCH_SIZE = 500;
12
+
13
+ export interface SyncCounts {
14
+ synced: number;
15
+ failed: number;
16
+ }
17
+
18
+ export interface SyncResult {
19
+ sessions: SyncCounts;
20
+ otel_metrics: SyncCounts;
21
+ otel_events: SyncCounts;
22
+ }
23
+
24
+ interface SyncOptions {
25
+ verbose?: boolean;
26
+ dryRun?: boolean;
27
+ }
28
+
29
+ function zeroCounts(): SyncCounts {
30
+ return { synced: 0, failed: 0 };
31
+ }
32
+
33
+ /**
34
+ * Build the sync payload for a single session — gathers all child entities
35
+ * (turns, tool_uses, hook_events, task_tags) and transforms them.
36
+ */
37
+ function buildSessionPayload(
38
+ repo: SessionRepo,
39
+ session: SessionRow,
40
+ ): SessionSyncPayload {
41
+ const turns = repo.getSessionTurns(session.id);
42
+ const toolUses = repo.getSessionToolUses(session.id) as import("../storage/db.js").ToolUseRow[];
43
+ const hookEvents = repo.getSessionHookEvents(session.id) as import("../storage/db.js").HookEventRow[];
44
+
45
+ // Build turn_id → turn_index lookup for this session
46
+ const turnLookup: TurnLookup = new Map(turns.map(t => [t.id, t.turn_index]));
47
+
48
+ // Get task tags for all turns in this session
49
+ const taskTags: import("../storage/db.js").TaskTagRow[] = [];
50
+ for (const turn of turns) {
51
+ const tags = repo.getTaskTagsForTurn(turn.id);
52
+ taskTags.push(...tags);
53
+ }
54
+
55
+ return {
56
+ session: transformSession(session),
57
+ turns: turns.map(transformTurn),
58
+ tool_uses: toolUses.map(row => transformToolUse(row, turnLookup)),
59
+ task_tags: taskTags.map(row => transformTaskTag(row, turnLookup)),
60
+ hook_events: hookEvents.map(transformHookEvent),
61
+ };
62
+ }
63
+
64
+ export async function runSync(
65
+ repo: SessionRepo,
66
+ client: ZozulApiClient,
67
+ opts: SyncOptions = {},
68
+ ): Promise<SyncResult> {
69
+ const result: SyncResult = {
70
+ sessions: zeroCounts(),
71
+ otel_metrics: zeroCounts(),
72
+ otel_events: zeroCounts(),
73
+ };
74
+
75
+ // ── Phase 1: Determine sessions to sync ──
76
+ const sessionsWatermark = repo.getSyncWatermark("sessions");
77
+ const turnsWatermark = repo.getSyncWatermark("turns");
78
+
79
+ // New sessions (by rowid)
80
+ const newSessions = repo.getUnsyncedSessions(sessionsWatermark);
81
+ const sessionIdsToSync = new Set(newSessions.map(s => s.id));
82
+
83
+ // Sessions with new turns
84
+ const peekTurns = repo.getTurnsAfter(turnsWatermark, BATCH_SIZE);
85
+ for (const t of peekTurns) sessionIdsToSync.add(t.session_id);
86
+
87
+ // ── Phase 2: Sync sessions (one request per session, includes all child data) ──
88
+ if (sessionIdsToSync.size > 0) {
89
+ const idsFromTurns = [...sessionIdsToSync].filter(id => !newSessions.some(s => s.id === id));
90
+ const extraSessions = repo.getSessionsByIds(idsFromTurns);
91
+ const allSessions = [...newSessions, ...extraSessions];
92
+
93
+ let maxSyncedRowid = sessionsWatermark;
94
+ let maxSyncedTurnId = turnsWatermark;
95
+
96
+ for (const session of allSessions) {
97
+ if (opts.dryRun) {
98
+ result.sessions.synced++;
99
+ continue;
100
+ }
101
+
102
+ const payload = buildSessionPayload(repo, session);
103
+
104
+ try {
105
+ const resp = await client.syncSession(session.id, payload);
106
+ result.sessions.synced++;
107
+
108
+ // Track watermarks
109
+ const rowid = (session as { _rowid?: number })._rowid;
110
+ if (rowid != null && rowid > maxSyncedRowid) maxSyncedRowid = rowid;
111
+
112
+ // Track max turn id for this session's turns
113
+ const sessionTurns = repo.getSessionTurns(session.id);
114
+ for (const t of sessionTurns) {
115
+ if (t.id > maxSyncedTurnId) maxSyncedTurnId = t.id;
116
+ }
117
+
118
+ if (opts.verbose) {
119
+ console.log(
120
+ ` ${session.id}: ${resp.turns_synced} turns, ` +
121
+ `${resp.tool_uses_synced} tool_uses, ${resp.task_tags_synced} tags, ` +
122
+ `${resp.hook_events_synced} hook_events`
123
+ );
124
+ }
125
+ } catch (err) {
126
+ result.sessions.failed++;
127
+ if (opts.verbose) {
128
+ console.error(` ${session.id}: failed — ${err instanceof Error ? err.message : err}`);
129
+ }
130
+ }
131
+ }
132
+
133
+ if (!opts.dryRun) {
134
+ if (maxSyncedRowid > sessionsWatermark) {
135
+ repo.setSyncWatermark("sessions", maxSyncedRowid);
136
+ }
137
+ if (maxSyncedTurnId > turnsWatermark) {
138
+ repo.setSyncWatermark("turns", maxSyncedTurnId);
139
+ }
140
+ }
141
+ }
142
+
143
+ // ── Phase 3: Sync OTel data (not session-scoped, uses separate bulk endpoints) ──
144
+ result.otel_metrics = await syncBulkTable(
145
+ "otel_metrics", repo,
146
+ (minId, limit) => repo.getOtelMetricsAfter(minId, limit),
147
+ transformOtelMetric,
148
+ (items) => client.postOtelMetricsBulk(items),
149
+ opts,
150
+ );
151
+
152
+ result.otel_events = await syncBulkTable(
153
+ "otel_events", repo,
154
+ (minId, limit) => repo.getOtelEventsAfter(minId, limit),
155
+ transformOtelEvent,
156
+ (items) => client.postOtelEventsBulk(items),
157
+ opts,
158
+ );
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Generic helper for append-only bulk tables (otel_metrics, otel_events).
165
+ */
166
+ async function syncBulkTable<TRow extends { id: number }, TApi>(
167
+ tableName: string,
168
+ repo: SessionRepo,
169
+ readAfter: (minId: number, limit: number) => TRow[],
170
+ transform: (row: TRow) => TApi,
171
+ postBulk: (items: TApi[]) => Promise<void>,
172
+ opts: SyncOptions,
173
+ ): Promise<SyncCounts> {
174
+ const counts = zeroCounts();
175
+ let watermark = repo.getSyncWatermark(tableName);
176
+
177
+ for (;;) {
178
+ const rows = readAfter(watermark, BATCH_SIZE);
179
+ if (rows.length === 0) break;
180
+
181
+ if (opts.dryRun) {
182
+ counts.synced += rows.length;
183
+ watermark = rows[rows.length - 1].id;
184
+ continue;
185
+ }
186
+
187
+ const payload = rows.map(transform);
188
+ try {
189
+ await postBulk(payload);
190
+ const newWatermark = rows[rows.length - 1].id;
191
+ repo.setSyncWatermark(tableName, newWatermark);
192
+ watermark = newWatermark;
193
+ counts.synced += rows.length;
194
+ if (opts.verbose) {
195
+ console.log(` ${tableName}: synced ${rows.length} rows (watermark → ${newWatermark})`);
196
+ }
197
+ } catch (err) {
198
+ counts.failed += rows.length;
199
+ if (opts.verbose) {
200
+ console.error(` ${tableName}: batch failed — ${err instanceof Error ? err.message : err}`);
201
+ }
202
+ break;
203
+ }
204
+ }
205
+
206
+ return counts;
207
+ }
@@ -0,0 +1,447 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import http from "node:http";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { getDb } from "../storage/db.js";
7
+ import { SessionRepo } from "../storage/repo.js";
8
+ import { ZozulApiClient } from "./client.js";
9
+ import { runSync } from "./index.js";
10
+
11
+ // ── Mock server that collects POSTed payloads ──
12
+
13
+ function createMockServer() {
14
+ const received: Record<string, unknown[]> = {};
15
+
16
+ const server = http.createServer(async (req, res) => {
17
+ if (req.method !== "POST") {
18
+ res.writeHead(405).end();
19
+ return;
20
+ }
21
+
22
+ const chunks: Buffer[] = [];
23
+ for await (const chunk of req) chunks.push(chunk as Buffer);
24
+ const body = JSON.parse(Buffer.concat(chunks).toString());
25
+
26
+ const route = req.url!;
27
+ if (!received[route]) received[route] = [];
28
+
29
+ if (Array.isArray(body)) {
30
+ received[route].push(...body);
31
+ } else {
32
+ received[route].push(body);
33
+ }
34
+
35
+ // Return a sync-style response for session sync endpoints
36
+ if (route.includes("/sync")) {
37
+ const payload = body as { turns?: unknown[]; tool_uses?: unknown[]; task_tags?: unknown[]; hook_events?: unknown[] };
38
+ res.writeHead(200, { "Content-Type": "application/json" });
39
+ res.end(JSON.stringify({
40
+ session_id: route.split("/")[4], // /api/v1/sessions/{id}/sync
41
+ turns_synced: payload.turns?.length ?? 0,
42
+ tool_uses_synced: payload.tool_uses?.length ?? 0,
43
+ task_tags_synced: payload.task_tags?.length ?? 0,
44
+ hook_events_synced: payload.hook_events?.length ?? 0,
45
+ }));
46
+ return;
47
+ }
48
+
49
+ res.writeHead(200, { "Content-Type": "application/json" });
50
+ res.end(JSON.stringify({ ok: true }));
51
+ });
52
+
53
+ return { server, received };
54
+ }
55
+
56
+ function listen(server: http.Server): Promise<number> {
57
+ return new Promise((resolve) => {
58
+ server.listen(0, "127.0.0.1", () => {
59
+ const addr = server.address() as { port: number };
60
+ resolve(addr.port);
61
+ });
62
+ });
63
+ }
64
+
65
+ function close(server: http.Server): Promise<void> {
66
+ return new Promise((resolve) => server.close(() => resolve()));
67
+ }
68
+
69
+ // ── Test suite ──
70
+
71
+ describe("zozul sync e2e", () => {
72
+ let dbPath: string;
73
+ let repo: SessionRepo;
74
+ let db: ReturnType<typeof getDb>;
75
+ let server: http.Server;
76
+ let received: Record<string, unknown[]>;
77
+ let client: ZozulApiClient;
78
+
79
+ beforeEach(async () => {
80
+ dbPath = path.join(os.tmpdir(), `zozul-test-${Date.now()}.db`);
81
+ db = getDb(dbPath);
82
+ repo = new SessionRepo(db);
83
+
84
+ const mock = createMockServer();
85
+ server = mock.server;
86
+ received = mock.received;
87
+ const port = await listen(server);
88
+ client = new ZozulApiClient({
89
+ apiUrl: `http://127.0.0.1:${port}`,
90
+ apiKey: "test-key",
91
+ });
92
+ });
93
+
94
+ afterEach(async () => {
95
+ db.close();
96
+ await close(server);
97
+ try { fs.unlinkSync(dbPath); } catch {}
98
+ try { fs.unlinkSync(dbPath + "-wal"); } catch {}
99
+ try { fs.unlinkSync(dbPath + "-shm"); } catch {}
100
+ });
101
+
102
+ it("syncs a session with all child data in one request", async () => {
103
+ // Seed session
104
+ repo.upsertSession({
105
+ id: "sess-001",
106
+ project_path: "/projects/test",
107
+ started_at: "2026-03-28T10:00:00Z",
108
+ total_input_tokens: 100,
109
+ total_output_tokens: 50,
110
+ total_cache_read_tokens: 20,
111
+ total_cache_creation_tokens: 5,
112
+ total_cost_usd: 0.01,
113
+ total_turns: 2,
114
+ total_duration_ms: 5000,
115
+ model: "claude-sonnet-4-6",
116
+ });
117
+
118
+ // Seed turns
119
+ const turnId = repo.insertTurn({
120
+ session_id: "sess-001",
121
+ turn_index: 0,
122
+ role: "human",
123
+ timestamp: "2026-03-28T10:00:01Z",
124
+ input_tokens: 50,
125
+ output_tokens: 0,
126
+ cache_read_tokens: 10,
127
+ cache_creation_tokens: 0,
128
+ cost_usd: 0.005,
129
+ duration_ms: 1000,
130
+ model: "claude-sonnet-4-6",
131
+ content_text: "Hello",
132
+ tool_calls: null,
133
+ is_real_user: 1,
134
+ });
135
+
136
+ repo.insertTurn({
137
+ session_id: "sess-001",
138
+ turn_index: 1,
139
+ role: "assistant",
140
+ timestamp: "2026-03-28T10:00:02Z",
141
+ input_tokens: 50,
142
+ output_tokens: 50,
143
+ cache_read_tokens: 10,
144
+ cache_creation_tokens: 5,
145
+ cost_usd: 0.005,
146
+ duration_ms: 4000,
147
+ model: "claude-sonnet-4-6",
148
+ content_text: "Hi there!",
149
+ tool_calls: JSON.stringify([{ toolName: "Read", toolInput: { path: "/tmp" } }]),
150
+ is_real_user: 0,
151
+ });
152
+
153
+ // Seed tool use
154
+ repo.insertToolUse({
155
+ session_id: "sess-001",
156
+ turn_id: turnId,
157
+ tool_name: "Read",
158
+ tool_input: JSON.stringify({ path: "/tmp" }),
159
+ tool_result: "file contents",
160
+ success: 1,
161
+ duration_ms: 200,
162
+ timestamp: "2026-03-28T10:00:02Z",
163
+ });
164
+
165
+ // Seed hook event
166
+ repo.insertHookEvent({
167
+ session_id: "sess-001",
168
+ event_name: "UserPromptSubmit",
169
+ timestamp: "2026-03-28T10:00:01Z",
170
+ payload: JSON.stringify({ prompt: "Hello" }),
171
+ });
172
+
173
+ // Seed otel metric
174
+ repo.insertOtelMetric({
175
+ name: "claude_code.token.usage",
176
+ value: 100,
177
+ attributes: JSON.stringify({ type: "input" }),
178
+ session_id: "sess-001",
179
+ model: "claude-sonnet-4-6",
180
+ timestamp: "2026-03-28T10:00:02Z",
181
+ });
182
+
183
+ // Seed otel event
184
+ repo.insertOtelEvent({
185
+ event_name: "claude_code.conversation.turn",
186
+ attributes: JSON.stringify({ role: "human" }),
187
+ session_id: "sess-001",
188
+ prompt_id: "prompt-001",
189
+ timestamp: "2026-03-28T10:00:01Z",
190
+ });
191
+
192
+ // Seed task tag
193
+ repo.tagTurn(turnId, "FEAT-123");
194
+
195
+ // ── Run sync ──
196
+ const result = await runSync(repo, client, { verbose: false });
197
+
198
+ // ── Verify counts ──
199
+ expect(result.sessions.synced).toBe(1);
200
+ expect(result.sessions.failed).toBe(0);
201
+ expect(result.otel_metrics.synced).toBe(1);
202
+ expect(result.otel_events.synced).toBe(1);
203
+
204
+ // ── Verify session sync payload ──
205
+ const syncPayloads = received["/api/v1/sessions/sess-001/sync"];
206
+ expect(syncPayloads).toHaveLength(1);
207
+ const payload = syncPayloads[0] as Record<string, unknown>;
208
+
209
+ // Session
210
+ expect(payload.session).toMatchObject({ id: "sess-001", model: "claude-sonnet-4-6" });
211
+
212
+ // Turns — uses turn_index, no session_id (session is implicit)
213
+ const turns = payload.turns as Record<string, unknown>[];
214
+ expect(turns).toHaveLength(2);
215
+ expect(turns[0]).toMatchObject({ turn_index: 0, is_real_user: true });
216
+ expect(turns[1]).toMatchObject({ turn_index: 1, is_real_user: false });
217
+ expect(turns[1].tool_calls).toEqual([{ toolName: "Read", toolInput: { path: "/tmp" } }]);
218
+
219
+ // Tool uses — uses turn_index instead of turn_id
220
+ const toolUses = payload.tool_uses as Record<string, unknown>[];
221
+ expect(toolUses).toHaveLength(1);
222
+ expect(toolUses[0]).toMatchObject({ tool_name: "Read", success: true, turn_index: 0 });
223
+ expect(toolUses[0].tool_input).toEqual({ path: "/tmp" });
224
+ expect(toolUses[0]).not.toHaveProperty("turn_id");
225
+ expect(toolUses[0]).not.toHaveProperty("session_id");
226
+
227
+ // Hook events
228
+ const hookEvents = payload.hook_events as Record<string, unknown>[];
229
+ expect(hookEvents).toHaveLength(1);
230
+ expect(hookEvents[0]).toMatchObject({ event_name: "UserPromptSubmit" });
231
+ expect(hookEvents[0].payload).toEqual({ prompt: "Hello" });
232
+
233
+ // Task tags — uses turn_index
234
+ const taskTags = payload.task_tags as Record<string, unknown>[];
235
+ expect(taskTags).toHaveLength(1);
236
+ expect(taskTags[0]).toMatchObject({ task: "FEAT-123", turn_index: 0 });
237
+
238
+ // OTel sent separately
239
+ const otelMetrics = received["/api/v1/otel/metrics/bulk"] as Record<string, unknown>[];
240
+ expect(otelMetrics).toHaveLength(1);
241
+ expect(otelMetrics[0]).toMatchObject({ name: "claude_code.token.usage", value: 100 });
242
+
243
+ const otelEvents = received["/api/v1/otel/events/bulk"] as Record<string, unknown>[];
244
+ expect(otelEvents).toHaveLength(1);
245
+ expect(otelEvents[0]).toMatchObject({ event_name: "claude_code.conversation.turn" });
246
+ });
247
+
248
+ it("is idempotent — second sync sends nothing", async () => {
249
+ repo.upsertSession({
250
+ id: "sess-002",
251
+ project_path: null,
252
+ started_at: "2026-03-28T11:00:00Z",
253
+ total_input_tokens: 10,
254
+ total_output_tokens: 10,
255
+ total_cache_read_tokens: 0,
256
+ total_cache_creation_tokens: 0,
257
+ total_cost_usd: 0.001,
258
+ total_turns: 1,
259
+ total_duration_ms: 500,
260
+ model: "claude-haiku-4-5",
261
+ });
262
+
263
+ repo.insertTurn({
264
+ session_id: "sess-002",
265
+ turn_index: 0,
266
+ role: "human",
267
+ timestamp: "2026-03-28T11:00:01Z",
268
+ input_tokens: 10,
269
+ output_tokens: 10,
270
+ cache_read_tokens: 0,
271
+ cache_creation_tokens: 0,
272
+ cost_usd: 0.001,
273
+ duration_ms: 500,
274
+ model: "claude-haiku-4-5",
275
+ content_text: "test",
276
+ tool_calls: null,
277
+ is_real_user: 1,
278
+ });
279
+
280
+ // First sync
281
+ const r1 = await runSync(repo, client, {});
282
+ expect(r1.sessions.synced).toBe(1);
283
+
284
+ // Clear received payloads
285
+ for (const key of Object.keys(received)) delete received[key];
286
+
287
+ // Second sync
288
+ const r2 = await runSync(repo, client, {});
289
+ expect(r2.sessions.synced).toBe(0);
290
+ expect(r2.otel_metrics.synced).toBe(0);
291
+ expect(r2.otel_events.synced).toBe(0);
292
+ expect(Object.keys(received)).toHaveLength(0);
293
+ });
294
+
295
+ it("incremental sync picks up only new sessions", async () => {
296
+ repo.upsertSession({
297
+ id: "sess-A",
298
+ project_path: null,
299
+ started_at: "2026-03-28T12:00:00Z",
300
+ total_input_tokens: 10,
301
+ total_output_tokens: 10,
302
+ total_cache_read_tokens: 0,
303
+ total_cache_creation_tokens: 0,
304
+ total_cost_usd: 0.001,
305
+ total_turns: 1,
306
+ total_duration_ms: 100,
307
+ model: null,
308
+ });
309
+ repo.insertTurn({
310
+ session_id: "sess-A",
311
+ turn_index: 0,
312
+ role: "human",
313
+ timestamp: "2026-03-28T12:00:01Z",
314
+ input_tokens: 10,
315
+ output_tokens: 10,
316
+ cache_read_tokens: 0,
317
+ cache_creation_tokens: 0,
318
+ cost_usd: 0.001,
319
+ duration_ms: 100,
320
+ model: null,
321
+ content_text: "first",
322
+ tool_calls: null,
323
+ is_real_user: 1,
324
+ });
325
+
326
+ await runSync(repo, client, {});
327
+ for (const key of Object.keys(received)) delete received[key];
328
+
329
+ // Add second session
330
+ repo.upsertSession({
331
+ id: "sess-B",
332
+ project_path: "/new",
333
+ started_at: "2026-03-28T13:00:00Z",
334
+ total_input_tokens: 20,
335
+ total_output_tokens: 20,
336
+ total_cache_read_tokens: 0,
337
+ total_cache_creation_tokens: 0,
338
+ total_cost_usd: 0.002,
339
+ total_turns: 1,
340
+ total_duration_ms: 200,
341
+ model: "claude-opus-4-6",
342
+ });
343
+ repo.insertTurn({
344
+ session_id: "sess-B",
345
+ turn_index: 0,
346
+ role: "human",
347
+ timestamp: "2026-03-28T13:00:01Z",
348
+ input_tokens: 20,
349
+ output_tokens: 20,
350
+ cache_read_tokens: 0,
351
+ cache_creation_tokens: 0,
352
+ cost_usd: 0.002,
353
+ duration_ms: 200,
354
+ model: "claude-opus-4-6",
355
+ content_text: "second",
356
+ tool_calls: null,
357
+ is_real_user: 1,
358
+ });
359
+
360
+ const r2 = await runSync(repo, client, {});
361
+ expect(r2.sessions.synced).toBe(1);
362
+
363
+ // Only sess-B should have been synced
364
+ expect(received["/api/v1/sessions/sess-B/sync"]).toHaveLength(1);
365
+ expect(received["/api/v1/sessions/sess-A/sync"]).toBeUndefined();
366
+ });
367
+
368
+ it("dry-run does not send data or update watermarks", async () => {
369
+ repo.upsertSession({
370
+ id: "sess-dry",
371
+ project_path: null,
372
+ started_at: "2026-03-28T14:00:00Z",
373
+ total_input_tokens: 5,
374
+ total_output_tokens: 5,
375
+ total_cache_read_tokens: 0,
376
+ total_cache_creation_tokens: 0,
377
+ total_cost_usd: 0.0005,
378
+ total_turns: 1,
379
+ total_duration_ms: 50,
380
+ model: null,
381
+ });
382
+ repo.insertTurn({
383
+ session_id: "sess-dry",
384
+ turn_index: 0,
385
+ role: "human",
386
+ timestamp: "2026-03-28T14:00:01Z",
387
+ input_tokens: 5,
388
+ output_tokens: 5,
389
+ cache_read_tokens: 0,
390
+ cache_creation_tokens: 0,
391
+ cost_usd: 0.0005,
392
+ duration_ms: 50,
393
+ model: null,
394
+ content_text: "dry",
395
+ tool_calls: null,
396
+ is_real_user: 1,
397
+ });
398
+
399
+ const result = await runSync(repo, client, { dryRun: true });
400
+ expect(result.sessions.synced).toBe(1);
401
+ expect(Object.keys(received)).toHaveLength(0);
402
+ expect(repo.getSyncWatermark("sessions")).toBe(0);
403
+
404
+ // Real sync after dry run should still work
405
+ const r2 = await runSync(repo, client, {});
406
+ expect(r2.sessions.synced).toBe(1);
407
+ expect(received["/api/v1/sessions/sess-dry/sync"]).toHaveLength(1);
408
+ });
409
+
410
+ it("handles server errors gracefully", async () => {
411
+ repo.upsertSession({
412
+ id: "sess-err",
413
+ project_path: null,
414
+ started_at: "2026-03-28T15:00:00Z",
415
+ total_input_tokens: 10,
416
+ total_output_tokens: 10,
417
+ total_cache_read_tokens: 0,
418
+ total_cache_creation_tokens: 0,
419
+ total_cost_usd: 0.001,
420
+ total_turns: 1,
421
+ total_duration_ms: 100,
422
+ model: null,
423
+ });
424
+ repo.insertTurn({
425
+ session_id: "sess-err",
426
+ turn_index: 0,
427
+ role: "human",
428
+ timestamp: "2026-03-28T15:00:01Z",
429
+ input_tokens: 10,
430
+ output_tokens: 10,
431
+ cache_read_tokens: 0,
432
+ cache_creation_tokens: 0,
433
+ cost_usd: 0.001,
434
+ duration_ms: 100,
435
+ model: null,
436
+ content_text: "error test",
437
+ tool_calls: null,
438
+ is_real_user: 1,
439
+ });
440
+
441
+ await close(server);
442
+
443
+ const result = await runSync(repo, client, {});
444
+ expect(result.sessions.failed).toBe(1);
445
+ expect(repo.getSyncWatermark("sessions")).toBe(0);
446
+ });
447
+ });