whydid 1.0.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/README.md +232 -0
- package/dashboard/dist/android-chrome-192x192.png +0 -0
- package/dashboard/dist/android-chrome-512x512.png +0 -0
- package/dashboard/dist/apple-touch-icon.png +0 -0
- package/dashboard/dist/assets/index-DImKqodm.js +69 -0
- package/dashboard/dist/assets/index-mHXgqqkj.css +1 -0
- package/dashboard/dist/favicon-16x16.png +0 -0
- package/dashboard/dist/favicon-32x32.png +0 -0
- package/dashboard/dist/favicon.ico +0 -0
- package/dashboard/dist/index.html +24 -0
- package/dashboard/dist/logo-mark.svg +8 -0
- package/dashboard/dist/site.webmanifest +11 -0
- package/dist/adapters/claude-code.js +168 -0
- package/dist/adapters/hook-events.js +86 -0
- package/dist/adapters/sync-claude-code.js +85 -0
- package/dist/cli/ai-budget-warning.js +19 -0
- package/dist/cli/commands/agent-event.js +80 -0
- package/dist/cli/commands/checkpoint.js +36 -0
- package/dist/cli/commands/context.js +27 -0
- package/dist/cli/commands/daemon.js +139 -0
- package/dist/cli/commands/export.js +36 -0
- package/dist/cli/commands/init.js +63 -0
- package/dist/cli/commands/key.js +65 -0
- package/dist/cli/commands/license.js +57 -0
- package/dist/cli/commands/milestone.js +64 -0
- package/dist/cli/commands/prompt.js +75 -0
- package/dist/cli/commands/recap.js +48 -0
- package/dist/cli/commands/restore-fork.js +101 -0
- package/dist/cli/commands/status.js +26 -0
- package/dist/cli/commands/test.js +33 -0
- package/dist/cli/commands/timeline.js +23 -0
- package/dist/cli/commands/why.js +71 -0
- package/dist/cli/context.js +48 -0
- package/dist/cli/index.js +77 -0
- package/dist/cli/license-gate.js +26 -0
- package/dist/cli/preflight.js +28 -0
- package/dist/core/agent-context.js +46 -0
- package/dist/core/agent-hooks.js +68 -0
- package/dist/core/ai-budget.js +27 -0
- package/dist/core/chat-retrieval.js +36 -0
- package/dist/core/context-tree.js +135 -0
- package/dist/core/diff.js +30 -0
- package/dist/core/engine.js +321 -0
- package/dist/core/export.js +98 -0
- package/dist/core/file-history.js +31 -0
- package/dist/core/manifest.js +102 -0
- package/dist/core/recap.js +49 -0
- package/dist/core/test-runner.js +23 -0
- package/dist/daemon/listen.js +26 -0
- package/dist/daemon/pidfile.js +36 -0
- package/dist/daemon/server.js +554 -0
- package/dist/db/database.js +631 -0
- package/dist/db/schema.js +186 -0
- package/dist/git/git.js +22 -0
- package/dist/licensing/config.js +28 -0
- package/dist/licensing/license-client.js +45 -0
- package/dist/licensing/license-manager.js +89 -0
- package/dist/licensing/license-payload.js +12 -0
- package/dist/licensing/license-store.js +96 -0
- package/dist/licensing/machine-id.js +78 -0
- package/dist/licensing/secret-store.js +102 -0
- package/dist/licensing/verify-license.js +20 -0
- package/dist/llm/agent-prompt.js +36 -0
- package/dist/llm/annotate-checkpoint.js +67 -0
- package/dist/llm/annotate.js +11 -0
- package/dist/llm/anthropic-provider.js +97 -0
- package/dist/llm/chat.js +35 -0
- package/dist/llm/community-pricing.js +75 -0
- package/dist/llm/factory.js +17 -0
- package/dist/llm/http-error.js +25 -0
- package/dist/llm/noop-provider.js +19 -0
- package/dist/llm/openai-provider.js +85 -0
- package/dist/llm/pricing.js +55 -0
- package/dist/llm/prompt.js +103 -0
- package/dist/llm/provider.js +2 -0
- package/dist/llm/schema.js +49 -0
- package/dist/llm/social-post.js +49 -0
- package/dist/objects/store.js +31 -0
- package/dist/shared/config.js +45 -0
- package/dist/shared/redaction.js +18 -0
- package/dist/shared/types.js +2 -0
- package/dist/watcher/ignore-rules.js +92 -0
- package/dist/watcher/watcher.js +249 -0
- package/package.json +55 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { SCHEMA_SQL } from "./schema.js";
|
|
3
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
4
|
+
const { DatabaseSync } = nodeRequire("node:sqlite");
|
|
5
|
+
function rowToSocialPost(row) {
|
|
6
|
+
return {
|
|
7
|
+
id: row.id, projectId: row.project_id, sessionId: row.session_id, platform: row.platform,
|
|
8
|
+
content: row.content, source: row.source, pinned: row.pinned === 1, createdAt: row.created_at,
|
|
9
|
+
model: row.model ?? null, inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function rowToChatConversation(row) {
|
|
13
|
+
return { id: row.id, projectId: row.project_id, title: row.title, createdAt: row.created_at, updatedAt: row.updated_at };
|
|
14
|
+
}
|
|
15
|
+
function rowToChatMessage(row) {
|
|
16
|
+
return {
|
|
17
|
+
id: row.id, conversationId: row.conversation_id, role: row.role, content: row.content,
|
|
18
|
+
citedStateIds: row.cited_state_ids_json ? JSON.parse(row.cited_state_ids_json) : [],
|
|
19
|
+
model: row.model ?? null, inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null, createdAt: row.created_at,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function buildStateFilter(projectId, opts) {
|
|
23
|
+
const clauses = ["s.project_id = ?"];
|
|
24
|
+
const params = [projectId];
|
|
25
|
+
if (opts.risk) {
|
|
26
|
+
clauses.push("COALESCE(a.risk_level, s.risk_level) = ?");
|
|
27
|
+
params.push(opts.risk);
|
|
28
|
+
}
|
|
29
|
+
if (opts.search && opts.search.trim() !== "") {
|
|
30
|
+
clauses.push("(LOWER(COALESCE(a.summary,'')) LIKE ? OR LOWER(COALESCE(a.reason,'')) LIKE ? OR LOWER(COALESCE(s.label,'')) LIKE ? OR LOWER(s.id) LIKE ?)");
|
|
31
|
+
const q = `%${opts.search.trim().toLowerCase()}%`;
|
|
32
|
+
params.push(q, q, q, q);
|
|
33
|
+
}
|
|
34
|
+
return { where: clauses.join(" AND "), params };
|
|
35
|
+
}
|
|
36
|
+
export class WhydidDatabase {
|
|
37
|
+
raw;
|
|
38
|
+
constructor(dbPath) {
|
|
39
|
+
this.raw = new DatabaseSync(dbPath);
|
|
40
|
+
this.raw.exec("PRAGMA journal_mode = WAL;");
|
|
41
|
+
this.raw.exec("PRAGMA foreign_keys = ON;");
|
|
42
|
+
this.raw.exec(SCHEMA_SQL);
|
|
43
|
+
this.runMigrations();
|
|
44
|
+
}
|
|
45
|
+
// CREATE TABLE IF NOT EXISTS is a no-op for a table that already exists,
|
|
46
|
+
// so a column added to schema.ts after someone's .whydid/db.sqlite was
|
|
47
|
+
// first created needs an explicit, idempotent migration — this project
|
|
48
|
+
// doesn't otherwise have a migration framework, so this stays a short
|
|
49
|
+
// list of "add this column if it isn't already there" steps.
|
|
50
|
+
runMigrations() {
|
|
51
|
+
this.addColumnIfMissing("annotations", "input_tokens", "INTEGER");
|
|
52
|
+
this.addColumnIfMissing("annotations", "output_tokens", "INTEGER");
|
|
53
|
+
this.addColumnIfMissing("social_posts", "model", "TEXT");
|
|
54
|
+
this.addColumnIfMissing("social_posts", "input_tokens", "INTEGER");
|
|
55
|
+
this.addColumnIfMissing("social_posts", "output_tokens", "INTEGER");
|
|
56
|
+
this.addColumnIfMissing("chat_messages", "model", "TEXT");
|
|
57
|
+
this.addColumnIfMissing("chat_messages", "input_tokens", "INTEGER");
|
|
58
|
+
this.addColumnIfMissing("chat_messages", "output_tokens", "INTEGER");
|
|
59
|
+
this.addColumnIfMissing("states", "milestone_label", "TEXT");
|
|
60
|
+
this.addColumnIfMissing("turns", "kind", "TEXT NOT NULL DEFAULT 'prompt'");
|
|
61
|
+
}
|
|
62
|
+
addColumnIfMissing(table, column, definition) {
|
|
63
|
+
const cols = this.raw.prepare(`PRAGMA table_info(${table})`).all();
|
|
64
|
+
if (!cols.some((c) => c.name === column)) {
|
|
65
|
+
this.raw.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
close() {
|
|
69
|
+
this.raw.close();
|
|
70
|
+
}
|
|
71
|
+
// node:sqlite runs every statement in its own implicit transaction
|
|
72
|
+
// (autocommit) unless wrapped in an explicit BEGIN/COMMIT — meaning a
|
|
73
|
+
// bulk insert of one row per statement pays a full fsync per row. Wrapping
|
|
74
|
+
// the batch in one transaction turns N fsyncs into 1, with identical rows
|
|
75
|
+
// ending up in the table either way.
|
|
76
|
+
transaction(fn) {
|
|
77
|
+
this.raw.exec("BEGIN");
|
|
78
|
+
try {
|
|
79
|
+
const result = fn();
|
|
80
|
+
this.raw.exec("COMMIT");
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
this.raw.exec("ROLLBACK");
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
insertProject(p) {
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
this.raw
|
|
91
|
+
.prepare(`INSERT INTO projects (id, name, root_path, git_root, head_state_id, created_at, updated_at)
|
|
92
|
+
VALUES (?, ?, ?, ?, NULL, ?, ?)`)
|
|
93
|
+
.run(p.id, p.name, p.rootPath, p.gitRoot, now, now);
|
|
94
|
+
}
|
|
95
|
+
getProject(id) {
|
|
96
|
+
const row = this.raw.prepare(`SELECT * FROM projects WHERE id = ?`).get(id);
|
|
97
|
+
if (!row)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
id: row.id, name: row.name, rootPath: row.root_path, gitRoot: row.git_root,
|
|
101
|
+
headStateId: row.head_state_id, createdAt: row.created_at, updatedAt: row.updated_at,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
insertState(s) {
|
|
105
|
+
this.raw
|
|
106
|
+
.prepare(`INSERT INTO states
|
|
107
|
+
(id, project_id, parent_state_id, session_id, turn_id, state_type, label, summary,
|
|
108
|
+
reason, decision_notes, risk_level, created_at, git_commit_sha, working_tree_hash)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
110
|
+
.run(s.id, s.projectId, s.parentStateId, s.sessionId, s.turnId, s.stateType, s.label, s.summary, s.reason, s.decisionNotes, s.riskLevel, s.createdAt, s.gitCommitSha, s.workingTreeHash);
|
|
111
|
+
}
|
|
112
|
+
rowToState(row) {
|
|
113
|
+
return {
|
|
114
|
+
id: row.id, projectId: row.project_id, parentStateId: row.parent_state_id,
|
|
115
|
+
sessionId: row.session_id, turnId: row.turn_id, stateType: row.state_type,
|
|
116
|
+
label: row.label, summary: row.summary, reason: row.reason,
|
|
117
|
+
decisionNotes: row.decision_notes, riskLevel: row.risk_level, createdAt: row.created_at,
|
|
118
|
+
gitCommitSha: row.git_commit_sha, workingTreeHash: row.working_tree_hash,
|
|
119
|
+
milestoneLabel: row.milestone_label ?? null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
getState(id) {
|
|
123
|
+
const row = this.raw.prepare(`SELECT * FROM states WHERE id = ?`).get(id);
|
|
124
|
+
return row ? this.rowToState(row) : undefined;
|
|
125
|
+
}
|
|
126
|
+
/** Tags (or untags, with label = null) a state as a milestone — a founder-chosen marker like "v1 launch" or "before pricing change" so it stands out in the timeline later. */
|
|
127
|
+
setStateMilestone(id, label) {
|
|
128
|
+
this.raw.prepare(`UPDATE states SET milestone_label = ? WHERE id = ?`).run(label, id);
|
|
129
|
+
}
|
|
130
|
+
listMilestones(projectId) {
|
|
131
|
+
const rows = this.raw
|
|
132
|
+
.prepare(`SELECT * FROM states WHERE project_id = ? AND milestone_label IS NOT NULL ORDER BY created_at DESC`)
|
|
133
|
+
.all(projectId);
|
|
134
|
+
return rows.map((r) => this.rowToState(r));
|
|
135
|
+
}
|
|
136
|
+
getHeadState(projectId) {
|
|
137
|
+
const project = this.getProject(projectId);
|
|
138
|
+
if (!project?.headStateId)
|
|
139
|
+
return undefined;
|
|
140
|
+
return this.getState(project.headStateId);
|
|
141
|
+
}
|
|
142
|
+
setHeadState(projectId, stateId) {
|
|
143
|
+
this.raw
|
|
144
|
+
.prepare(`UPDATE projects SET head_state_id = ?, updated_at = ? WHERE id = ?`)
|
|
145
|
+
.run(stateId, new Date().toISOString(), projectId);
|
|
146
|
+
}
|
|
147
|
+
listStates(projectId, opts = {}) {
|
|
148
|
+
const { where, params } = buildStateFilter(projectId, opts);
|
|
149
|
+
let sql = `SELECT s.* FROM states s LEFT JOIN annotations a ON a.id = (
|
|
150
|
+
SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
|
|
151
|
+
) WHERE ${where} ORDER BY s.created_at DESC, s.id DESC`;
|
|
152
|
+
if (opts.limit != null) {
|
|
153
|
+
sql += ` LIMIT ? OFFSET ?`;
|
|
154
|
+
params.push(opts.limit, opts.offset ?? 0);
|
|
155
|
+
}
|
|
156
|
+
const rows = this.raw.prepare(sql).all(...params);
|
|
157
|
+
return rows.map((r) => this.rowToState(r));
|
|
158
|
+
}
|
|
159
|
+
countStates(projectId, opts = {}) {
|
|
160
|
+
const { where, params } = buildStateFilter(projectId, opts);
|
|
161
|
+
const sql = `SELECT COUNT(*) as n FROM states s LEFT JOIN annotations a ON a.id = (
|
|
162
|
+
SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
|
|
163
|
+
) WHERE ${where}`;
|
|
164
|
+
const row = this.raw.prepare(sql).get(...params);
|
|
165
|
+
return row.n;
|
|
166
|
+
}
|
|
167
|
+
/** Real checkpoints (excludes the initial state) per calendar day, oldest first, since sinceIso. Days with zero activity are simply absent. */
|
|
168
|
+
getDailyCheckpointCounts(projectId, sinceIso) {
|
|
169
|
+
const rows = this.raw
|
|
170
|
+
.prepare(`SELECT substr(created_at, 1, 10) as day, COUNT(*) as n
|
|
171
|
+
FROM states
|
|
172
|
+
WHERE project_id = ? AND state_type != 'initial' AND created_at >= ?
|
|
173
|
+
GROUP BY day ORDER BY day ASC`)
|
|
174
|
+
.all(projectId, sinceIso);
|
|
175
|
+
return rows.map((r) => ({ date: r.day, count: r.n }));
|
|
176
|
+
}
|
|
177
|
+
/** All-time totals for the "progress so far" showcase — not paginated/filtered. */
|
|
178
|
+
getProjectTotals(projectId) {
|
|
179
|
+
const checkpoints = this.raw
|
|
180
|
+
.prepare(`SELECT COUNT(*) as n FROM states WHERE project_id = ? AND state_type != 'initial'`)
|
|
181
|
+
.get(projectId);
|
|
182
|
+
const sessions = this.raw.prepare(`SELECT COUNT(*) as n FROM sessions WHERE project_id = ?`).get(projectId);
|
|
183
|
+
return { totalCheckpoints: checkpoints.n, totalSessions: sessions.n };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* State ids with a *generated* annotation, newest first, capped at limit —
|
|
187
|
+
* scoped to a directory's immediate file children, or project-wide.
|
|
188
|
+
* dir === null -> any file, anywhere in the project (for the project summary)
|
|
189
|
+
* dir === "" -> files directly at the project root (no "/" in their path)
|
|
190
|
+
* dir === "src/auth" -> files whose immediate directory is exactly "src/auth"
|
|
191
|
+
* (deeper-nested files like src/auth/handlers/x.ts belong to their own
|
|
192
|
+
* "src/auth/handlers" bucket, not this one — matches how CONTEXT.md files
|
|
193
|
+
* are generated, one per immediate directory)
|
|
194
|
+
*/
|
|
195
|
+
findAnnotatedStateIdsForDirectory(projectId, dir, limit) {
|
|
196
|
+
let sql = `
|
|
197
|
+
SELECT DISTINCT s.id, s.created_at
|
|
198
|
+
FROM states s
|
|
199
|
+
JOIN annotations a ON a.id = (
|
|
200
|
+
SELECT id FROM annotations WHERE state_id = s.id ORDER BY created_at DESC LIMIT 1
|
|
201
|
+
)`;
|
|
202
|
+
const clauses = [`s.project_id = ?`, `a.status = 'generated'`];
|
|
203
|
+
const params = [projectId];
|
|
204
|
+
if (dir !== null) {
|
|
205
|
+
sql += ` JOIN file_changes fc ON fc.state_id = s.id`;
|
|
206
|
+
if (dir === "") {
|
|
207
|
+
clauses.push(`fc.file_path NOT LIKE '%/%'`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
clauses.push(`fc.file_path LIKE ? AND fc.file_path NOT LIKE ?`);
|
|
211
|
+
params.push(`${dir}/%`, `${dir}/%/%`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
sql += ` WHERE ${clauses.join(" AND ")} ORDER BY s.created_at DESC LIMIT ?`;
|
|
215
|
+
params.push(limit);
|
|
216
|
+
const rows = this.raw.prepare(sql).all(...params);
|
|
217
|
+
return rows.map((r) => r.id);
|
|
218
|
+
}
|
|
219
|
+
upsertContextFile(entry) {
|
|
220
|
+
this.raw
|
|
221
|
+
.prepare(`INSERT INTO context_files (project_id, dir_path, file_path, checkpoint_count, updated_at)
|
|
222
|
+
VALUES (?, ?, ?, ?, ?)
|
|
223
|
+
ON CONFLICT(project_id, file_path) DO UPDATE SET
|
|
224
|
+
dir_path = excluded.dir_path, checkpoint_count = excluded.checkpoint_count, updated_at = excluded.updated_at`)
|
|
225
|
+
.run(entry.projectId, entry.dirPath, entry.filePath, entry.checkpointCount, entry.updatedAt);
|
|
226
|
+
}
|
|
227
|
+
listContextFiles(projectId) {
|
|
228
|
+
const rows = this.raw
|
|
229
|
+
.prepare(`SELECT dir_path, file_path, checkpoint_count, updated_at FROM context_files WHERE project_id = ? ORDER BY updated_at DESC`)
|
|
230
|
+
.all(projectId);
|
|
231
|
+
return rows.map((r) => ({ dirPath: r.dir_path, filePath: r.file_path, checkpointCount: r.checkpoint_count, updatedAt: r.updated_at }));
|
|
232
|
+
}
|
|
233
|
+
insertSocialPost(p) {
|
|
234
|
+
this.raw
|
|
235
|
+
.prepare(`INSERT INTO social_posts (id, project_id, session_id, platform, content, source, pinned, created_at, model, input_tokens, output_tokens)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`)
|
|
237
|
+
.run(p.id, p.projectId, p.sessionId, p.platform, p.content, p.source, new Date().toISOString(), p.model ?? null, p.inputTokens ?? null, p.outputTokens ?? null);
|
|
238
|
+
}
|
|
239
|
+
listSocialPosts(projectId) {
|
|
240
|
+
const rows = this.raw
|
|
241
|
+
.prepare(`SELECT * FROM social_posts WHERE project_id = ? ORDER BY created_at DESC`)
|
|
242
|
+
.all(projectId);
|
|
243
|
+
return rows.map(rowToSocialPost);
|
|
244
|
+
}
|
|
245
|
+
getSocialPost(id) {
|
|
246
|
+
const row = this.raw.prepare(`SELECT * FROM social_posts WHERE id = ?`).get(id);
|
|
247
|
+
return row ? rowToSocialPost(row) : undefined;
|
|
248
|
+
}
|
|
249
|
+
setSocialPostPinned(id, pinned) {
|
|
250
|
+
this.raw.prepare(`UPDATE social_posts SET pinned = ? WHERE id = ?`).run(pinned ? 1 : 0, id);
|
|
251
|
+
}
|
|
252
|
+
deleteSocialPost(id) {
|
|
253
|
+
this.raw.prepare(`DELETE FROM social_posts WHERE id = ?`).run(id);
|
|
254
|
+
}
|
|
255
|
+
/** Deletes unpinned drafts older than retentionDays. retentionDays <= 0 is a no-op (auto-delete disabled). Returns the number removed. */
|
|
256
|
+
pruneOldSocialPosts(projectId, retentionDays) {
|
|
257
|
+
if (retentionDays <= 0)
|
|
258
|
+
return 0;
|
|
259
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
260
|
+
const result = this.raw
|
|
261
|
+
.prepare(`DELETE FROM social_posts WHERE project_id = ? AND pinned = 0 AND created_at < ?`)
|
|
262
|
+
.run(projectId, cutoff);
|
|
263
|
+
return Number(result.changes);
|
|
264
|
+
}
|
|
265
|
+
// ---- Project chat ----
|
|
266
|
+
insertChatConversation(c) {
|
|
267
|
+
const now = new Date().toISOString();
|
|
268
|
+
this.raw
|
|
269
|
+
.prepare(`INSERT INTO chat_conversations (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`)
|
|
270
|
+
.run(c.id, c.projectId, c.title, now, now);
|
|
271
|
+
}
|
|
272
|
+
listChatConversations(projectId) {
|
|
273
|
+
const rows = this.raw
|
|
274
|
+
.prepare(`SELECT * FROM chat_conversations WHERE project_id = ? ORDER BY updated_at DESC`)
|
|
275
|
+
.all(projectId);
|
|
276
|
+
return rows.map(rowToChatConversation);
|
|
277
|
+
}
|
|
278
|
+
getChatConversation(id) {
|
|
279
|
+
const row = this.raw.prepare(`SELECT * FROM chat_conversations WHERE id = ?`).get(id);
|
|
280
|
+
return row ? rowToChatConversation(row) : undefined;
|
|
281
|
+
}
|
|
282
|
+
touchChatConversation(id) {
|
|
283
|
+
this.raw.prepare(`UPDATE chat_conversations SET updated_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
|
|
284
|
+
}
|
|
285
|
+
setChatConversationTitle(id, title) {
|
|
286
|
+
this.raw.prepare(`UPDATE chat_conversations SET title = ? WHERE id = ?`).run(title, id);
|
|
287
|
+
}
|
|
288
|
+
deleteChatConversation(id) {
|
|
289
|
+
this.transaction(() => {
|
|
290
|
+
this.raw.prepare(`DELETE FROM chat_messages WHERE conversation_id = ?`).run(id);
|
|
291
|
+
this.raw.prepare(`DELETE FROM chat_conversations WHERE id = ?`).run(id);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
insertChatMessage(m) {
|
|
295
|
+
this.raw
|
|
296
|
+
.prepare(`INSERT INTO chat_messages (id, conversation_id, role, content, cited_state_ids_json, model, input_tokens, output_tokens, created_at)
|
|
297
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
298
|
+
.run(m.id, m.conversationId, m.role, m.content, m.citedStateIds ? JSON.stringify(m.citedStateIds) : null, m.model ?? null, m.inputTokens ?? null, m.outputTokens ?? null, new Date().toISOString());
|
|
299
|
+
}
|
|
300
|
+
listChatMessages(conversationId) {
|
|
301
|
+
const rows = this.raw
|
|
302
|
+
.prepare(`SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`)
|
|
303
|
+
.all(conversationId);
|
|
304
|
+
return rows.map(rowToChatMessage);
|
|
305
|
+
}
|
|
306
|
+
/** Indexes (or re-indexes) one state's annotation content for chat retrieval. Call only for generated annotations — there's nothing useful to search otherwise. */
|
|
307
|
+
indexAnnotationForSearch(stateId, projectId, text) {
|
|
308
|
+
this.raw.prepare(`DELETE FROM annotation_search WHERE state_id = ?`).run(stateId);
|
|
309
|
+
this.raw.prepare(`INSERT INTO annotation_search (state_id, project_id, text) VALUES (?, ?, ?)`).run(stateId, projectId, text);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Keyword search over indexed annotations, safe against arbitrary user
|
|
313
|
+
* input: terms are extracted as plain words and each wrapped as an FTS5
|
|
314
|
+
* quoted phrase (escaping embedded quotes), joined with OR — so anything
|
|
315
|
+
* a free-text chat question contains (colons, parens, "-", etc., all
|
|
316
|
+
* meaningful in raw FTS5 query syntax) can never produce a malformed
|
|
317
|
+
* MATCH expression or an unintended AND/NOT.
|
|
318
|
+
*/
|
|
319
|
+
searchAnnotations(projectId, query, limit) {
|
|
320
|
+
const terms = [...new Set(query.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [])].slice(0, 12);
|
|
321
|
+
if (terms.length === 0)
|
|
322
|
+
return [];
|
|
323
|
+
const matchExpr = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
324
|
+
const rows = this.raw
|
|
325
|
+
.prepare(`SELECT state_id FROM annotation_search WHERE project_id = ? AND annotation_search MATCH ? ORDER BY rank LIMIT ?`)
|
|
326
|
+
.all(projectId, matchExpr, limit);
|
|
327
|
+
return rows.map((r) => r.state_id);
|
|
328
|
+
}
|
|
329
|
+
insertFileChanges(stateId, changes) {
|
|
330
|
+
const stmt = this.raw.prepare(`INSERT INTO file_changes (id, state_id, file_path, change_type, before_hash, after_hash, lines_added, lines_removed)
|
|
331
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
332
|
+
this.transaction(() => {
|
|
333
|
+
for (const c of changes) {
|
|
334
|
+
stmt.run(`${stateId}:${c.filePath}`, stateId, c.filePath, c.changeType, c.beforeHash, c.afterHash, c.linesAdded, c.linesRemoved);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
getFileChanges(stateId) {
|
|
339
|
+
const rows = this.raw.prepare(`SELECT * FROM file_changes WHERE state_id = ?`).all(stateId);
|
|
340
|
+
return rows.map((r) => ({
|
|
341
|
+
id: r.id, stateId: r.state_id, filePath: r.file_path, changeType: r.change_type,
|
|
342
|
+
beforeHash: r.before_hash, afterHash: r.after_hash, linesAdded: r.lines_added,
|
|
343
|
+
linesRemoved: r.lines_removed,
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
insertStateManifest(stateId, manifest) {
|
|
347
|
+
const stmt = this.raw.prepare(`INSERT INTO state_manifest (state_id, file_path, content_hash, size_bytes) VALUES (?, ?, ?, ?)`);
|
|
348
|
+
this.transaction(() => {
|
|
349
|
+
for (const entry of manifest) {
|
|
350
|
+
stmt.run(stateId, entry.filePath, entry.contentHash, entry.sizeBytes);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Whether a full manifest snapshot ("keyframe") is stored for this exact state — vs. a state whose manifest must be reconstructed from its ancestors' deltas. */
|
|
355
|
+
hasFullManifest(stateId) {
|
|
356
|
+
const row = this.raw.prepare(`SELECT 1 FROM state_manifest WHERE state_id = ? LIMIT 1`).get(stateId);
|
|
357
|
+
return row !== undefined;
|
|
358
|
+
}
|
|
359
|
+
readFullManifestRows(stateId) {
|
|
360
|
+
const rows = this.raw
|
|
361
|
+
.prepare(`SELECT file_path, content_hash, size_bytes FROM state_manifest WHERE state_id = ? ORDER BY file_path`)
|
|
362
|
+
.all(stateId);
|
|
363
|
+
return rows.map((r) => ({ filePath: r.file_path, contentHash: r.content_hash, sizeBytes: r.size_bytes }));
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Returns the full file manifest for a state. Not every state has one
|
|
367
|
+
* stored directly — createCheckpoint only stores a full snapshot
|
|
368
|
+
* ("keyframe") periodically (see MANIFEST_KEYFRAME_INTERVAL in engine.ts)
|
|
369
|
+
* to avoid writing every tracked file's row on every single checkpoint,
|
|
370
|
+
* which at scale (tens of thousands of files, thousands of checkpoints
|
|
371
|
+
* over a project's life) made state_manifest the dominant driver of
|
|
372
|
+
* .whydid's on-disk size. For a state with no stored snapshot, this walks
|
|
373
|
+
* up the parent chain collecting file_changes (already stored for every
|
|
374
|
+
* state regardless) until it finds an ancestor that does have one, then
|
|
375
|
+
* replays those deltas oldest-first — the initial state always has a
|
|
376
|
+
* full manifest, so the walk is guaranteed to terminate.
|
|
377
|
+
*
|
|
378
|
+
* sizeBytes on a reconstructed (non-keyframe) entry is not accurate — it
|
|
379
|
+
* isn't consumed by anything correctness-critical (restore/fork apply by
|
|
380
|
+
* contentHash only; diffing and the state fingerprint are hash-based too)
|
|
381
|
+
* so recomputing it from the object store on every replay isn't worth the
|
|
382
|
+
* extra disk read.
|
|
383
|
+
*/
|
|
384
|
+
getStateManifest(stateId) {
|
|
385
|
+
if (this.hasFullManifest(stateId))
|
|
386
|
+
return this.readFullManifestRows(stateId);
|
|
387
|
+
const deltaChain = []; // nearest-to-stateId first
|
|
388
|
+
let cur = stateId;
|
|
389
|
+
let keyframeRows = [];
|
|
390
|
+
while (cur) {
|
|
391
|
+
if (this.hasFullManifest(cur)) {
|
|
392
|
+
keyframeRows = this.readFullManifestRows(cur);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
deltaChain.push(cur);
|
|
396
|
+
const parent = this.raw.prepare(`SELECT parent_state_id FROM states WHERE id = ?`).get(cur);
|
|
397
|
+
cur = parent?.parent_state_id ?? undefined;
|
|
398
|
+
}
|
|
399
|
+
const manifestMap = new Map(keyframeRows.map((e) => [e.filePath, e]));
|
|
400
|
+
for (const sid of deltaChain.reverse()) {
|
|
401
|
+
const changes = this.raw.prepare(`SELECT file_path, change_type, after_hash FROM file_changes WHERE state_id = ?`).all(sid);
|
|
402
|
+
for (const c of changes) {
|
|
403
|
+
if (c.change_type === "deleted") {
|
|
404
|
+
manifestMap.delete(c.file_path);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
manifestMap.set(c.file_path, { filePath: c.file_path, contentHash: c.after_hash, sizeBytes: -1 });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return [...manifestMap.values()].sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
412
|
+
}
|
|
413
|
+
insertAnnotation(a) {
|
|
414
|
+
this.raw
|
|
415
|
+
.prepare(`INSERT INTO annotations (id, state_id, summary, reason, decisions_json, risk_level, generated_by, model, status, created_at, input_tokens, output_tokens)
|
|
416
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
417
|
+
.run(a.id, a.stateId, a.summary, a.reason, JSON.stringify(a.decisions), a.riskLevel, a.generatedBy, a.model, a.status, a.createdAt ?? new Date().toISOString(), a.inputTokens ?? null, a.outputTokens ?? null);
|
|
418
|
+
}
|
|
419
|
+
getAnnotation(stateId) {
|
|
420
|
+
const row = this.raw
|
|
421
|
+
.prepare(`SELECT * FROM annotations WHERE state_id = ? ORDER BY created_at DESC LIMIT 1`)
|
|
422
|
+
.get(stateId);
|
|
423
|
+
if (!row)
|
|
424
|
+
return undefined;
|
|
425
|
+
return {
|
|
426
|
+
id: row.id, stateId: row.state_id, summary: row.summary, reason: row.reason,
|
|
427
|
+
decisions: JSON.parse(row.decisions_json ?? "[]"), riskLevel: row.risk_level,
|
|
428
|
+
generatedBy: row.generated_by, model: row.model, status: row.status, createdAt: row.created_at,
|
|
429
|
+
inputTokens: row.input_tokens ?? null, outputTokens: row.output_tokens ?? null,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** Per-day input/output token totals across all generated annotations, oldest first — the data behind the AI-cost visualization. */
|
|
433
|
+
// Every LLM call whydid itself makes — checkpoint annotation, build-in-public
|
|
434
|
+
// drafting, and project chat — records real usage on its own table (they
|
|
435
|
+
// each already had a natural row to attach it to). This unions all three
|
|
436
|
+
// so the AI Usage page reflects total real spend, not just annotations.
|
|
437
|
+
static TOKEN_USAGE_SOURCES = `
|
|
438
|
+
SELECT a.created_at as created_at, a.model as model, a.input_tokens as input_tokens, a.output_tokens as output_tokens, s.project_id as project_id
|
|
439
|
+
FROM annotations a JOIN states s ON s.id = a.state_id WHERE a.input_tokens IS NOT NULL
|
|
440
|
+
UNION ALL
|
|
441
|
+
SELECT created_at, model, input_tokens, output_tokens, project_id FROM social_posts WHERE input_tokens IS NOT NULL
|
|
442
|
+
UNION ALL
|
|
443
|
+
SELECT m.created_at as created_at, m.model as model, m.input_tokens as input_tokens, m.output_tokens as output_tokens, c.project_id as project_id
|
|
444
|
+
FROM chat_messages m JOIN chat_conversations c ON c.id = m.conversation_id WHERE m.input_tokens IS NOT NULL
|
|
445
|
+
`;
|
|
446
|
+
getDailyTokenUsage(projectId, sinceIso) {
|
|
447
|
+
const rows = this.raw
|
|
448
|
+
.prepare(`SELECT substr(created_at, 1, 10) as day, SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
|
|
449
|
+
FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
|
|
450
|
+
WHERE project_id = ? AND created_at >= ?
|
|
451
|
+
GROUP BY day ORDER BY day ASC`)
|
|
452
|
+
.all(projectId, sinceIso);
|
|
453
|
+
return rows.map((r) => ({ date: r.day, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
|
|
454
|
+
}
|
|
455
|
+
/** All-time token totals, broken down per model — pairs with a pricing table to estimate spend, since cost varies per model. */
|
|
456
|
+
getTokenUsageByModel(projectId) {
|
|
457
|
+
const rows = this.raw
|
|
458
|
+
.prepare(`SELECT COALESCE(model, 'unknown') as model, COUNT(*) as calls,
|
|
459
|
+
SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
|
|
460
|
+
FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
|
|
461
|
+
WHERE project_id = ?
|
|
462
|
+
GROUP BY model ORDER BY input_tokens DESC`)
|
|
463
|
+
.all(projectId);
|
|
464
|
+
return rows.map((r) => ({ model: r.model, calls: r.calls, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
|
|
465
|
+
}
|
|
466
|
+
/** Same as getTokenUsageByModel but scoped to created_at >= sinceIso — used for month-to-date budget tracking, where "all-time" would never trip an alert. */
|
|
467
|
+
getTokenUsageByModelSince(projectId, sinceIso) {
|
|
468
|
+
const rows = this.raw
|
|
469
|
+
.prepare(`SELECT COALESCE(model, 'unknown') as model, COUNT(*) as calls,
|
|
470
|
+
SUM(COALESCE(input_tokens, 0)) as input_tokens, SUM(COALESCE(output_tokens, 0)) as output_tokens
|
|
471
|
+
FROM (${WhydidDatabase.TOKEN_USAGE_SOURCES}) usage
|
|
472
|
+
WHERE project_id = ? AND created_at >= ?
|
|
473
|
+
GROUP BY model ORDER BY input_tokens DESC`)
|
|
474
|
+
.all(projectId, sinceIso);
|
|
475
|
+
return rows.map((r) => ({ model: r.model, calls: r.calls, inputTokens: r.input_tokens, outputTokens: r.output_tokens }));
|
|
476
|
+
}
|
|
477
|
+
insertFork(f) {
|
|
478
|
+
this.raw
|
|
479
|
+
.prepare(`INSERT INTO forks (id, project_id, source_state_id, name, created_at) VALUES (?, ?, ?, ?, ?)`)
|
|
480
|
+
.run(f.id, f.projectId, f.sourceStateId, f.name, new Date().toISOString());
|
|
481
|
+
}
|
|
482
|
+
listForks(projectId) {
|
|
483
|
+
const rows = this.raw.prepare(`SELECT * FROM forks WHERE project_id = ?`).all(projectId);
|
|
484
|
+
return rows.map((r) => ({
|
|
485
|
+
id: r.id, projectId: r.project_id, sourceStateId: r.source_state_id, name: r.name, createdAt: r.created_at,
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
/** Idempotent by id — safe to call again after a daemon restart re-scans a session file. */
|
|
489
|
+
insertSession(s) {
|
|
490
|
+
this.raw
|
|
491
|
+
.prepare(`INSERT OR IGNORE INTO sessions (id, project_id, agent, agent_session_id, started_at, ended_at, metadata_json)
|
|
492
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?)`)
|
|
493
|
+
.run(s.id, s.projectId, s.agent, s.agentSessionId, s.startedAt, s.metadataJson ?? null);
|
|
494
|
+
}
|
|
495
|
+
getSessionByAgentSessionId(projectId, agent, agentSessionId) {
|
|
496
|
+
const row = this.raw
|
|
497
|
+
.prepare(`SELECT * FROM sessions WHERE project_id = ? AND agent = ? AND agent_session_id = ?`)
|
|
498
|
+
.get(projectId, agent, agentSessionId);
|
|
499
|
+
return row ? this.rowToSession(row) : undefined;
|
|
500
|
+
}
|
|
501
|
+
getSession(id) {
|
|
502
|
+
const row = this.raw.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
|
|
503
|
+
return row ? this.rowToSession(row) : undefined;
|
|
504
|
+
}
|
|
505
|
+
listSessions(projectId, opts = {}) {
|
|
506
|
+
let sql = `SELECT * FROM sessions WHERE project_id = ? ORDER BY started_at DESC, id DESC`;
|
|
507
|
+
const params = [projectId];
|
|
508
|
+
if (opts.limit != null) {
|
|
509
|
+
sql += ` LIMIT ? OFFSET ?`;
|
|
510
|
+
params.push(opts.limit, opts.offset ?? 0);
|
|
511
|
+
}
|
|
512
|
+
const rows = this.raw.prepare(sql).all(...params);
|
|
513
|
+
return rows.map((r) => this.rowToSession(r));
|
|
514
|
+
}
|
|
515
|
+
countSessions(projectId) {
|
|
516
|
+
const row = this.raw.prepare(`SELECT COUNT(*) as n FROM sessions WHERE project_id = ?`).get(projectId);
|
|
517
|
+
return row.n;
|
|
518
|
+
}
|
|
519
|
+
getMostRecentActiveTurn(projectId) {
|
|
520
|
+
const row = this.raw.prepare(`SELECT s.id AS session_id, s.agent, t.id AS turn_id, t.prompt
|
|
521
|
+
FROM turns t JOIN sessions s ON s.id = t.session_id
|
|
522
|
+
WHERE s.project_id = ? AND t.status = 'in_progress'
|
|
523
|
+
ORDER BY t.started_at DESC, t.id DESC LIMIT 1`).get(projectId);
|
|
524
|
+
return row ? { sessionId: row.session_id, turnId: row.turn_id, prompt: row.prompt, agent: row.agent } : undefined;
|
|
525
|
+
}
|
|
526
|
+
listActiveTurns(projectId) {
|
|
527
|
+
const rows = this.raw.prepare(`SELECT s.id AS session_id, t.id AS turn_id
|
|
528
|
+
FROM turns t JOIN sessions s ON s.id = t.session_id
|
|
529
|
+
WHERE s.project_id = ? AND t.status = 'in_progress'`).all(projectId);
|
|
530
|
+
return rows.map((row) => ({ sessionId: row.session_id, turnId: row.turn_id }));
|
|
531
|
+
}
|
|
532
|
+
updateSessionEndedAt(sessionId, endedAt = new Date().toISOString()) {
|
|
533
|
+
this.raw.prepare(`UPDATE sessions SET ended_at = ? WHERE id = ?`).run(endedAt, sessionId);
|
|
534
|
+
}
|
|
535
|
+
// Ordered by started_at, not sequence: a Claude Code session's sequence
|
|
536
|
+
// numbers come from an in-memory counter (ClaudeCodeTailer) that starts
|
|
537
|
+
// over at 0 every time the daemon restarts mid-session, so two turns
|
|
538
|
+
// discovered across a restart can end up with colliding or out-of-order
|
|
539
|
+
// sequence values even though their real timestamps are always correct.
|
|
540
|
+
// (Codex/Cursor turns, written through hook-events.ts off a fresh
|
|
541
|
+
// getLatestTurn() query every time, don't have this problem — but
|
|
542
|
+
// ordering everyone by the timestamp that's always reliable is simpler
|
|
543
|
+
// than trusting sequence only for the agents where it happens to hold.)
|
|
544
|
+
listTurns(sessionId) {
|
|
545
|
+
const rows = this.raw.prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY started_at ASC, sequence ASC`).all(sessionId);
|
|
546
|
+
return rows.map((r) => this.rowToTurn(r));
|
|
547
|
+
}
|
|
548
|
+
/** States attributed to a turn (its session/turn id columns), newest first. */
|
|
549
|
+
getStatesForTurn(turnId) {
|
|
550
|
+
const rows = this.raw.prepare(`SELECT * FROM states WHERE turn_id = ? ORDER BY created_at DESC`).all(turnId);
|
|
551
|
+
return rows.map((r) => this.rowToState(r));
|
|
552
|
+
}
|
|
553
|
+
rowToSession(row) {
|
|
554
|
+
return {
|
|
555
|
+
id: row.id, projectId: row.project_id, agent: row.agent, agentSessionId: row.agent_session_id,
|
|
556
|
+
startedAt: row.started_at, endedAt: row.ended_at, metadataJson: row.metadata_json,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Upsert by id (Claude Code's own message uuid) — safe to re-poll the same
|
|
561
|
+
* turn repeatedly as its title/status/touched-files metadata fills in over
|
|
562
|
+
* the course of the turn.
|
|
563
|
+
*/
|
|
564
|
+
insertTurn(t) {
|
|
565
|
+
this.raw
|
|
566
|
+
.prepare(`INSERT INTO turns (id, session_id, sequence, prompt, started_at, ended_at, status, metadata_json, kind)
|
|
567
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
|
|
568
|
+
ON CONFLICT(id) DO UPDATE SET prompt = excluded.prompt, status = excluded.status, metadata_json = excluded.metadata_json`)
|
|
569
|
+
.run(t.id, t.sessionId, t.sequence, t.prompt, t.startedAt, t.status, t.metadataJson ?? null, t.kind ?? "prompt");
|
|
570
|
+
}
|
|
571
|
+
updateTurnStatus(turnId, status, endedAt) {
|
|
572
|
+
this.raw.prepare(`UPDATE turns SET status = ?, ended_at = ? WHERE id = ?`).run(status, endedAt ?? new Date().toISOString(), turnId);
|
|
573
|
+
}
|
|
574
|
+
getTurn(id) {
|
|
575
|
+
const row = this.raw.prepare(`SELECT * FROM turns WHERE id = ?`).get(id);
|
|
576
|
+
return row ? this.rowToTurn(row) : undefined;
|
|
577
|
+
}
|
|
578
|
+
getLatestTurn(sessionId) {
|
|
579
|
+
const row = this.raw.prepare(`SELECT * FROM turns WHERE session_id = ? ORDER BY sequence DESC LIMIT 1`).get(sessionId);
|
|
580
|
+
return row ? this.rowToTurn(row) : undefined;
|
|
581
|
+
}
|
|
582
|
+
/** Every turn across the project's sessions where a coding agent's own context got auto-compacted and the session continued — newest first. */
|
|
583
|
+
listCompactResumeTurns(projectId) {
|
|
584
|
+
const rows = this.raw
|
|
585
|
+
.prepare(`SELECT t.* FROM turns t JOIN sessions s ON s.id = t.session_id
|
|
586
|
+
WHERE s.project_id = ? AND t.kind = 'compact_resume'
|
|
587
|
+
ORDER BY t.started_at DESC`)
|
|
588
|
+
.all(projectId);
|
|
589
|
+
return rows.map((r) => this.rowToTurn(r));
|
|
590
|
+
}
|
|
591
|
+
rowToTurn(row) {
|
|
592
|
+
return {
|
|
593
|
+
id: row.id, sessionId: row.session_id, sequence: row.sequence, prompt: row.prompt,
|
|
594
|
+
startedAt: row.started_at, endedAt: row.ended_at, status: row.status, metadataJson: row.metadata_json,
|
|
595
|
+
kind: row.kind ?? "prompt",
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
insertEvent(e) {
|
|
599
|
+
this.raw
|
|
600
|
+
.prepare(`INSERT INTO events (id, project_id, type, timestamp, payload_json) VALUES (?, ?, ?, ?, ?)`)
|
|
601
|
+
.run(e.id, e.projectId, e.type, new Date().toISOString(), e.payloadJson);
|
|
602
|
+
}
|
|
603
|
+
insertTestRun(t) {
|
|
604
|
+
this.raw
|
|
605
|
+
.prepare(`INSERT INTO test_runs (id, state_id, command, status, exit_code, duration_ms, output_path, created_at)
|
|
606
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
607
|
+
.run(t.id, t.stateId, t.command, t.status, t.exitCode, t.durationMs, t.outputPath, new Date().toISOString());
|
|
608
|
+
}
|
|
609
|
+
getTestRuns(stateId) {
|
|
610
|
+
const rows = this.raw.prepare(`SELECT * FROM test_runs WHERE state_id = ? ORDER BY created_at DESC`).all(stateId);
|
|
611
|
+
return rows.map((r) => ({
|
|
612
|
+
id: r.id, stateId: r.state_id, command: r.command, status: r.status,
|
|
613
|
+
exitCode: r.exit_code, durationMs: r.duration_ms, outputPath: r.output_path, createdAt: r.created_at,
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
getLatestTestRunForProject(projectId) {
|
|
617
|
+
const row = this.raw
|
|
618
|
+
.prepare(`SELECT test_runs.* FROM test_runs
|
|
619
|
+
JOIN states ON states.id = test_runs.state_id
|
|
620
|
+
WHERE states.project_id = ?
|
|
621
|
+
ORDER BY test_runs.created_at DESC LIMIT 1`)
|
|
622
|
+
.get(projectId);
|
|
623
|
+
if (!row)
|
|
624
|
+
return undefined;
|
|
625
|
+
return {
|
|
626
|
+
id: row.id, stateId: row.state_id, command: row.command, status: row.status,
|
|
627
|
+
exitCode: row.exit_code, durationMs: row.duration_ms, outputPath: row.output_path, createdAt: row.created_at,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=database.js.map
|