@seanxdo/superview 0.1.13
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/README.md +193 -0
- package/README.zh-CN.md +193 -0
- package/core/contextReplay.ts +388 -0
- package/core/cost.ts +125 -0
- package/core/hash.ts +5 -0
- package/core/history.ts +96 -0
- package/core/id.ts +6 -0
- package/core/normalizer.ts +720 -0
- package/core/parser.ts +53 -0
- package/core/redactor.ts +49 -0
- package/core/replay.ts +55 -0
- package/core/timeline.ts +350 -0
- package/core/types.ts +460 -0
- package/dist/ui/assets/index-BUbbOxsU.js +18 -0
- package/dist/ui/assets/index-DafedT5l.css +1 -0
- package/dist/ui/index.html +13 -0
- package/package.json +72 -0
- package/runtime-node/adapters/claude-code.ts +205 -0
- package/runtime-node/adapters/codex.ts +24 -0
- package/runtime-node/adapters/index.ts +18 -0
- package/runtime-node/adapters/opencode.ts +193 -0
- package/runtime-node/adapters/shared.ts +113 -0
- package/runtime-node/cli-ingest.ts +7 -0
- package/runtime-node/cli-start.js +15 -0
- package/runtime-node/cli-start.ts +9 -0
- package/runtime-node/dev-server.ts +6 -0
- package/runtime-node/git-provider.ts +102 -0
- package/runtime-node/history.ts +9 -0
- package/runtime-node/ingest-worker.ts +32 -0
- package/runtime-node/ingest.ts +362 -0
- package/runtime-node/prod-server.ts +24 -0
- package/runtime-node/scanner.ts +13 -0
- package/runtime-node/server.ts +183 -0
- package/storage/database.ts +1016 -0
- package/storage/paths.ts +20 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
AgentProvider,
|
|
6
|
+
Artifact,
|
|
7
|
+
CodexHistoryPrompt,
|
|
8
|
+
DailyTokenUsageResponse,
|
|
9
|
+
Episode,
|
|
10
|
+
EventEvidence,
|
|
11
|
+
GitCommitRecord,
|
|
12
|
+
IngestJob,
|
|
13
|
+
NormalizedBundle,
|
|
14
|
+
ProjectRecord,
|
|
15
|
+
RawEventRef,
|
|
16
|
+
RunReplay,
|
|
17
|
+
SessionRecord,
|
|
18
|
+
TaskJourneyDetail,
|
|
19
|
+
TokenUsage,
|
|
20
|
+
TimelineQuery,
|
|
21
|
+
TimelineEvent,
|
|
22
|
+
TurnRecord
|
|
23
|
+
} from "../core/types";
|
|
24
|
+
import { buildProjectTimeline, groupEpisodes } from "../core/timeline";
|
|
25
|
+
import { buildReplayNodes } from "../core/replay";
|
|
26
|
+
import { resolveDatabasePath } from "./paths";
|
|
27
|
+
|
|
28
|
+
const SCHEMA_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
type EventRow = Omit<TimelineEvent, "files" | "tokenUsage" | "skills"> & {
|
|
31
|
+
filesJson: string;
|
|
32
|
+
tokenUsageJson: string | null;
|
|
33
|
+
skillsJson: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export class SuperViewDatabase {
|
|
37
|
+
private db: Database.Database;
|
|
38
|
+
|
|
39
|
+
constructor(databasePath = resolveDatabasePath()) {
|
|
40
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
41
|
+
this.db = new Database(databasePath);
|
|
42
|
+
this.db.pragma("journal_mode = WAL");
|
|
43
|
+
this.db.pragma("busy_timeout = 5000");
|
|
44
|
+
this.db.pragma("foreign_keys = ON");
|
|
45
|
+
this.migrate();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
close() {
|
|
49
|
+
this.db.close();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
reset() {
|
|
53
|
+
this.db.exec(`
|
|
54
|
+
DROP TABLE IF EXISTS task_journey_skills;
|
|
55
|
+
DROP TABLE IF EXISTS causal_edges;
|
|
56
|
+
DROP TABLE IF EXISTS episodes;
|
|
57
|
+
DROP TABLE IF EXISTS task_journeys;
|
|
58
|
+
DROP TABLE IF EXISTS turn_skills;
|
|
59
|
+
DROP TABLE IF EXISTS turns;
|
|
60
|
+
DROP TABLE IF EXISTS events;
|
|
61
|
+
DROP TABLE IF EXISTS raw_event_refs;
|
|
62
|
+
DROP TABLE IF EXISTS artifacts;
|
|
63
|
+
DROP TABLE IF EXISTS git_commits;
|
|
64
|
+
DROP TABLE IF EXISTS history_prompts;
|
|
65
|
+
DROP TABLE IF EXISTS sessions;
|
|
66
|
+
DROP TABLE IF EXISTS ingested_files;
|
|
67
|
+
DROP TABLE IF EXISTS ingest_jobs;
|
|
68
|
+
DROP TABLE IF EXISTS projects;
|
|
69
|
+
DROP TABLE IF EXISTS schema_meta;
|
|
70
|
+
`);
|
|
71
|
+
this.migrate();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
migrate() {
|
|
75
|
+
this.db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
77
|
+
version INTEGER PRIMARY KEY,
|
|
78
|
+
updated_at TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
name TEXT NOT NULL,
|
|
84
|
+
cwd TEXT NOT NULL,
|
|
85
|
+
repo_root TEXT,
|
|
86
|
+
created_at TEXT NOT NULL,
|
|
87
|
+
updated_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
project_id TEXT NOT NULL,
|
|
93
|
+
path TEXT NOT NULL,
|
|
94
|
+
cwd TEXT NOT NULL,
|
|
95
|
+
started_at TEXT NOT NULL,
|
|
96
|
+
ended_at TEXT,
|
|
97
|
+
cli_version TEXT,
|
|
98
|
+
model_provider TEXT,
|
|
99
|
+
source TEXT,
|
|
100
|
+
provider TEXT NOT NULL DEFAULT 'codex',
|
|
101
|
+
external_session_id TEXT,
|
|
102
|
+
agent_name TEXT,
|
|
103
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
107
|
+
id TEXT PRIMARY KEY,
|
|
108
|
+
session_id TEXT NOT NULL,
|
|
109
|
+
started_at TEXT NOT NULL,
|
|
110
|
+
ended_at TEXT,
|
|
111
|
+
cwd TEXT,
|
|
112
|
+
model TEXT,
|
|
113
|
+
approval_policy TEXT,
|
|
114
|
+
sandbox_policy TEXT,
|
|
115
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS raw_event_refs (
|
|
119
|
+
id TEXT PRIMARY KEY,
|
|
120
|
+
session_id TEXT NOT NULL,
|
|
121
|
+
provider TEXT NOT NULL DEFAULT 'codex',
|
|
122
|
+
line_no INTEGER NOT NULL,
|
|
123
|
+
timestamp TEXT NOT NULL,
|
|
124
|
+
type TEXT NOT NULL,
|
|
125
|
+
redacted_payload_json TEXT NOT NULL,
|
|
126
|
+
source_path TEXT NOT NULL,
|
|
127
|
+
sha256 TEXT NOT NULL,
|
|
128
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
132
|
+
id TEXT PRIMARY KEY,
|
|
133
|
+
project_id TEXT NOT NULL,
|
|
134
|
+
session_id TEXT NOT NULL,
|
|
135
|
+
turn_id TEXT,
|
|
136
|
+
timestamp TEXT NOT NULL,
|
|
137
|
+
kind TEXT NOT NULL,
|
|
138
|
+
lane TEXT NOT NULL,
|
|
139
|
+
title TEXT NOT NULL,
|
|
140
|
+
detail TEXT,
|
|
141
|
+
tool_name TEXT,
|
|
142
|
+
call_id TEXT,
|
|
143
|
+
status TEXT NOT NULL,
|
|
144
|
+
files_json TEXT NOT NULL,
|
|
145
|
+
raw_event_ref_id TEXT,
|
|
146
|
+
duration_ms INTEGER,
|
|
147
|
+
output_event_id TEXT,
|
|
148
|
+
commit_hash TEXT,
|
|
149
|
+
token_usage_json TEXT,
|
|
150
|
+
skills_json TEXT,
|
|
151
|
+
FOREIGN KEY(project_id) REFERENCES projects(id),
|
|
152
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
156
|
+
id TEXT PRIMARY KEY,
|
|
157
|
+
event_id TEXT NOT NULL,
|
|
158
|
+
type TEXT NOT NULL,
|
|
159
|
+
path TEXT,
|
|
160
|
+
excerpt TEXT NOT NULL,
|
|
161
|
+
sha256 TEXT,
|
|
162
|
+
FOREIGN KEY(event_id) REFERENCES events(id)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS history_prompts (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
session_id TEXT NOT NULL,
|
|
168
|
+
ts TEXT NOT NULL,
|
|
169
|
+
text TEXT NOT NULL,
|
|
170
|
+
source_path TEXT NOT NULL,
|
|
171
|
+
line_no INTEGER NOT NULL,
|
|
172
|
+
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE TABLE IF NOT EXISTS git_commits (
|
|
176
|
+
id TEXT PRIMARY KEY,
|
|
177
|
+
project_id TEXT NOT NULL,
|
|
178
|
+
repo_root TEXT NOT NULL,
|
|
179
|
+
hash TEXT NOT NULL,
|
|
180
|
+
short_hash TEXT NOT NULL,
|
|
181
|
+
author_name TEXT,
|
|
182
|
+
author_email TEXT,
|
|
183
|
+
timestamp TEXT NOT NULL,
|
|
184
|
+
subject TEXT NOT NULL,
|
|
185
|
+
files_changed INTEGER NOT NULL,
|
|
186
|
+
insertions INTEGER NOT NULL,
|
|
187
|
+
deletions INTEGER NOT NULL,
|
|
188
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
CREATE TABLE IF NOT EXISTS episodes (
|
|
192
|
+
id TEXT PRIMARY KEY,
|
|
193
|
+
project_id TEXT NOT NULL,
|
|
194
|
+
started_at TEXT NOT NULL,
|
|
195
|
+
ended_at TEXT NOT NULL,
|
|
196
|
+
title TEXT NOT NULL,
|
|
197
|
+
summary TEXT NOT NULL,
|
|
198
|
+
status TEXT NOT NULL,
|
|
199
|
+
event_ids_json TEXT NOT NULL,
|
|
200
|
+
FOREIGN KEY(project_id) REFERENCES projects(id)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
CREATE TABLE IF NOT EXISTS ingested_files (
|
|
204
|
+
path TEXT PRIMARY KEY,
|
|
205
|
+
mtime_ms REAL NOT NULL,
|
|
206
|
+
size_bytes INTEGER NOT NULL,
|
|
207
|
+
sha256 TEXT,
|
|
208
|
+
session_id TEXT,
|
|
209
|
+
processor_version TEXT,
|
|
210
|
+
processed_at TEXT NOT NULL
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
CREATE TABLE IF NOT EXISTS ingest_jobs (
|
|
214
|
+
id TEXT PRIMARY KEY,
|
|
215
|
+
status TEXT NOT NULL,
|
|
216
|
+
phase TEXT NOT NULL DEFAULT 'queued',
|
|
217
|
+
started_at TEXT NOT NULL,
|
|
218
|
+
finished_at TEXT,
|
|
219
|
+
total_files INTEGER NOT NULL,
|
|
220
|
+
processed_files INTEGER NOT NULL,
|
|
221
|
+
total_events INTEGER NOT NULL,
|
|
222
|
+
skipped_files INTEGER NOT NULL DEFAULT 0,
|
|
223
|
+
candidate_files INTEGER NOT NULL DEFAULT 0,
|
|
224
|
+
changed_files INTEGER NOT NULL DEFAULT 0,
|
|
225
|
+
processed_bytes INTEGER NOT NULL DEFAULT 0,
|
|
226
|
+
total_bytes INTEGER NOT NULL DEFAULT 0,
|
|
227
|
+
current_file TEXT,
|
|
228
|
+
worker_pid INTEGER,
|
|
229
|
+
processor_version TEXT,
|
|
230
|
+
errors_json TEXT NOT NULL
|
|
231
|
+
);
|
|
232
|
+
`);
|
|
233
|
+
|
|
234
|
+
this.ensureColumn("ingest_jobs", "phase", "TEXT NOT NULL DEFAULT 'queued'");
|
|
235
|
+
this.ensureColumn("ingest_jobs", "skipped_files", "INTEGER NOT NULL DEFAULT 0");
|
|
236
|
+
this.ensureColumn("ingest_jobs", "candidate_files", "INTEGER NOT NULL DEFAULT 0");
|
|
237
|
+
this.ensureColumn("ingest_jobs", "changed_files", "INTEGER NOT NULL DEFAULT 0");
|
|
238
|
+
this.ensureColumn("ingest_jobs", "processed_bytes", "INTEGER NOT NULL DEFAULT 0");
|
|
239
|
+
this.ensureColumn("ingest_jobs", "total_bytes", "INTEGER NOT NULL DEFAULT 0");
|
|
240
|
+
this.ensureColumn("ingest_jobs", "current_file", "TEXT");
|
|
241
|
+
this.ensureColumn("ingest_jobs", "worker_pid", "INTEGER");
|
|
242
|
+
this.ensureColumn("ingest_jobs", "processor_version", "TEXT");
|
|
243
|
+
this.ensureColumn("events", "duration_ms", "INTEGER");
|
|
244
|
+
this.ensureColumn("events", "output_event_id", "TEXT");
|
|
245
|
+
this.ensureColumn("events", "commit_hash", "TEXT");
|
|
246
|
+
this.ensureColumn("events", "token_usage_json", "TEXT");
|
|
247
|
+
this.ensureColumn("events", "skills_json", "TEXT");
|
|
248
|
+
this.ensureColumn("ingested_files", "processor_version", "TEXT");
|
|
249
|
+
this.ensureColumn("sessions", "provider", "TEXT NOT NULL DEFAULT 'codex'");
|
|
250
|
+
this.ensureColumn("sessions", "external_session_id", "TEXT");
|
|
251
|
+
this.ensureColumn("sessions", "agent_name", "TEXT");
|
|
252
|
+
this.ensureColumn("raw_event_refs", "provider", "TEXT NOT NULL DEFAULT 'codex'");
|
|
253
|
+
this.db.exec(`
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_events_project_id ON events(project_id);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_events_project_timestamp ON events(project_id, timestamp);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
|
|
257
|
+
CREATE INDEX IF NOT EXISTS idx_events_raw_event_ref_id ON events(raw_event_ref_id);
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project_id ON sessions(project_id);
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_episodes_project_id ON episodes(project_id);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_event_id ON artifacts(event_id);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_raw_event_refs_source_path ON raw_event_refs(source_path);
|
|
262
|
+
`);
|
|
263
|
+
this.db.prepare("INSERT OR REPLACE INTO schema_meta(version, updated_at) VALUES (?, ?)").run(SCHEMA_VERSION, new Date().toISOString());
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
upsertBundle(bundle: NormalizedBundle) {
|
|
267
|
+
const tx = this.db.transaction(() => {
|
|
268
|
+
for (const sourcePath of new Set(bundle.rawEventRefs.map((raw) => raw.sourcePath))) {
|
|
269
|
+
this.deleteRawSource(sourcePath);
|
|
270
|
+
}
|
|
271
|
+
this.upsertProject(bundle.project);
|
|
272
|
+
this.upsertSession(bundle.session);
|
|
273
|
+
for (const turn of bundle.turns) this.upsertTurn(turn);
|
|
274
|
+
for (const raw of bundle.rawEventRefs) this.upsertRawEvent(raw);
|
|
275
|
+
for (const event of bundle.events) this.upsertEvent(event);
|
|
276
|
+
for (const prompt of bundle.historyPrompts ?? []) this.upsertHistoryPrompt(prompt);
|
|
277
|
+
for (const commit of bundle.gitCommits ?? []) this.upsertGitCommit(bundle.project.id, bundle.session.id, commit);
|
|
278
|
+
for (const artifact of bundle.artifacts) this.upsertArtifact(artifact);
|
|
279
|
+
for (const artifact of this.gitArtifactsForCommits(bundle.project.id, bundle.gitCommits ?? [])) this.upsertArtifact(artifact);
|
|
280
|
+
this.replaceProjectEpisodes(bundle.project.id, groupEpisodes(bundle.project.id, this.listEvents(bundle.project.id)));
|
|
281
|
+
});
|
|
282
|
+
tx();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private ensureColumn(table: string, column: string, definition: string) {
|
|
286
|
+
const columns = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
287
|
+
if (!columns.some((row) => row.name === column)) {
|
|
288
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
upsertProject(project: ProjectRecord) {
|
|
293
|
+
this.db
|
|
294
|
+
.prepare(
|
|
295
|
+
`INSERT INTO projects(id, name, cwd, repo_root, created_at, updated_at)
|
|
296
|
+
VALUES (@id, @name, @cwd, @repoRoot, @createdAt, @updatedAt)
|
|
297
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
298
|
+
name=excluded.name,
|
|
299
|
+
cwd=excluded.cwd,
|
|
300
|
+
repo_root=excluded.repo_root,
|
|
301
|
+
updated_at=excluded.updated_at`
|
|
302
|
+
)
|
|
303
|
+
.run(project);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
upsertSession(session: SessionRecord) {
|
|
307
|
+
this.db
|
|
308
|
+
.prepare(
|
|
309
|
+
`INSERT INTO sessions(id, project_id, path, cwd, started_at, ended_at, cli_version, model_provider, source, provider, external_session_id, agent_name)
|
|
310
|
+
VALUES (@id, @projectId, @path, @cwd, @startedAt, @endedAt, @cliVersion, @modelProvider, @source, @provider, @externalSessionId, @agentName)
|
|
311
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
312
|
+
project_id=excluded.project_id,
|
|
313
|
+
path=excluded.path,
|
|
314
|
+
cwd=excluded.cwd,
|
|
315
|
+
ended_at=excluded.ended_at,
|
|
316
|
+
cli_version=excluded.cli_version,
|
|
317
|
+
model_provider=excluded.model_provider,
|
|
318
|
+
source=excluded.source,
|
|
319
|
+
provider=excluded.provider,
|
|
320
|
+
external_session_id=excluded.external_session_id,
|
|
321
|
+
agent_name=excluded.agent_name`
|
|
322
|
+
)
|
|
323
|
+
.run(session);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
upsertTurn(turn: TurnRecord) {
|
|
327
|
+
this.db
|
|
328
|
+
.prepare(
|
|
329
|
+
`INSERT INTO turns(id, session_id, started_at, ended_at, cwd, model, approval_policy, sandbox_policy)
|
|
330
|
+
VALUES (@id, @sessionId, @startedAt, @endedAt, @cwd, @model, @approvalPolicy, @sandboxPolicy)
|
|
331
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
332
|
+
ended_at=excluded.ended_at,
|
|
333
|
+
cwd=excluded.cwd,
|
|
334
|
+
model=excluded.model,
|
|
335
|
+
approval_policy=excluded.approval_policy,
|
|
336
|
+
sandbox_policy=excluded.sandbox_policy`
|
|
337
|
+
)
|
|
338
|
+
.run(turn);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
upsertRawEvent(raw: RawEventRef) {
|
|
342
|
+
this.db
|
|
343
|
+
.prepare(
|
|
344
|
+
`INSERT OR REPLACE INTO raw_event_refs(id, session_id, provider, line_no, timestamp, type, redacted_payload_json, source_path, sha256)
|
|
345
|
+
VALUES (@id, @sessionId, @provider, @lineNo, @timestamp, @type, @redactedPayloadJson, @sourcePath, @sha256)`
|
|
346
|
+
)
|
|
347
|
+
.run(raw);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
upsertEvent(event: TimelineEvent) {
|
|
351
|
+
this.db
|
|
352
|
+
.prepare(
|
|
353
|
+
`INSERT OR REPLACE INTO events(id, project_id, session_id, turn_id, timestamp, kind, lane, title, detail, tool_name, call_id, status, files_json, raw_event_ref_id, duration_ms, output_event_id, commit_hash, token_usage_json, skills_json)
|
|
354
|
+
VALUES (@id, @projectId, @sessionId, @turnId, @timestamp, @kind, @lane, @title, @detail, @toolName, @callId, @status, @filesJson, @rawEventRefId, @durationMs, @outputEventId, @commitHash, @tokenUsageJson, @skillsJson)`
|
|
355
|
+
)
|
|
356
|
+
.run({
|
|
357
|
+
...event,
|
|
358
|
+
filesJson: JSON.stringify(event.files),
|
|
359
|
+
durationMs: event.durationMs ?? null,
|
|
360
|
+
outputEventId: event.outputEventId ?? null,
|
|
361
|
+
commitHash: event.commitHash ?? null,
|
|
362
|
+
tokenUsageJson: event.tokenUsage ? JSON.stringify(event.tokenUsage) : null,
|
|
363
|
+
skillsJson: event.skills && event.skills.length > 0 ? JSON.stringify(event.skills) : null
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
upsertHistoryPrompt(prompt: CodexHistoryPrompt) {
|
|
368
|
+
this.db
|
|
369
|
+
.prepare(
|
|
370
|
+
`INSERT OR REPLACE INTO history_prompts(id, session_id, ts, text, source_path, line_no)
|
|
371
|
+
VALUES (@id, @sessionId, @ts, @text, @sourcePath, @lineNo)`
|
|
372
|
+
)
|
|
373
|
+
.run({
|
|
374
|
+
id: `${prompt.sessionId}:${prompt.lineNo}`,
|
|
375
|
+
...prompt
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
upsertGitCommit(projectId: string, sessionId: string, commit: GitCommitRecord) {
|
|
380
|
+
this.db
|
|
381
|
+
.prepare(
|
|
382
|
+
`INSERT OR REPLACE INTO git_commits(id, project_id, repo_root, hash, short_hash, author_name, author_email, timestamp, subject, files_changed, insertions, deletions)
|
|
383
|
+
VALUES (@id, @projectId, @repoRoot, @hash, @shortHash, @authorName, @authorEmail, @timestamp, @subject, @filesChanged, @insertions, @deletions)`
|
|
384
|
+
)
|
|
385
|
+
.run({ ...commit, projectId, id: `${projectId}:${commit.hash}` });
|
|
386
|
+
|
|
387
|
+
this.upsertEvent({
|
|
388
|
+
id: `git_${projectId}_${commit.shortHash}`,
|
|
389
|
+
projectId,
|
|
390
|
+
sessionId,
|
|
391
|
+
turnId: null,
|
|
392
|
+
timestamp: commit.timestamp,
|
|
393
|
+
kind: "file_change",
|
|
394
|
+
lane: "Code",
|
|
395
|
+
title: `Git commit ${commit.shortHash}: ${commit.subject}`,
|
|
396
|
+
detail: `${commit.filesChanged} files, +${commit.insertions}/-${commit.deletions}`,
|
|
397
|
+
toolName: "git",
|
|
398
|
+
callId: null,
|
|
399
|
+
status: "success",
|
|
400
|
+
files: [],
|
|
401
|
+
rawEventRefId: null,
|
|
402
|
+
durationMs: null,
|
|
403
|
+
outputEventId: null,
|
|
404
|
+
commitHash: commit.hash,
|
|
405
|
+
tokenUsage: null,
|
|
406
|
+
skills: []
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private gitArtifactsForCommits(projectId: string, commits: GitCommitRecord[]): Artifact[] {
|
|
411
|
+
return commits.map((commit) => ({
|
|
412
|
+
id: `artifact_git_${projectId}_${commit.hash}`,
|
|
413
|
+
eventId: `git_${projectId}_${commit.shortHash}`,
|
|
414
|
+
type: "git",
|
|
415
|
+
path: commit.repoRoot,
|
|
416
|
+
excerpt: `${commit.hash}\n${commit.subject}\n${commit.filesChanged} files changed, ${commit.insertions} insertions, ${commit.deletions} deletions`,
|
|
417
|
+
sha256: commit.hash
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
upsertArtifact(artifact: Artifact) {
|
|
422
|
+
this.db
|
|
423
|
+
.prepare(
|
|
424
|
+
`INSERT OR REPLACE INTO artifacts(id, event_id, type, path, excerpt, sha256)
|
|
425
|
+
VALUES (@id, @eventId, @type, @path, @excerpt, @sha256)`
|
|
426
|
+
)
|
|
427
|
+
.run(artifact);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
upsertEpisodes(episodes: Episode[]) {
|
|
431
|
+
const tx = this.db.transaction(() => {
|
|
432
|
+
for (const episode of episodes) {
|
|
433
|
+
this.db
|
|
434
|
+
.prepare(
|
|
435
|
+
`INSERT OR REPLACE INTO episodes(id, project_id, started_at, ended_at, title, summary, status, event_ids_json)
|
|
436
|
+
VALUES (@id, @projectId, @startedAt, @endedAt, @title, @summary, @status, @eventIdsJson)`
|
|
437
|
+
)
|
|
438
|
+
.run({ ...episode, eventIdsJson: JSON.stringify(episode.eventIds) });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
tx();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
pruneMissingIngestedFiles(providers: AgentProvider[], retainedSourceIds: Set<string>): number {
|
|
445
|
+
const uniqueProviders = Array.from(new Set(providers));
|
|
446
|
+
if (uniqueProviders.length === 0) return 0;
|
|
447
|
+
const clauses = uniqueProviders.map(() => "path LIKE ?").join(" OR ");
|
|
448
|
+
const rows = this.db.prepare(`SELECT path FROM ingested_files WHERE ${clauses}`).all(...uniqueProviders.map((provider) => `${provider}:%`)) as Array<{ path: string }>;
|
|
449
|
+
const staleRows = rows.filter((row) => !retainedSourceIds.has(row.path));
|
|
450
|
+
if (staleRows.length === 0) return 0;
|
|
451
|
+
const tx = this.db.transaction(() => {
|
|
452
|
+
for (const row of staleRows) {
|
|
453
|
+
this.deleteIngestedSource(row.path);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
tx();
|
|
457
|
+
return staleRows.length;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private replaceProjectEpisodes(projectId: string, episodes: Episode[]) {
|
|
461
|
+
this.db.prepare("DELETE FROM episodes WHERE project_id = ?").run(projectId);
|
|
462
|
+
for (const episode of episodes) {
|
|
463
|
+
this.db
|
|
464
|
+
.prepare(
|
|
465
|
+
`INSERT OR REPLACE INTO episodes(id, project_id, started_at, ended_at, title, summary, status, event_ids_json)
|
|
466
|
+
VALUES (@id, @projectId, @startedAt, @endedAt, @title, @summary, @status, @eventIdsJson)`
|
|
467
|
+
)
|
|
468
|
+
.run({ ...episode, eventIdsJson: JSON.stringify(episode.eventIds) });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private deleteIngestedSource(sourceId: string) {
|
|
473
|
+
const sourcePath = sourcePathFromIngestedId(sourceId);
|
|
474
|
+
this.deleteRawSource(sourcePath);
|
|
475
|
+
this.db.prepare("DELETE FROM ingested_files WHERE path = ?").run(sourceId);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private deleteRawSource(sourcePath: string) {
|
|
479
|
+
const projectRows = this.db
|
|
480
|
+
.prepare(
|
|
481
|
+
`SELECT DISTINCT e.project_id as projectId
|
|
482
|
+
FROM events e
|
|
483
|
+
JOIN raw_event_refs r ON r.id = e.raw_event_ref_id
|
|
484
|
+
WHERE r.source_path = ?
|
|
485
|
+
UNION
|
|
486
|
+
SELECT project_id as projectId FROM sessions WHERE path = ?`
|
|
487
|
+
)
|
|
488
|
+
.all(sourcePath, sourcePath) as Array<{ projectId: string }>;
|
|
489
|
+
const sessionRows = this.db
|
|
490
|
+
.prepare(
|
|
491
|
+
`SELECT DISTINCT session_id as sessionId FROM raw_event_refs WHERE source_path = ?
|
|
492
|
+
UNION
|
|
493
|
+
SELECT id as sessionId FROM sessions WHERE path = ?`
|
|
494
|
+
)
|
|
495
|
+
.all(sourcePath, sourcePath) as Array<{ sessionId: string }>;
|
|
496
|
+
const projectIds = projectRows.map((row) => row.projectId);
|
|
497
|
+
const sessionIds = sessionRows.map((row) => row.sessionId);
|
|
498
|
+
|
|
499
|
+
this.db
|
|
500
|
+
.prepare(
|
|
501
|
+
`DELETE FROM artifacts
|
|
502
|
+
WHERE event_id IN (
|
|
503
|
+
SELECT e.id
|
|
504
|
+
FROM events e
|
|
505
|
+
JOIN raw_event_refs r ON r.id = e.raw_event_ref_id
|
|
506
|
+
WHERE r.source_path = ?
|
|
507
|
+
)`
|
|
508
|
+
)
|
|
509
|
+
.run(sourcePath);
|
|
510
|
+
this.db
|
|
511
|
+
.prepare(
|
|
512
|
+
`DELETE FROM events
|
|
513
|
+
WHERE raw_event_ref_id IN (
|
|
514
|
+
SELECT id FROM raw_event_refs WHERE source_path = ?
|
|
515
|
+
)`
|
|
516
|
+
)
|
|
517
|
+
.run(sourcePath);
|
|
518
|
+
this.db.prepare("DELETE FROM raw_event_refs WHERE source_path = ?").run(sourcePath);
|
|
519
|
+
this.deleteIngestedRowsForSourcePath(sourcePath);
|
|
520
|
+
|
|
521
|
+
for (const sessionId of sessionIds) {
|
|
522
|
+
this.deleteSessionIfOrphan(sessionId);
|
|
523
|
+
}
|
|
524
|
+
for (const projectId of projectIds) {
|
|
525
|
+
this.refreshProjectEpisodes(projectId);
|
|
526
|
+
this.deleteProjectIfOrphan(projectId);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private refreshProjectEpisodes(projectId: string) {
|
|
531
|
+
this.replaceProjectEpisodes(projectId, groupEpisodes(projectId, this.listEvents(projectId)));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private deleteSessionIfOrphan(sessionId: string) {
|
|
535
|
+
const row = this.db
|
|
536
|
+
.prepare(
|
|
537
|
+
`SELECT
|
|
538
|
+
(SELECT COUNT(*) FROM events WHERE session_id = ?) as eventCount,
|
|
539
|
+
(SELECT COUNT(*) FROM raw_event_refs WHERE session_id = ?) as rawCount`
|
|
540
|
+
)
|
|
541
|
+
.get(sessionId, sessionId) as { eventCount: number; rawCount: number };
|
|
542
|
+
if (row.eventCount > 0 || row.rawCount > 0) return;
|
|
543
|
+
this.db.prepare("DELETE FROM turns WHERE session_id = ?").run(sessionId);
|
|
544
|
+
this.db.prepare("DELETE FROM history_prompts WHERE session_id = ?").run(sessionId);
|
|
545
|
+
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private deleteProjectIfOrphan(projectId: string) {
|
|
549
|
+
this.db
|
|
550
|
+
.prepare(
|
|
551
|
+
`DELETE FROM projects
|
|
552
|
+
WHERE id = ?
|
|
553
|
+
AND NOT EXISTS (SELECT 1 FROM events WHERE project_id = ?)
|
|
554
|
+
AND NOT EXISTS (SELECT 1 FROM sessions WHERE project_id = ?)
|
|
555
|
+
AND NOT EXISTS (SELECT 1 FROM git_commits WHERE project_id = ?)
|
|
556
|
+
AND NOT EXISTS (SELECT 1 FROM episodes WHERE project_id = ?)`
|
|
557
|
+
)
|
|
558
|
+
.run(projectId, projectId, projectId, projectId, projectId);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private deleteIngestedRowsForSourcePath(sourcePath: string) {
|
|
562
|
+
for (const provider of ALL_AGENT_PROVIDERS) {
|
|
563
|
+
this.db.prepare("DELETE FROM ingested_files WHERE path = ?").run(`${provider}:${sourcePath}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
upsertJob(job: IngestJob) {
|
|
568
|
+
this.db
|
|
569
|
+
.prepare(
|
|
570
|
+
`INSERT OR REPLACE INTO ingest_jobs(id, status, phase, started_at, finished_at, total_files, processed_files, total_events, skipped_files, candidate_files, changed_files, processed_bytes, total_bytes, current_file, worker_pid, processor_version, errors_json)
|
|
571
|
+
VALUES (@id, @status, @phase, @startedAt, @finishedAt, @totalFiles, @processedFiles, @totalEvents, @skippedFiles, @candidateFiles, @changedFiles, @processedBytes, @totalBytes, @currentFile, @workerPid, @processorVersion, @errorsJson)`
|
|
572
|
+
)
|
|
573
|
+
.run({
|
|
574
|
+
...job,
|
|
575
|
+
phase: job.phase ?? phaseForStatus(job.status),
|
|
576
|
+
skippedFiles: job.skippedFiles ?? 0,
|
|
577
|
+
candidateFiles: job.candidateFiles ?? 0,
|
|
578
|
+
changedFiles: job.changedFiles ?? 0,
|
|
579
|
+
processedBytes: job.processedBytes ?? 0,
|
|
580
|
+
totalBytes: job.totalBytes ?? 0,
|
|
581
|
+
currentFile: job.currentFile ?? null,
|
|
582
|
+
workerPid: job.workerPid ?? null,
|
|
583
|
+
processorVersion: job.processorVersion ?? null,
|
|
584
|
+
errorsJson: JSON.stringify(job.errors)
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
getIngestedFile(path: string): { path: string; mtimeMs: number; sizeBytes: number; sha256: string | null; sessionId: string | null; processorVersion: string | null; processedAt: string } | null {
|
|
589
|
+
const row = this.db
|
|
590
|
+
.prepare(
|
|
591
|
+
`SELECT path, mtime_ms as mtimeMs, size_bytes as sizeBytes, sha256, session_id as sessionId, processor_version as processorVersion, processed_at as processedAt
|
|
592
|
+
FROM ingested_files WHERE path = ?`
|
|
593
|
+
)
|
|
594
|
+
.get(path) as { path: string; mtimeMs: number; sizeBytes: number; sha256: string | null; sessionId: string | null; processorVersion: string | null; processedAt: string } | undefined;
|
|
595
|
+
return row ?? null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
upsertIngestedFile(file: { path: string; mtimeMs: number; sizeBytes: number; sha256?: string | null; sessionId?: string | null; processorVersion?: string | null; processedAt: string }) {
|
|
599
|
+
this.db
|
|
600
|
+
.prepare(
|
|
601
|
+
`INSERT INTO ingested_files(path, mtime_ms, size_bytes, sha256, session_id, processor_version, processed_at)
|
|
602
|
+
VALUES (@path, @mtimeMs, @sizeBytes, @sha256, @sessionId, @processorVersion, @processedAt)
|
|
603
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
604
|
+
mtime_ms=excluded.mtime_ms,
|
|
605
|
+
size_bytes=excluded.size_bytes,
|
|
606
|
+
sha256=excluded.sha256,
|
|
607
|
+
session_id=excluded.session_id,
|
|
608
|
+
processor_version=excluded.processor_version,
|
|
609
|
+
processed_at=excluded.processed_at`
|
|
610
|
+
)
|
|
611
|
+
.run({ ...file, sha256: file.sha256 ?? null, sessionId: file.sessionId ?? null, processorVersion: file.processorVersion ?? null });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
listProjects(): ProjectRecord[] {
|
|
615
|
+
return this.db
|
|
616
|
+
.prepare("SELECT id, name, cwd, repo_root as repoRoot, created_at as createdAt, updated_at as updatedAt FROM projects ORDER BY updated_at DESC")
|
|
617
|
+
.all() as ProjectRecord[];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
getProject(projectId: string): ProjectRecord | null {
|
|
621
|
+
return (
|
|
622
|
+
(this.db
|
|
623
|
+
.prepare("SELECT id, name, cwd, repo_root as repoRoot, created_at as createdAt, updated_at as updatedAt FROM projects WHERE id = ?")
|
|
624
|
+
.get(projectId) as ProjectRecord | undefined) ?? null
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
listEvents(projectId: string, query: TimelineQuery = {}): TimelineEvent[] {
|
|
629
|
+
const { where, params } = this.timelineWhere(projectId, query);
|
|
630
|
+
const limit = normalizeLimit(query.limit);
|
|
631
|
+
const offset = Math.max(0, Math.trunc(query.offset ?? 0));
|
|
632
|
+
const pagination = query.limit === undefined && query.offset === undefined ? "" : " LIMIT ? OFFSET ?";
|
|
633
|
+
const paginationParams = pagination ? [limit, offset] : [];
|
|
634
|
+
const rows = this.db
|
|
635
|
+
.prepare(
|
|
636
|
+
`SELECT id, project_id as projectId, session_id as sessionId, turn_id as turnId, timestamp, kind, lane, title, detail,
|
|
637
|
+
tool_name as toolName, call_id as callId, status, files_json as filesJson, raw_event_ref_id as rawEventRefId,
|
|
638
|
+
duration_ms as durationMs, output_event_id as outputEventId, commit_hash as commitHash, token_usage_json as tokenUsageJson,
|
|
639
|
+
skills_json as skillsJson
|
|
640
|
+
FROM events ${where} ORDER BY timestamp ASC${pagination}`
|
|
641
|
+
)
|
|
642
|
+
.all(...params, ...paginationParams) as EventRow[];
|
|
643
|
+
return rows.map(rowToTimelineEvent);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
countEvents(projectId: string, query: TimelineQuery = {}): number {
|
|
647
|
+
const { where, params } = this.timelineWhere(projectId, query);
|
|
648
|
+
const row = this.db.prepare(`SELECT COUNT(*) as total FROM events ${where}`).get(...params) as { total: number };
|
|
649
|
+
return row.total;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
getTimeline(projectId: string, query: TimelineQuery = {}) {
|
|
653
|
+
const project = this.getProject(projectId);
|
|
654
|
+
if (!project) return null;
|
|
655
|
+
const events = this.listEvents(projectId, query);
|
|
656
|
+
const timeline = buildProjectTimeline(project, events);
|
|
657
|
+
return {
|
|
658
|
+
...timeline,
|
|
659
|
+
episodes: this.listEpisodes(projectId),
|
|
660
|
+
tokenUsage: this.getProjectTokenUsage(projectId),
|
|
661
|
+
totalEvents: this.countEvents(projectId, query),
|
|
662
|
+
limit: normalizeLimit(query.limit),
|
|
663
|
+
offset: Math.max(0, Math.trunc(query.offset ?? 0))
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
getProjectTokenUsage(projectId: string): TokenUsage {
|
|
668
|
+
const rows = this.db.prepare("SELECT token_usage_json as tokenUsageJson FROM events WHERE project_id = ? AND token_usage_json IS NOT NULL").all(projectId) as Array<{ tokenUsageJson: string | null }>;
|
|
669
|
+
return rows.reduce<TokenUsage>((total, row) => {
|
|
670
|
+
const usage = parseTokenUsage(row.tokenUsageJson);
|
|
671
|
+
return {
|
|
672
|
+
input: total.input + (usage?.input ?? 0),
|
|
673
|
+
output: total.output + (usage?.output ?? 0),
|
|
674
|
+
reasoning: total.reasoning + (usage?.reasoning ?? 0),
|
|
675
|
+
cachedInput: total.cachedInput + (usage?.cachedInput ?? 0),
|
|
676
|
+
total: total.total + (usage?.total ?? 0)
|
|
677
|
+
};
|
|
678
|
+
}, { input: 0, output: 0, reasoning: 0, cachedInput: 0, total: 0 });
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
getProjectTokenUsageByProjectIds(projectIds: string[]): Map<string, TokenUsage> {
|
|
682
|
+
const usageByProject = new Map(projectIds.map((projectId) => [projectId, emptyTokenUsage()]));
|
|
683
|
+
if (projectIds.length === 0) return usageByProject;
|
|
684
|
+
const placeholders = projectIds.map(() => "?").join(", ");
|
|
685
|
+
const rows = this.db
|
|
686
|
+
.prepare(`SELECT project_id as projectId, token_usage_json as tokenUsageJson FROM events WHERE project_id IN (${placeholders}) AND token_usage_json IS NOT NULL`)
|
|
687
|
+
.all(...projectIds) as Array<{ projectId: string; tokenUsageJson: string | null }>;
|
|
688
|
+
for (const row of rows) {
|
|
689
|
+
const usage = parseTokenUsage(row.tokenUsageJson);
|
|
690
|
+
if (!usage) continue;
|
|
691
|
+
const total = usageByProject.get(row.projectId) ?? emptyTokenUsage();
|
|
692
|
+
addTokenUsage(total, usage);
|
|
693
|
+
usageByProject.set(row.projectId, total);
|
|
694
|
+
}
|
|
695
|
+
return usageByProject;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
getProjectDailyTokenUsage(projectId: string): DailyTokenUsageResponse | null {
|
|
699
|
+
if (!this.getProject(projectId)) return null;
|
|
700
|
+
const rows = this.db
|
|
701
|
+
.prepare(
|
|
702
|
+
`SELECT substr(timestamp, 1, 10) as date, token_usage_json as tokenUsageJson
|
|
703
|
+
FROM events
|
|
704
|
+
WHERE project_id = ? AND token_usage_json IS NOT NULL
|
|
705
|
+
ORDER BY timestamp ASC`
|
|
706
|
+
)
|
|
707
|
+
.all(projectId) as Array<{ date: string; tokenUsageJson: string | null }>;
|
|
708
|
+
const pointsByDate = new Map<string, TokenUsage>();
|
|
709
|
+
const total = emptyTokenUsage();
|
|
710
|
+
for (const row of rows) {
|
|
711
|
+
const usage = parseTokenUsage(row.tokenUsageJson);
|
|
712
|
+
if (!usage) continue;
|
|
713
|
+
const point = pointsByDate.get(row.date) ?? emptyTokenUsage();
|
|
714
|
+
addTokenUsage(point, usage);
|
|
715
|
+
addTokenUsage(total, usage);
|
|
716
|
+
pointsByDate.set(row.date, point);
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
projectId,
|
|
720
|
+
points: Array.from(pointsByDate, ([date, usage]) => ({ date, ...usage })),
|
|
721
|
+
total
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
getTaskJourneyDetail(journeyId: string, projectId?: string): TaskJourneyDetail | null {
|
|
726
|
+
const projects = projectId ? [this.getProject(projectId)].filter((project): project is ProjectRecord => Boolean(project)) : this.listProjects();
|
|
727
|
+
for (const project of projects) {
|
|
728
|
+
const events = this.listEvents(project.id);
|
|
729
|
+
const timeline = buildProjectTimeline(project, events);
|
|
730
|
+
const journey = timeline.taskJourneys.find((candidate) => candidate.id === journeyId);
|
|
731
|
+
if (!journey) continue;
|
|
732
|
+
const eventIds = new Set(journey.eventIds);
|
|
733
|
+
const journeyEvents = events.filter((event) => eventIds.has(event.id));
|
|
734
|
+
return {
|
|
735
|
+
journey,
|
|
736
|
+
events: journeyEvents,
|
|
737
|
+
causalEdges: timeline.causalEdges.filter((edge) => eventIds.has(edge.fromEventId) || eventIds.has(edge.toEventId))
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
getEventEvidenceByEventIds(eventIds: string[]): Record<string, EventEvidence> {
|
|
744
|
+
if (eventIds.length === 0) return {};
|
|
745
|
+
const events = eventIds.map((eventId) => this.getEvent(eventId)).filter((event): event is TimelineEvent => Boolean(event));
|
|
746
|
+
const artifactsByEventId = new Map<string, Artifact[]>();
|
|
747
|
+
for (const artifact of this.listArtifactsForEvents(eventIds)) {
|
|
748
|
+
const artifacts = artifactsByEventId.get(artifact.eventId) ?? [];
|
|
749
|
+
artifacts.push(artifact);
|
|
750
|
+
artifactsByEventId.set(artifact.eventId, artifacts);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const evidence: Record<string, EventEvidence> = {};
|
|
754
|
+
for (const event of events) {
|
|
755
|
+
evidence[event.id] = {
|
|
756
|
+
event,
|
|
757
|
+
artifacts: artifactsByEventId.get(event.id) ?? [],
|
|
758
|
+
rawEvent: event.rawEventRefId ? this.getRawEvent(event.rawEventRefId) : null
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return evidence;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
listHistoryPromptsForSession(sessionId: string): CodexHistoryPrompt[] {
|
|
765
|
+
return this.db
|
|
766
|
+
.prepare(
|
|
767
|
+
`SELECT session_id as sessionId, ts, text, source_path as sourcePath, line_no as lineNo
|
|
768
|
+
FROM history_prompts
|
|
769
|
+
WHERE session_id = ?
|
|
770
|
+
ORDER BY ts ASC, line_no ASC`
|
|
771
|
+
)
|
|
772
|
+
.all(sessionId) as CodexHistoryPrompt[];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private timelineWhere(projectId: string, query: TimelineQuery) {
|
|
776
|
+
const clauses = ["project_id = ?"];
|
|
777
|
+
const params: unknown[] = [projectId];
|
|
778
|
+
if (query.lane) {
|
|
779
|
+
clauses.push("lane = ?");
|
|
780
|
+
params.push(query.lane);
|
|
781
|
+
}
|
|
782
|
+
if (query.since) {
|
|
783
|
+
clauses.push("timestamp >= ?");
|
|
784
|
+
params.push(query.since);
|
|
785
|
+
}
|
|
786
|
+
if (query.until) {
|
|
787
|
+
clauses.push("timestamp <= ?");
|
|
788
|
+
params.push(query.until);
|
|
789
|
+
}
|
|
790
|
+
return { where: `WHERE ${clauses.join(" AND ")}`, params };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
listEpisodes(projectId: string): Episode[] {
|
|
794
|
+
const rows = this.db
|
|
795
|
+
.prepare(
|
|
796
|
+
`SELECT id, project_id as projectId, started_at as startedAt, ended_at as endedAt,
|
|
797
|
+
title, summary, status, event_ids_json as eventIdsJson
|
|
798
|
+
FROM episodes WHERE project_id = ? ORDER BY started_at ASC`
|
|
799
|
+
)
|
|
800
|
+
.all(projectId) as Array<Omit<Episode, "eventIds"> & { eventIdsJson: string }>;
|
|
801
|
+
return rows.map((row) => ({ ...row, eventIds: JSON.parse(row.eventIdsJson) as string[] }));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
listSessions(projectId?: string): SessionRecord[] {
|
|
805
|
+
const sql = `SELECT id, project_id as projectId, path, cwd, started_at as startedAt, ended_at as endedAt,
|
|
806
|
+
cli_version as cliVersion, model_provider as modelProvider, source, provider,
|
|
807
|
+
COALESCE(external_session_id, id) as externalSessionId, agent_name as agentName
|
|
808
|
+
FROM sessions ${projectId ? "WHERE project_id = ?" : ""} ORDER BY started_at DESC`;
|
|
809
|
+
return (projectId ? this.db.prepare(sql).all(projectId) : this.db.prepare(sql).all()) as SessionRecord[];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
listSessionsByProjectIds(projectIds: string[]): Map<string, SessionRecord[]> {
|
|
813
|
+
const sessionsByProject = new Map(projectIds.map((projectId) => [projectId, [] as SessionRecord[]]));
|
|
814
|
+
if (projectIds.length === 0) return sessionsByProject;
|
|
815
|
+
const placeholders = projectIds.map(() => "?").join(", ");
|
|
816
|
+
const rows = this.db
|
|
817
|
+
.prepare(
|
|
818
|
+
`SELECT id, project_id as projectId, path, cwd, started_at as startedAt, ended_at as endedAt,
|
|
819
|
+
cli_version as cliVersion, model_provider as modelProvider, source, provider,
|
|
820
|
+
COALESCE(external_session_id, id) as externalSessionId, agent_name as agentName
|
|
821
|
+
FROM sessions WHERE project_id IN (${placeholders}) ORDER BY started_at DESC`
|
|
822
|
+
)
|
|
823
|
+
.all(...projectIds) as SessionRecord[];
|
|
824
|
+
for (const session of rows) {
|
|
825
|
+
const bucket = sessionsByProject.get(session.projectId) ?? [];
|
|
826
|
+
bucket.push(session);
|
|
827
|
+
sessionsByProject.set(session.projectId, bucket);
|
|
828
|
+
}
|
|
829
|
+
return sessionsByProject;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
getSession(sessionId: string): SessionRecord | null {
|
|
833
|
+
return (
|
|
834
|
+
(this.db
|
|
835
|
+
.prepare(
|
|
836
|
+
`SELECT id, project_id as projectId, path, cwd, started_at as startedAt, ended_at as endedAt,
|
|
837
|
+
cli_version as cliVersion, model_provider as modelProvider, source, provider,
|
|
838
|
+
COALESCE(external_session_id, id) as externalSessionId, agent_name as agentName
|
|
839
|
+
FROM sessions WHERE id = ?`
|
|
840
|
+
)
|
|
841
|
+
.get(sessionId) as SessionRecord | undefined) ?? null
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
getRunReplay(sessionId: string): RunReplay | null {
|
|
846
|
+
const session = this.getSession(sessionId);
|
|
847
|
+
if (!session) return null;
|
|
848
|
+
const events = this.listEventsForSession(session.projectId, sessionId);
|
|
849
|
+
const artifacts = this.listArtifactsForEvents(events.map((event) => event.id));
|
|
850
|
+
return {
|
|
851
|
+
session,
|
|
852
|
+
events,
|
|
853
|
+
nodes: buildReplayNodes(events),
|
|
854
|
+
artifacts
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
listArtifactsForEvents(eventIds: string[]): Artifact[] {
|
|
859
|
+
if (eventIds.length === 0) return [];
|
|
860
|
+
const placeholders = eventIds.map(() => "?").join(",");
|
|
861
|
+
return this.db
|
|
862
|
+
.prepare(`SELECT id, event_id as eventId, type, path, excerpt, sha256 FROM artifacts WHERE event_id IN (${placeholders})`)
|
|
863
|
+
.all(...eventIds) as Artifact[];
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
listEventsForSession(projectId: string, sessionId: string): TimelineEvent[] {
|
|
867
|
+
const rows = this.db
|
|
868
|
+
.prepare(
|
|
869
|
+
`SELECT id, project_id as projectId, session_id as sessionId, turn_id as turnId, timestamp, kind, lane, title, detail,
|
|
870
|
+
tool_name as toolName, call_id as callId, status, files_json as filesJson, raw_event_ref_id as rawEventRefId,
|
|
871
|
+
duration_ms as durationMs, output_event_id as outputEventId, commit_hash as commitHash, token_usage_json as tokenUsageJson,
|
|
872
|
+
skills_json as skillsJson
|
|
873
|
+
FROM events WHERE project_id = ? AND session_id = ? ORDER BY timestamp ASC`
|
|
874
|
+
)
|
|
875
|
+
.all(projectId, sessionId) as EventRow[];
|
|
876
|
+
return rows.map(rowToTimelineEvent);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
getEvent(eventId: string): TimelineEvent | null {
|
|
880
|
+
const row = this.db
|
|
881
|
+
.prepare(
|
|
882
|
+
`SELECT id, project_id as projectId, session_id as sessionId, turn_id as turnId, timestamp, kind, lane, title, detail,
|
|
883
|
+
tool_name as toolName, call_id as callId, status, files_json as filesJson, raw_event_ref_id as rawEventRefId,
|
|
884
|
+
duration_ms as durationMs, output_event_id as outputEventId, commit_hash as commitHash, token_usage_json as tokenUsageJson,
|
|
885
|
+
skills_json as skillsJson
|
|
886
|
+
FROM events WHERE id = ?`
|
|
887
|
+
)
|
|
888
|
+
.get(eventId) as EventRow | undefined;
|
|
889
|
+
return row ? rowToTimelineEvent(row) : null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
getRawEvent(rawEventRefId: string): RawEventRef | null {
|
|
893
|
+
const row = this.db
|
|
894
|
+
.prepare(
|
|
895
|
+
`SELECT id, session_id as sessionId, provider, line_no as lineNo, timestamp, type, redacted_payload_json as redactedPayloadJson,
|
|
896
|
+
source_path as sourcePath, sha256
|
|
897
|
+
FROM raw_event_refs WHERE id = ?`
|
|
898
|
+
)
|
|
899
|
+
.get(rawEventRefId) as RawEventRef | undefined;
|
|
900
|
+
return row ?? null;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
getEventEvidence(eventId: string): EventEvidence | null {
|
|
904
|
+
const event = this.getEvent(eventId);
|
|
905
|
+
if (!event) return null;
|
|
906
|
+
return {
|
|
907
|
+
event,
|
|
908
|
+
artifacts: this.listArtifactsForEvents([event.id]),
|
|
909
|
+
rawEvent: event.rawEventRefId ? this.getRawEvent(event.rawEventRefId) : null
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
getJob(jobId: string): IngestJob | null {
|
|
914
|
+
const row = this.db
|
|
915
|
+
.prepare(
|
|
916
|
+
`SELECT id, status, phase, started_at as startedAt, finished_at as finishedAt, total_files as totalFiles,
|
|
917
|
+
processed_files as processedFiles, total_events as totalEvents, skipped_files as skippedFiles,
|
|
918
|
+
candidate_files as candidateFiles, changed_files as changedFiles, processed_bytes as processedBytes,
|
|
919
|
+
total_bytes as totalBytes, current_file as currentFile, worker_pid as workerPid,
|
|
920
|
+
processor_version as processorVersion, errors_json as errorsJson
|
|
921
|
+
FROM ingest_jobs WHERE id = ?`
|
|
922
|
+
)
|
|
923
|
+
.get(jobId) as (Omit<IngestJob, "errors"> & { errorsJson: string }) | undefined;
|
|
924
|
+
return row ? { ...row, errors: JSON.parse(row.errorsJson) as string[] } : null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
getActiveIngestJob(): IngestJob | null {
|
|
928
|
+
const row = this.db
|
|
929
|
+
.prepare(
|
|
930
|
+
`SELECT id, status, phase, started_at as startedAt, finished_at as finishedAt, total_files as totalFiles,
|
|
931
|
+
processed_files as processedFiles, total_events as totalEvents, skipped_files as skippedFiles,
|
|
932
|
+
candidate_files as candidateFiles, changed_files as changedFiles, processed_bytes as processedBytes,
|
|
933
|
+
total_bytes as totalBytes, current_file as currentFile, worker_pid as workerPid,
|
|
934
|
+
processor_version as processorVersion, errors_json as errorsJson
|
|
935
|
+
FROM ingest_jobs
|
|
936
|
+
WHERE status IN ('queued', 'running')
|
|
937
|
+
ORDER BY started_at DESC
|
|
938
|
+
LIMIT 1`
|
|
939
|
+
)
|
|
940
|
+
.get() as (Omit<IngestJob, "errors"> & { errorsJson: string }) | undefined;
|
|
941
|
+
return row ? { ...row, errors: JSON.parse(row.errorsJson) as string[] } : null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function phaseForStatus(status: IngestJob["status"]): IngestJob["phase"] {
|
|
946
|
+
if (status === "completed") return "completed";
|
|
947
|
+
if (status === "failed") return "failed";
|
|
948
|
+
if (status === "running") return "scanning";
|
|
949
|
+
return "queued";
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const DEFAULT_TIMELINE_LIMIT = 200;
|
|
953
|
+
const MAX_TIMELINE_LIMIT = 100000;
|
|
954
|
+
|
|
955
|
+
function normalizeLimit(limit: number | undefined): number {
|
|
956
|
+
if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_TIMELINE_LIMIT;
|
|
957
|
+
return Math.min(MAX_TIMELINE_LIMIT, Math.max(1, Math.trunc(limit)));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function emptyTokenUsage(): TokenUsage {
|
|
961
|
+
return { input: 0, output: 0, reasoning: 0, cachedInput: 0, total: 0 };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function addTokenUsage(total: TokenUsage, usage: TokenUsage) {
|
|
965
|
+
total.input += usage.input;
|
|
966
|
+
total.output += usage.output;
|
|
967
|
+
total.reasoning += usage.reasoning;
|
|
968
|
+
total.cachedInput += usage.cachedInput;
|
|
969
|
+
total.total += usage.total;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function rowToTimelineEvent(row: EventRow): TimelineEvent {
|
|
973
|
+
const { filesJson, tokenUsageJson, skillsJson, ...event } = row;
|
|
974
|
+
return {
|
|
975
|
+
...event,
|
|
976
|
+
files: JSON.parse(filesJson) as string[],
|
|
977
|
+
tokenUsage: parseTokenUsage(tokenUsageJson),
|
|
978
|
+
skills: parseSkills(skillsJson)
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function parseTokenUsage(value: string | null): TokenUsage | null {
|
|
983
|
+
if (!value) return null;
|
|
984
|
+
try {
|
|
985
|
+
const parsed = JSON.parse(value) as Partial<TokenUsage>;
|
|
986
|
+
return {
|
|
987
|
+
input: Number(parsed.input ?? 0),
|
|
988
|
+
output: Number(parsed.output ?? 0),
|
|
989
|
+
reasoning: Number(parsed.reasoning ?? 0),
|
|
990
|
+
cachedInput: Number(parsed.cachedInput ?? 0),
|
|
991
|
+
total: Number(parsed.total ?? 0)
|
|
992
|
+
};
|
|
993
|
+
} catch {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function parseSkills(value: string | null): TimelineEvent["skills"] {
|
|
999
|
+
if (!value) return [];
|
|
1000
|
+
try {
|
|
1001
|
+
const parsed = JSON.parse(value);
|
|
1002
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1003
|
+
} catch {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const ALL_AGENT_PROVIDERS: AgentProvider[] = ["codex", "claude-code", "opencode"];
|
|
1009
|
+
|
|
1010
|
+
function sourcePathFromIngestedId(sourceId: string): string {
|
|
1011
|
+
for (const provider of ALL_AGENT_PROVIDERS) {
|
|
1012
|
+
const prefix = `${provider}:`;
|
|
1013
|
+
if (sourceId.startsWith(prefix)) return sourceId.slice(prefix.length);
|
|
1014
|
+
}
|
|
1015
|
+
return sourceId;
|
|
1016
|
+
}
|