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.
- package/.env.example +44 -0
- package/.github/workflows/publish.yml +26 -0
- package/DEVELOPMENT.md +288 -0
- package/LICENSE +201 -0
- package/README.md +178 -0
- package/dist/cli/commands.d.ts +3 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +307 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/format.js +115 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/context/index.d.ts +8 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +37 -0
- package/dist/context/index.js.map +1 -0
- package/dist/dashboard/html.d.ts +17 -0
- package/dist/dashboard/html.d.ts.map +1 -0
- package/dist/dashboard/html.js +79 -0
- package/dist/dashboard/html.js.map +1 -0
- package/dist/dashboard/index.html +1245 -0
- package/dist/hooks/config.d.ts +19 -0
- package/dist/hooks/config.d.ts.map +1 -0
- package/dist/hooks/config.js +106 -0
- package/dist/hooks/config.js.map +1 -0
- package/dist/hooks/git.d.ts +6 -0
- package/dist/hooks/git.d.ts.map +1 -0
- package/dist/hooks/git.js +73 -0
- package/dist/hooks/git.js.map +1 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/server.d.ts +16 -0
- package/dist/hooks/server.d.ts.map +1 -0
- package/dist/hooks/server.js +349 -0
- package/dist/hooks/server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/otel/config.d.ts +36 -0
- package/dist/otel/config.d.ts.map +1 -0
- package/dist/otel/config.js +109 -0
- package/dist/otel/config.js.map +1 -0
- package/dist/otel/index.d.ts +4 -0
- package/dist/otel/index.d.ts.map +1 -0
- package/dist/otel/index.js +3 -0
- package/dist/otel/index.js.map +1 -0
- package/dist/otel/receiver.d.ts +10 -0
- package/dist/otel/receiver.d.ts.map +1 -0
- package/dist/otel/receiver.js +155 -0
- package/dist/otel/receiver.js.map +1 -0
- package/dist/parser/index.d.ts +4 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +3 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/ingest.d.ts +20 -0
- package/dist/parser/ingest.d.ts.map +1 -0
- package/dist/parser/ingest.js +98 -0
- package/dist/parser/ingest.js.map +1 -0
- package/dist/parser/jsonl.d.ts +14 -0
- package/dist/parser/jsonl.d.ts.map +1 -0
- package/dist/parser/jsonl.js +202 -0
- package/dist/parser/jsonl.js.map +1 -0
- package/dist/parser/types.d.ts +81 -0
- package/dist/parser/types.d.ts.map +1 -0
- package/dist/parser/types.js +9 -0
- package/dist/parser/types.js.map +1 -0
- package/dist/parser/watcher.d.ts +16 -0
- package/dist/parser/watcher.d.ts.map +1 -0
- package/dist/parser/watcher.js +103 -0
- package/dist/parser/watcher.js.map +1 -0
- package/dist/pricing/index.d.ts +2 -0
- package/dist/pricing/index.d.ts.map +1 -0
- package/dist/pricing/index.js +37 -0
- package/dist/pricing/index.js.map +1 -0
- package/dist/service/index.d.ts +31 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +252 -0
- package/dist/service/index.js.map +1 -0
- package/dist/storage/db.d.ts +75 -0
- package/dist/storage/db.d.ts.map +1 -0
- package/dist/storage/db.js +117 -0
- package/dist/storage/db.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +3 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/repo.d.ts +162 -0
- package/dist/storage/repo.d.ts.map +1 -0
- package/dist/storage/repo.js +472 -0
- package/dist/storage/repo.js.map +1 -0
- package/dist/sync/client.d.ts +24 -0
- package/dist/sync/client.d.ts.map +1 -0
- package/dist/sync/client.js +41 -0
- package/dist/sync/client.js.map +1 -0
- package/dist/sync/index.d.ts +18 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +135 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/sync.test.d.ts +2 -0
- package/dist/sync/sync.test.d.ts.map +1 -0
- package/dist/sync/sync.test.js +412 -0
- package/dist/sync/sync.test.js.map +1 -0
- package/dist/sync/transform.d.ts +80 -0
- package/dist/sync/transform.d.ts.map +1 -0
- package/dist/sync/transform.js +90 -0
- package/dist/sync/transform.js.map +1 -0
- package/package.json +50 -0
- package/src/cli/commands.ts +332 -0
- package/src/cli/format.ts +133 -0
- package/src/context/index.ts +42 -0
- package/src/dashboard/html.ts +97 -0
- package/src/dashboard/index.html +1245 -0
- package/src/hooks/config.ts +119 -0
- package/src/hooks/git.ts +77 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/server.ts +397 -0
- package/src/index.ts +6 -0
- package/src/otel/config.ts +141 -0
- package/src/otel/index.ts +8 -0
- package/src/otel/receiver.ts +183 -0
- package/src/parser/index.ts +3 -0
- package/src/parser/ingest.ts +119 -0
- package/src/parser/jsonl.ts +241 -0
- package/src/parser/types.ts +89 -0
- package/src/parser/watcher.ts +116 -0
- package/src/pricing/index.ts +51 -0
- package/src/service/index.ts +272 -0
- package/src/storage/db.ts +198 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/repo.ts +601 -0
- package/src/sync/client.ts +63 -0
- package/src/sync/index.ts +207 -0
- package/src/sync/sync.test.ts +447 -0
- package/src/sync/transform.ts +184 -0
- 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
|
+
});
|