@proletariat/cli 0.3.82 → 0.3.83

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.
@@ -1,33 +1,81 @@
1
1
  import Database from 'better-sqlite3';
2
+ import { eq, and, or, isNull, sql, asc, desc, like } from 'drizzle-orm';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
4
5
  import { getThemePersistentDir, isEphemeralAgentName } from '../themes.js';
5
6
  import { throwIfNativeBindingError } from './native-validation.js';
6
7
  import { runDrizzleMigrations } from './migrator.js';
7
8
  import { ALL_MIGRATIONS } from './migrations/index.js';
9
+ import { createDrizzleConnection } from './drizzle.js';
10
+ import { workspace as workspaceTable, repositories as repositoriesTable, agents as agentsTable, agentThemes as agentThemesTable, agentThemeNames as agentThemeNamesTable, agentWorktrees as agentWorktreesTable, workspaceSettings as workspaceSettingsTable, mediaItems as mediaItemsTable, } from './drizzle-schema.js';
8
11
  // Re-export CREATE_TABLES_SQL from its canonical location
9
12
  export { CREATE_TABLES_SQL } from './workspace-schema.js';
10
- // CREATE_TABLES_SQL is now in workspace-schema.ts and re-exported above
13
+ // =============================================================================
14
+ // Internal helpers
15
+ // =============================================================================
11
16
  /**
12
- * Ensure ephemeral agents are correctly typed based on their worktree path or naming pattern
17
+ * Open the workspace database, wrap it with Drizzle, run a function,
18
+ * and close the connection. Handles the open/close lifecycle.
19
+ */
20
+ function withDrizzle(workspacePath, fn) {
21
+ const sqliteDb = openWorkspaceDatabase(workspacePath);
22
+ const ddb = createDrizzleConnection(sqliteDb);
23
+ try {
24
+ return fn(ddb, sqliteDb);
25
+ }
26
+ finally {
27
+ sqliteDb.close();
28
+ }
29
+ }
30
+ /**
31
+ * Map a Drizzle agent row to the Agent interface.
32
+ * Handles default values for backwards compatibility with old databases.
33
+ */
34
+ function toAgent(row) {
35
+ return {
36
+ name: row.name,
37
+ type: (row.type || 'persistent'),
38
+ status: (row.status || 'active'),
39
+ base_name: row.baseName,
40
+ theme_id: row.themeId,
41
+ worktree_path: row.worktreePath,
42
+ mount_mode: (row.mountMode || 'worktree'),
43
+ created_at: row.createdAt,
44
+ cleaned_at: row.cleanedAt,
45
+ };
46
+ }
47
+ /**
48
+ * Map a Drizzle theme row to the AgentTheme interface.
49
+ */
50
+ function toAgentTheme(row) {
51
+ return {
52
+ id: row.id,
53
+ name: row.name,
54
+ display_name: row.displayName,
55
+ description: row.description,
56
+ builtin: Boolean(row.builtin),
57
+ created_at: row.createdAt,
58
+ };
59
+ }
60
+ /**
61
+ * Ensure ephemeral agents are correctly typed based on their worktree path or naming pattern.
62
+ * Uses raw SQL because it relies on SQLite-specific GLOB operator and sqlite_master introspection.
13
63
  */
14
64
  function ensureEphemeralAgentTypes(db) {
15
- // Check if agents table exists
65
+ // Check if agents table exists (sqlite_master introspection — no Drizzle equivalent)
16
66
  const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agents'").get();
17
67
  if (!tableExists) {
18
68
  return;
19
69
  }
20
70
  // Agents in temp directory should be ephemeral
21
71
  db.exec("UPDATE agents SET type = 'ephemeral' WHERE worktree_path LIKE 'agents/temp/%' AND type != 'ephemeral'");
22
- // Detect ephemeral agents by naming pattern: adjective-name-number (e.g., blue-khosla-1)
23
- // Staff agents are single names like 'lecun', 'musk', 'gates'
72
+ // Detect ephemeral agents by naming pattern using SQLite GLOB (no Drizzle equivalent)
24
73
  db.exec(`
25
74
  UPDATE agents SET type = 'ephemeral'
26
75
  WHERE type != 'ephemeral'
27
76
  AND name GLOB '*-*-[0-9]*'
28
77
  `);
29
78
  // Also detect numberless ephemeral names (e.g., bold-bezos) using isEphemeralAgentName()
30
- // This catches agents that match the adjective-name pattern but don't have a number suffix
31
79
  const potentialEphemeral = db.prepare(`
32
80
  SELECT name FROM agents
33
81
  WHERE type != 'ephemeral'
@@ -73,7 +121,7 @@ export function openWorkspaceDatabase(workspacePath) {
73
121
  db.pragma('busy_timeout = 5000'); // Wait up to 5 seconds if database is locked
74
122
  // Run Drizzle migrations (creates tracking table, applies pending migrations)
75
123
  runDrizzleMigrations(db, ALL_MIGRATIONS);
76
- // Ensure ephemeral agents are correctly typed
124
+ // Ensure ephemeral agents are correctly typed (raw SQL — uses SQLite GLOB)
77
125
  ensureEphemeralAgentTypes(db);
78
126
  return db;
79
127
  }
@@ -107,11 +155,15 @@ export function createWorkspaceDatabase(workspacePath, type, workspaceName, hasP
107
155
  db.pragma('foreign_keys = ON');
108
156
  // Run all migrations (baseline creates core workspace + PMO tables)
109
157
  runDrizzleMigrations(db, ALL_MIGRATIONS);
110
- // Insert workspace data (convert boolean to number for SQLite)
111
- db.prepare(`
112
- INSERT INTO workspace (id, type, workspace_name, has_pmo, created_at)
113
- VALUES (1, ?, ?, ?, ?)
114
- `).run(type, workspaceName, hasPMO ? 1 : 0, new Date().toISOString());
158
+ // Insert workspace data using Drizzle
159
+ const ddb = createDrizzleConnection(db);
160
+ ddb.insert(workspaceTable).values({
161
+ id: 1,
162
+ type,
163
+ workspaceName,
164
+ hasPmo: hasPMO,
165
+ createdAt: new Date().toISOString(),
166
+ }).run();
115
167
  return db;
116
168
  }
117
169
  /**
@@ -119,10 +171,19 @@ export function createWorkspaceDatabase(workspacePath, type, workspaceName, hasP
119
171
  */
120
172
  export function getWorkspaceConfig(workspacePath) {
121
173
  try {
122
- const db = openWorkspaceDatabase(workspacePath);
123
- const config = db.prepare('SELECT * FROM workspace LIMIT 1').get();
124
- db.close();
125
- return config || null;
174
+ return withDrizzle(workspacePath, (ddb) => {
175
+ const row = ddb.select().from(workspaceTable).limit(1).get();
176
+ if (!row)
177
+ return null;
178
+ return {
179
+ id: row.id ?? 1,
180
+ type: row.type,
181
+ workspace_name: row.workspaceName,
182
+ has_pmo: Boolean(row.hasPmo),
183
+ active_theme_id: row.activeThemeId,
184
+ created_at: row.createdAt,
185
+ };
186
+ });
126
187
  }
127
188
  catch {
128
189
  return null;
@@ -139,12 +200,12 @@ export function getActiveTheme(workspacePath) {
139
200
  return getTheme(workspacePath, config.active_theme_id);
140
201
  }
141
202
  // Auto-detect from existing agents
142
- const agents = getWorkspaceAgents(workspacePath);
143
- if (agents.length === 0) {
203
+ const agentList = getWorkspaceAgents(workspacePath);
204
+ if (agentList.length === 0) {
144
205
  return null;
145
206
  }
146
207
  // Check if any agent has a theme_id set
147
- const themedAgent = agents.find(a => a.theme_id);
208
+ const themedAgent = agentList.find(a => a.theme_id);
148
209
  if (themedAgent?.theme_id) {
149
210
  const theme = getTheme(workspacePath, themedAgent.theme_id);
150
211
  if (theme) {
@@ -159,7 +220,7 @@ export function getActiveTheme(workspacePath) {
159
220
  const themeNames = getThemeNames(workspacePath, theme.id);
160
221
  const themeNameSet = new Set(themeNames.map(n => n.name.toLowerCase()));
161
222
  // If any existing agent matches this theme's names
162
- const matchingAgent = agents.find(a => themeNameSet.has(a.name.toLowerCase()));
223
+ const matchingAgent = agentList.find(a => themeNameSet.has(a.name.toLowerCase()));
163
224
  if (matchingAgent) {
164
225
  // Auto-set it for future use
165
226
  setActiveTheme(workspacePath, theme.id);
@@ -172,82 +233,125 @@ export function getActiveTheme(workspacePath) {
172
233
  * Set the active theme for a workspace
173
234
  */
174
235
  export function setActiveTheme(workspacePath, themeId) {
175
- const db = openWorkspaceDatabase(workspacePath);
176
- if (themeId) {
177
- // Validate theme exists
178
- const theme = db.prepare('SELECT id FROM agent_themes WHERE id = ?').get(themeId);
179
- if (!theme) {
180
- db.close();
181
- throw new Error(`Theme "${themeId}" not found`);
236
+ withDrizzle(workspacePath, (ddb) => {
237
+ if (themeId) {
238
+ // Validate theme exists
239
+ const theme = ddb.select({ id: agentThemesTable.id })
240
+ .from(agentThemesTable)
241
+ .where(eq(agentThemesTable.id, themeId))
242
+ .get();
243
+ if (!theme) {
244
+ throw new Error(`Theme "${themeId}" not found`);
245
+ }
182
246
  }
183
- }
184
- db.prepare('UPDATE workspace SET active_theme_id = ? WHERE id = 1').run(themeId);
185
- db.close();
247
+ ddb.update(workspaceTable)
248
+ .set({ activeThemeId: themeId })
249
+ .where(eq(workspaceTable.id, 1))
250
+ .run();
251
+ });
186
252
  }
187
253
  /**
188
254
  * Add repositories to database
189
255
  */
190
256
  export function addRepositoriesToDatabase(workspacePath, repos) {
191
- const db = openWorkspaceDatabase(workspacePath);
192
- const insertRepo = db.prepare(`
193
- INSERT OR REPLACE INTO repositories (name, path, type, source_url, action, added_at)
194
- VALUES (?, ?, ?, ?, ?, ?)
195
- `);
196
- const transaction = db.transaction(() => {
257
+ withDrizzle(workspacePath, (ddb) => {
197
258
  for (const repo of repos) {
198
- insertRepo.run(repo.name, repo.path, 'main', repo.source_url || null, repo.action || null, new Date().toISOString());
259
+ ddb.insert(repositoriesTable)
260
+ .values({
261
+ name: repo.name,
262
+ path: repo.path,
263
+ type: 'main',
264
+ sourceUrl: repo.source_url || null,
265
+ action: repo.action || null,
266
+ addedAt: new Date().toISOString(),
267
+ })
268
+ .onConflictDoUpdate({
269
+ target: repositoriesTable.name,
270
+ set: {
271
+ path: repo.path,
272
+ type: 'main',
273
+ sourceUrl: repo.source_url || null,
274
+ action: repo.action || null,
275
+ addedAt: new Date().toISOString(),
276
+ },
277
+ })
278
+ .run();
199
279
  }
200
280
  });
201
- transaction();
202
- db.close();
203
281
  }
204
282
  /**
205
283
  * Add agents to database (case-insensitive uniqueness)
206
284
  */
207
285
  export function addAgentsToDatabase(workspacePath, agentNames, themeId, mountMode = 'worktree') {
208
- const db = openWorkspaceDatabase(workspacePath);
209
- // Check for existing agents (case-insensitive)
210
- const checkExisting = db.prepare('SELECT name FROM agents WHERE LOWER(name) = LOWER(?)');
211
- const insertAgent = db.prepare(`
212
- INSERT OR REPLACE INTO agents (name, type, base_name, theme_id, worktree_path, mount_mode, created_at)
213
- VALUES (?, ?, ?, ?, ?, ?, ?)
214
- `);
215
- const insertWorktree = db.prepare(`
216
- INSERT OR REPLACE INTO agent_worktrees (agent_name, repo_name, worktree_path, branch, created_at)
217
- VALUES (?, ?, ?, ?, ?)
218
- `);
219
- // Get workspace config to determine paths
220
- const workspace = db.prepare('SELECT * FROM workspace').get();
221
- // Get all repos for this workspace
222
- const repos = db.prepare('SELECT name FROM repositories').all();
223
- // Determine the effective theme ID (provided or active theme)
224
- const effectiveThemeId = themeId || workspace.active_theme_id || undefined;
225
- const persistentDir = getThemePersistentDir(effectiveThemeId);
226
- const transaction = db.transaction(() => {
227
- for (const agentName of agentNames) {
228
- // Skip if agent already exists (case-insensitive check)
229
- const existing = checkExisting.get(agentName);
230
- if (existing) {
231
- continue; // Agent already exists with same name (different case)
232
- }
233
- const now = new Date().toISOString();
234
- // Determine worktree path for the agent
235
- const agentWorktreePath = workspace.type === 'hq'
236
- ? `agents/${persistentDir}/${agentName}`
237
- : agentName;
238
- // Add agent (persistent type for manually added agents)
239
- insertAgent.run(agentName, 'persistent', null, effectiveThemeId || null, agentWorktreePath, mountMode, now);
240
- // Add worktrees for all repos
241
- for (const repo of repos) {
242
- const worktreePath = workspace.type === 'hq'
243
- ? `agents/${persistentDir}/${agentName}/${repo.name}`
244
- : `${agentName}/${repo.name}`;
245
- insertWorktree.run(agentName, repo.name, worktreePath, `agent-${agentName}`, now);
286
+ withDrizzle(workspacePath, (ddb, sqliteDb) => {
287
+ // Get workspace config to determine paths
288
+ const wsRow = ddb.select().from(workspaceTable).get();
289
+ if (!wsRow)
290
+ throw new Error('No workspace config found');
291
+ // Get all repos for this workspace
292
+ const repos = ddb.select({ name: repositoriesTable.name }).from(repositoriesTable).all();
293
+ // Determine the effective theme ID (provided or active theme)
294
+ const effectiveThemeId = themeId || wsRow.activeThemeId || undefined;
295
+ const persistentDir = getThemePersistentDir(effectiveThemeId);
296
+ const transaction = sqliteDb.transaction(() => {
297
+ for (const agentName of agentNames) {
298
+ // Check for existing agents (case-insensitive) via Drizzle sql
299
+ const existing = ddb.select({ name: agentsTable.name })
300
+ .from(agentsTable)
301
+ .where(sql `LOWER(${agentsTable.name}) = LOWER(${agentName})`)
302
+ .get();
303
+ if (existing) {
304
+ continue; // Agent already exists with same name (different case)
305
+ }
306
+ const now = new Date().toISOString();
307
+ // Determine worktree path for the agent
308
+ const agentWorktreePath = wsRow.type === 'hq'
309
+ ? `agents/${persistentDir}/${agentName}`
310
+ : agentName;
311
+ // Add agent (persistent type for manually added agents)
312
+ ddb.insert(agentsTable).values({
313
+ name: agentName,
314
+ type: 'persistent',
315
+ baseName: null,
316
+ themeId: effectiveThemeId || null,
317
+ worktreePath: agentWorktreePath,
318
+ mountMode,
319
+ createdAt: now,
320
+ }).onConflictDoUpdate({
321
+ target: agentsTable.name,
322
+ set: {
323
+ type: 'persistent',
324
+ baseName: null,
325
+ themeId: effectiveThemeId || null,
326
+ worktreePath: agentWorktreePath,
327
+ mountMode,
328
+ createdAt: now,
329
+ },
330
+ }).run();
331
+ // Add worktrees for all repos
332
+ for (const repo of repos) {
333
+ const worktreePath = wsRow.type === 'hq'
334
+ ? `agents/${persistentDir}/${agentName}/${repo.name}`
335
+ : `${agentName}/${repo.name}`;
336
+ ddb.insert(agentWorktreesTable).values({
337
+ agentName,
338
+ repoName: repo.name,
339
+ worktreePath,
340
+ branch: `agent-${agentName}`,
341
+ createdAt: now,
342
+ }).onConflictDoUpdate({
343
+ target: [agentWorktreesTable.agentName, agentWorktreesTable.repoName],
344
+ set: {
345
+ worktreePath,
346
+ branch: `agent-${agentName}`,
347
+ createdAt: now,
348
+ },
349
+ }).run();
350
+ }
246
351
  }
247
- }
352
+ });
353
+ transaction();
248
354
  });
249
- transaction();
250
- db.close();
251
355
  }
252
356
  /**
253
357
  * Add an ephemeral agent to the database.
@@ -269,26 +373,27 @@ export function addEphemeralAgentToDatabase(workspacePath, agentName, baseName,
269
373
  * simply gets null and can retry with a different name.
270
374
  */
271
375
  export function tryAddEphemeralAgentToDatabase(workspacePath, agentName, baseName, themeId, mountMode = 'worktree') {
272
- const db = openWorkspaceDatabase(workspacePath);
376
+ const sqliteDb = openWorkspaceDatabase(workspacePath);
377
+ const ddb = createDrizzleConnection(sqliteDb);
273
378
  try {
274
379
  const now = new Date().toISOString();
275
380
  const worktreePath = `agents/temp/${agentName}`;
276
- db.prepare(`
277
- INSERT INTO agents (name, type, status, base_name, theme_id, worktree_path, mount_mode, created_at)
278
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
279
- `).run(agentName, 'ephemeral', 'active', baseName, themeId || null, worktreePath, mountMode, now);
280
- const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentName);
281
- return {
282
- name: agent.name,
283
- type: agent.type,
284
- status: agent.status,
285
- base_name: agent.base_name,
286
- theme_id: agent.theme_id,
287
- worktree_path: agent.worktree_path,
288
- mount_mode: (agent.mount_mode || 'clone'),
289
- created_at: agent.created_at,
290
- cleaned_at: agent.cleaned_at,
291
- };
381
+ ddb.insert(agentsTable).values({
382
+ name: agentName,
383
+ type: 'ephemeral',
384
+ status: 'active',
385
+ baseName,
386
+ themeId: themeId || null,
387
+ worktreePath,
388
+ mountMode,
389
+ createdAt: now,
390
+ }).run();
391
+ const agent = ddb.select().from(agentsTable)
392
+ .where(eq(agentsTable.name, agentName))
393
+ .get();
394
+ if (!agent)
395
+ return null;
396
+ return toAgent(agent);
292
397
  }
293
398
  catch (err) {
294
399
  const sqliteErr = err;
@@ -298,48 +403,50 @@ export function tryAddEphemeralAgentToDatabase(workspacePath, agentName, baseNam
298
403
  throw err;
299
404
  }
300
405
  finally {
301
- db.close();
406
+ sqliteDb.close();
302
407
  }
303
408
  }
304
409
  /**
305
410
  * Get all ephemeral agent names from the database
306
411
  */
307
412
  export function getEphemeralAgentNames(workspacePath) {
308
- const db = openWorkspaceDatabase(workspacePath);
309
- const agents = db.prepare("SELECT name FROM agents WHERE type = 'ephemeral'").all();
310
- db.close();
311
- return new Set(agents.map(a => a.name.toLowerCase()));
413
+ return withDrizzle(workspacePath, (ddb) => {
414
+ const rows = ddb.select({ name: agentsTable.name })
415
+ .from(agentsTable)
416
+ .where(eq(agentsTable.type, 'ephemeral'))
417
+ .all();
418
+ return new Set(rows.map(a => a.name.toLowerCase()));
419
+ });
312
420
  }
313
421
  /**
314
422
  * Remove an ephemeral agent from the database
315
423
  */
316
424
  export function removeEphemeralAgent(workspacePath, agentName) {
317
- const db = openWorkspaceDatabase(workspacePath);
318
- db.prepare("DELETE FROM agents WHERE name = ? AND type = 'ephemeral'").run(agentName);
319
- db.close();
425
+ withDrizzle(workspacePath, (ddb) => {
426
+ ddb.delete(agentsTable)
427
+ .where(and(eq(agentsTable.name, agentName), eq(agentsTable.type, 'ephemeral')))
428
+ .run();
429
+ });
320
430
  }
321
431
  /**
322
432
  * Get all agents in workspace
323
433
  */
324
434
  export function getWorkspaceAgents(workspacePath, includeCleanedUp = false) {
325
- const db = openWorkspaceDatabase(workspacePath);
326
- const query = includeCleanedUp
327
- ? 'SELECT * FROM agents ORDER BY created_at'
328
- : "SELECT * FROM agents WHERE status = 'active' OR status IS NULL ORDER BY created_at";
329
- const rows = db.prepare(query).all();
330
- db.close();
331
- // Map rows to Agent type, handling missing columns in old databases
332
- return rows.map(row => ({
333
- name: row.name,
334
- type: (row.type || 'persistent'),
335
- status: (row.status || 'active'),
336
- base_name: row.base_name,
337
- theme_id: row.theme_id,
338
- worktree_path: row.worktree_path,
339
- mount_mode: (row.mount_mode || 'worktree'),
340
- created_at: row.created_at,
341
- cleaned_at: row.cleaned_at,
342
- }));
435
+ return withDrizzle(workspacePath, (ddb) => {
436
+ let rows;
437
+ if (includeCleanedUp) {
438
+ rows = ddb.select().from(agentsTable)
439
+ .orderBy(asc(agentsTable.createdAt))
440
+ .all();
441
+ }
442
+ else {
443
+ rows = ddb.select().from(agentsTable)
444
+ .where(or(eq(agentsTable.status, 'active'), isNull(agentsTable.status)))
445
+ .orderBy(asc(agentsTable.createdAt))
446
+ .all();
447
+ }
448
+ return rows.map(toAgent);
449
+ });
343
450
  }
344
451
  /**
345
452
  * Get an agent by directory path.
@@ -356,38 +463,31 @@ export function getAgentByPath(workspacePath, absolutePath) {
356
463
  }
357
464
  // Get relative path from workspace root
358
465
  const relativePath = path.relative(normalizedWorkspace, normalizedPath);
359
- const db = openWorkspaceDatabase(workspacePath);
360
- const agents = db.prepare("SELECT * FROM agents WHERE status = 'active' OR status IS NULL").all();
361
- db.close();
362
- // Find agent whose worktree_path matches or contains the relative path
363
- for (const row of agents) {
364
- if (row.worktree_path) {
365
- // Check if relativePath starts with or equals the agent's worktree_path
366
- if (relativePath === row.worktree_path || relativePath.startsWith(row.worktree_path + '/')) {
367
- return {
368
- name: row.name,
369
- type: (row.type || 'persistent'),
370
- status: (row.status || 'active'),
371
- base_name: row.base_name,
372
- theme_id: row.theme_id,
373
- worktree_path: row.worktree_path,
374
- mount_mode: (row.mount_mode || 'worktree'),
375
- created_at: row.created_at,
376
- cleaned_at: row.cleaned_at,
377
- };
466
+ return withDrizzle(workspacePath, (ddb) => {
467
+ const rows = ddb.select().from(agentsTable)
468
+ .where(or(eq(agentsTable.status, 'active'), isNull(agentsTable.status)))
469
+ .all();
470
+ // Find agent whose worktree_path matches or contains the relative path
471
+ for (const row of rows) {
472
+ if (row.worktreePath) {
473
+ if (relativePath === row.worktreePath || relativePath.startsWith(row.worktreePath + '/')) {
474
+ return toAgent(row);
475
+ }
378
476
  }
379
477
  }
380
- }
381
- return null;
478
+ return null;
479
+ });
382
480
  }
383
481
  /**
384
482
  * Mark an agent as cleaned up (keeps the record for history)
385
483
  */
386
484
  export function markAgentCleaned(workspacePath, agentName) {
387
- const db = openWorkspaceDatabase(workspacePath);
388
- const now = new Date().toISOString();
389
- db.prepare("UPDATE agents SET status = 'cleaned', cleaned_at = ? WHERE name = ?").run(now, agentName);
390
- db.close();
485
+ withDrizzle(workspacePath, (ddb) => {
486
+ ddb.update(agentsTable)
487
+ .set({ status: 'cleaned', cleanedAt: new Date().toISOString() })
488
+ .where(eq(agentsTable.name, agentName))
489
+ .run();
490
+ });
391
491
  }
392
492
  /**
393
493
  * Sync agents in database with what exists on disk.
@@ -395,9 +495,9 @@ export function markAgentCleaned(workspacePath, agentName) {
395
495
  * Returns list of agents that were cleaned up.
396
496
  */
397
497
  export function syncAgentsWithDisk(workspacePath) {
398
- const agents = getWorkspaceAgents(workspacePath, false); // Only active agents
498
+ const agentList = getWorkspaceAgents(workspacePath, false); // Only active agents
399
499
  const cleanedAgents = [];
400
- for (const agent of agents) {
500
+ for (const agent of agentList) {
401
501
  // Determine expected directory path
402
502
  let agentDir;
403
503
  if (agent.worktree_path) {
@@ -431,9 +531,8 @@ export function discoverAgentsOnDisk(workspacePath) {
431
531
  const activeNames = new Set(activeAgents.map(a => a.name.toLowerCase()));
432
532
  // Get ALL agents including cleaned (for reactivation)
433
533
  const allAgents = getWorkspaceAgents(workspacePath, true);
434
- const cleanedAgents = new Map(allAgents.filter(a => a.status === 'cleaned').map(a => [a.name.toLowerCase(), a]));
435
- const db = openWorkspaceDatabase(workspacePath);
436
- try {
534
+ const cleanedAgentsMap = new Map(allAgents.filter(a => a.status === 'cleaned').map(a => [a.name.toLowerCase(), a]));
535
+ withDrizzle(workspacePath, (ddb) => {
437
536
  // Scan staff directory
438
537
  const staffDir = path.join(workspacePath, 'agents', 'staff');
439
538
  if (fs.existsSync(staffDir)) {
@@ -444,21 +543,24 @@ export function discoverAgentsOnDisk(workspacePath) {
444
543
  if (!activeNames.has(nameLower)) {
445
544
  const worktreePath = `agents/staff/${entry.name}`;
446
545
  const now = new Date().toISOString();
447
- // Check if this is a cleaned agent that should be reactivated
448
- const cleanedAgent = cleanedAgents.get(nameLower);
546
+ const cleanedAgent = cleanedAgentsMap.get(nameLower);
449
547
  if (cleanedAgent) {
450
548
  // Reactivate the cleaned agent
451
- db.prepare(`
452
- UPDATE agents SET status = 'active', cleaned_at = NULL, worktree_path = ?
453
- WHERE LOWER(name) = LOWER(?)
454
- `).run(worktreePath, entry.name);
549
+ ddb.update(agentsTable)
550
+ .set({ status: 'active', cleanedAt: null, worktreePath })
551
+ .where(sql `LOWER(${agentsTable.name}) = LOWER(${entry.name})`)
552
+ .run();
455
553
  }
456
554
  else {
457
- // Register new agent - discovered agents default to 'worktree' mode (legacy behavior)
458
- db.prepare(`
459
- INSERT INTO agents (name, type, status, worktree_path, mount_mode, created_at)
460
- VALUES (?, 'persistent', 'active', ?, 'worktree', ?)
461
- `).run(entry.name, worktreePath, now);
555
+ // Register new agent
556
+ ddb.insert(agentsTable).values({
557
+ name: entry.name,
558
+ type: 'persistent',
559
+ status: 'active',
560
+ worktreePath,
561
+ mountMode: 'worktree',
562
+ createdAt: now,
563
+ }).run();
462
564
  }
463
565
  result.discovered.push({ name: entry.name, type: 'persistent', path: worktreePath });
464
566
  activeNames.add(nameLower);
@@ -476,21 +578,24 @@ export function discoverAgentsOnDisk(workspacePath) {
476
578
  if (!activeNames.has(nameLower)) {
477
579
  const worktreePath = `agents/temp/${entry.name}`;
478
580
  const now = new Date().toISOString();
479
- // Check if this is a cleaned agent that should be reactivated
480
- const cleanedAgent = cleanedAgents.get(nameLower);
581
+ const cleanedAgent = cleanedAgentsMap.get(nameLower);
481
582
  if (cleanedAgent) {
482
583
  // Reactivate the cleaned agent
483
- db.prepare(`
484
- UPDATE agents SET status = 'active', cleaned_at = NULL, worktree_path = ?
485
- WHERE LOWER(name) = LOWER(?)
486
- `).run(worktreePath, entry.name);
584
+ ddb.update(agentsTable)
585
+ .set({ status: 'active', cleanedAt: null, worktreePath })
586
+ .where(sql `LOWER(${agentsTable.name}) = LOWER(${entry.name})`)
587
+ .run();
487
588
  }
488
589
  else {
489
- // Register new agent - discovered agents default to 'worktree' mode (legacy behavior)
490
- db.prepare(`
491
- INSERT INTO agents (name, type, status, worktree_path, mount_mode, created_at)
492
- VALUES (?, 'ephemeral', 'active', ?, 'worktree', ?)
493
- `).run(entry.name, worktreePath, now);
590
+ // Register new agent
591
+ ddb.insert(agentsTable).values({
592
+ name: entry.name,
593
+ type: 'ephemeral',
594
+ status: 'active',
595
+ worktreePath,
596
+ mountMode: 'worktree',
597
+ createdAt: now,
598
+ }).run();
494
599
  }
495
600
  result.discovered.push({ name: entry.name, type: 'ephemeral', path: worktreePath });
496
601
  activeNames.add(nameLower);
@@ -498,79 +603,123 @@ export function discoverAgentsOnDisk(workspacePath) {
498
603
  }
499
604
  }
500
605
  }
501
- }
502
- finally {
503
- db.close();
504
- }
606
+ });
505
607
  return result;
506
608
  }
507
609
  /**
508
610
  * Get all repositories in workspace
509
611
  */
510
612
  export function getWorkspaceRepositories(workspacePath) {
511
- const db = openWorkspaceDatabase(workspacePath);
512
- const repos = db.prepare('SELECT * FROM repositories ORDER BY added_at').all();
513
- db.close();
514
- return repos;
613
+ return withDrizzle(workspacePath, (ddb) => {
614
+ const rows = ddb.select().from(repositoriesTable)
615
+ .orderBy(asc(repositoriesTable.addedAt))
616
+ .all();
617
+ return rows.map(row => ({
618
+ name: row.name,
619
+ path: row.path,
620
+ type: (row.type || 'main'),
621
+ source_url: row.sourceUrl ?? undefined,
622
+ action: (row.action ?? undefined),
623
+ added_at: row.addedAt,
624
+ }));
625
+ });
515
626
  }
516
627
  /**
517
628
  * Get worktrees for a specific agent
518
629
  */
519
630
  export function getAgentWorktrees(workspacePath, agentName) {
520
- const db = openWorkspaceDatabase(workspacePath);
521
- const worktrees = db.prepare('SELECT * FROM agent_worktrees WHERE agent_name = ?').all(agentName);
522
- db.close();
523
- return worktrees;
631
+ return withDrizzle(workspacePath, (ddb) => {
632
+ const rows = ddb.select().from(agentWorktreesTable)
633
+ .where(eq(agentWorktreesTable.agentName, agentName))
634
+ .all();
635
+ return rows.map(row => ({
636
+ agent_name: row.agentName,
637
+ repo_name: row.repoName,
638
+ worktree_path: row.worktreePath,
639
+ branch: row.branch,
640
+ created_at: row.createdAt,
641
+ last_commit_hash: row.lastCommitHash ?? undefined,
642
+ commits_ahead: row.commitsAhead,
643
+ is_clean: Boolean(row.isClean),
644
+ last_checked: row.lastChecked ?? undefined,
645
+ }));
646
+ });
524
647
  }
525
648
  /**
526
649
  * Find agent worktrees matching a branch pattern (case-insensitive LIKE).
527
650
  */
528
651
  export function findWorktreesByBranch(workspacePath, branchPattern) {
529
- const db = openWorkspaceDatabase(workspacePath);
530
- const worktrees = db.prepare('SELECT * FROM agent_worktrees WHERE LOWER(branch) LIKE ?').all(branchPattern);
531
- db.close();
532
- return worktrees;
652
+ return withDrizzle(workspacePath, (ddb) => {
653
+ const rows = ddb.select().from(agentWorktreesTable)
654
+ .where(like(sql `LOWER(${agentWorktreesTable.branch})`, branchPattern))
655
+ .all();
656
+ return rows.map(row => ({
657
+ agent_name: row.agentName,
658
+ repo_name: row.repoName,
659
+ worktree_path: row.worktreePath,
660
+ branch: row.branch,
661
+ created_at: row.createdAt,
662
+ last_commit_hash: row.lastCommitHash ?? undefined,
663
+ commits_ahead: row.commitsAhead,
664
+ is_clean: Boolean(row.isClean),
665
+ last_checked: row.lastChecked ?? undefined,
666
+ }));
667
+ });
533
668
  }
534
669
  /**
535
670
  * Get agent worktrees for a specific repository.
536
671
  */
537
672
  export function getWorktreesForRepo(workspacePath, repoName) {
538
- const db = openWorkspaceDatabase(workspacePath);
539
- const worktrees = db.prepare('SELECT agent_name, is_clean, commits_ahead, branch FROM agent_worktrees WHERE repo_name = ?').all(repoName);
540
- db.close();
541
- return worktrees;
673
+ return withDrizzle(workspacePath, (ddb) => {
674
+ const rows = ddb.select({
675
+ agent_name: agentWorktreesTable.agentName,
676
+ is_clean: sql `${agentWorktreesTable.isClean}`,
677
+ commits_ahead: agentWorktreesTable.commitsAhead,
678
+ branch: agentWorktreesTable.branch,
679
+ }).from(agentWorktreesTable)
680
+ .where(eq(agentWorktreesTable.repoName, repoName))
681
+ .all();
682
+ return rows;
683
+ });
542
684
  }
543
685
  /**
544
686
  * Upsert a workspace setting (key-value pair).
545
687
  */
546
688
  export function upsertWorkspaceSetting(db, key, value) {
547
- db.prepare(`
548
- INSERT INTO workspace_settings (key, value)
549
- VALUES (?, ?)
550
- ON CONFLICT(key) DO UPDATE SET value = excluded.value
551
- `).run(key, value);
689
+ const ddb = createDrizzleConnection(db);
690
+ ddb.insert(workspaceSettingsTable)
691
+ .values({ key, value })
692
+ .onConflictDoUpdate({
693
+ target: workspaceSettingsTable.key,
694
+ set: { value },
695
+ })
696
+ .run();
552
697
  }
553
698
  /**
554
699
  * Remove agents from database
555
700
  */
556
701
  export function removeAgentsFromDatabase(workspacePath, agentNames) {
557
- const db = openWorkspaceDatabase(workspacePath);
558
- const deleteAgent = db.prepare('DELETE FROM agents WHERE name = ?');
559
- // Note: agent_worktrees will be deleted automatically due to CASCADE
560
- const transaction = db.transaction(() => {
561
- for (const agentName of agentNames) {
562
- deleteAgent.run(agentName);
563
- }
702
+ withDrizzle(workspacePath, (ddb, sqliteDb) => {
703
+ // Note: agent_worktrees will be deleted automatically due to CASCADE
704
+ const transaction = sqliteDb.transaction(() => {
705
+ for (const agentName of agentNames) {
706
+ ddb.delete(agentsTable)
707
+ .where(eq(agentsTable.name, agentName))
708
+ .run();
709
+ }
710
+ });
711
+ transaction();
564
712
  });
565
- transaction();
566
- db.close();
567
713
  }
568
714
  // =============================================================================
569
715
  // PMO Bootstrapping Operations
716
+ // Raw SQL is required here because these operate before migrations run
717
+ // or perform DDL operations that Drizzle doesn't support.
570
718
  // =============================================================================
571
719
  /**
572
720
  * Check if PMO tables exist and get basic stats.
573
721
  * Used by pmo init to detect existing PMO before storage layer is available.
722
+ * Raw SQL: uses sqlite_master introspection (pre-migration bootstrap).
574
723
  */
575
724
  export function checkPMOExists(dbPath) {
576
725
  let db;
@@ -601,6 +750,7 @@ export function checkPMOExists(dbPath) {
601
750
  /**
602
751
  * Get a PMO setting from the pmo_settings table.
603
752
  * Used for bootstrapping queries before storage layer is available.
753
+ * Raw SQL: pre-migration bootstrap query.
604
754
  */
605
755
  export function getPMOSetting(dbPath, key) {
606
756
  let db;
@@ -625,6 +775,7 @@ export function getPMOSetting(dbPath, key) {
625
775
  /**
626
776
  * Drop PMO tables from the database.
627
777
  * Used during PMO reinitialization.
778
+ * Raw SQL: DDL operations (DROP TABLE) are not supported by Drizzle.
628
779
  */
629
780
  export function dropPMOTables(dbPath, tables) {
630
781
  let db;
@@ -656,61 +807,79 @@ export function dropPMOTables(dbPath, tables) {
656
807
  * Get all themes
657
808
  */
658
809
  export function getThemes(workspacePath) {
659
- const db = openWorkspaceDatabase(workspacePath);
660
- const themes = db.prepare('SELECT * FROM agent_themes ORDER BY builtin DESC, name').all();
661
- db.close();
662
- return themes;
810
+ return withDrizzle(workspacePath, (ddb) => {
811
+ const rows = ddb.select().from(agentThemesTable)
812
+ .orderBy(desc(agentThemesTable.builtin), asc(agentThemesTable.name))
813
+ .all();
814
+ return rows.map(toAgentTheme);
815
+ });
663
816
  }
664
817
  /**
665
818
  * Get a theme by ID
666
819
  */
667
820
  export function getTheme(workspacePath, themeId) {
668
- const db = openWorkspaceDatabase(workspacePath);
669
- const theme = db.prepare('SELECT * FROM agent_themes WHERE id = ?').get(themeId);
670
- db.close();
671
- return theme || null;
821
+ return withDrizzle(workspacePath, (ddb) => {
822
+ const row = ddb.select().from(agentThemesTable)
823
+ .where(eq(agentThemesTable.id, themeId))
824
+ .get();
825
+ return row ? toAgentTheme(row) : null;
826
+ });
672
827
  }
673
828
  /**
674
829
  * Create a new theme
675
830
  */
676
831
  export function createTheme(workspacePath, theme) {
677
- const db = openWorkspaceDatabase(workspacePath);
678
- const now = new Date().toISOString();
679
- db.prepare(`
680
- INSERT INTO agent_themes (id, name, display_name, description, builtin, created_at)
681
- VALUES (?, ?, ?, ?, ?, ?)
682
- `).run(theme.id, theme.name, theme.displayName, theme.description || null, theme.builtin ? 1 : 0, now);
683
- const created = db.prepare('SELECT * FROM agent_themes WHERE id = ?').get(theme.id);
684
- db.close();
685
- return created;
832
+ return withDrizzle(workspacePath, (ddb) => {
833
+ const now = new Date().toISOString();
834
+ ddb.insert(agentThemesTable).values({
835
+ id: theme.id,
836
+ name: theme.name,
837
+ displayName: theme.displayName,
838
+ description: theme.description || null,
839
+ builtin: theme.builtin || false,
840
+ createdAt: now,
841
+ }).run();
842
+ const created = ddb.select().from(agentThemesTable)
843
+ .where(eq(agentThemesTable.id, theme.id))
844
+ .get();
845
+ return toAgentTheme(created);
846
+ });
686
847
  }
687
848
  /**
688
849
  * Delete a theme (cannot delete builtin themes)
689
850
  */
690
851
  export function deleteTheme(workspacePath, themeId) {
691
- const db = openWorkspaceDatabase(workspacePath);
692
- // Check if builtin
693
- const theme = db.prepare('SELECT builtin FROM agent_themes WHERE id = ?').get(themeId);
694
- if (!theme) {
695
- db.close();
696
- return false;
697
- }
698
- if (theme.builtin) {
699
- db.close();
700
- throw new Error('Cannot delete built-in themes');
701
- }
702
- db.prepare('DELETE FROM agent_themes WHERE id = ?').run(themeId);
703
- db.close();
704
- return true;
852
+ return withDrizzle(workspacePath, (ddb) => {
853
+ const theme = ddb.select({ builtin: agentThemesTable.builtin })
854
+ .from(agentThemesTable)
855
+ .where(eq(agentThemesTable.id, themeId))
856
+ .get();
857
+ if (!theme) {
858
+ return false;
859
+ }
860
+ if (theme.builtin) {
861
+ throw new Error('Cannot delete built-in themes');
862
+ }
863
+ ddb.delete(agentThemesTable)
864
+ .where(eq(agentThemesTable.id, themeId))
865
+ .run();
866
+ return true;
867
+ });
705
868
  }
706
869
  /**
707
870
  * Get names for a theme
708
871
  */
709
872
  export function getThemeNames(workspacePath, themeId) {
710
- const db = openWorkspaceDatabase(workspacePath);
711
- const names = db.prepare('SELECT * FROM agent_theme_names WHERE theme_id = ? ORDER BY name').all(themeId);
712
- db.close();
713
- return names;
873
+ return withDrizzle(workspacePath, (ddb) => {
874
+ const rows = ddb.select().from(agentThemeNamesTable)
875
+ .where(eq(agentThemeNamesTable.themeId, themeId))
876
+ .orderBy(asc(agentThemeNamesTable.name))
877
+ .all();
878
+ return rows.map(row => ({
879
+ theme_id: row.themeId,
880
+ name: row.name,
881
+ }));
882
+ });
714
883
  }
715
884
  /**
716
885
  * Get available names for a theme.
@@ -719,141 +888,183 @@ export function getThemeNames(workspacePath, themeId) {
719
888
  * 2. The agent exists but its worktree directory is missing (manually deleted)
720
889
  */
721
890
  export function getAvailableThemeNames(workspacePath, themeId) {
722
- const db = openWorkspaceDatabase(workspacePath);
723
- // Get all theme names
724
- const names = db.prepare('SELECT name FROM agent_theme_names WHERE theme_id = ? ORDER BY name').all(themeId);
725
- // Get existing staff agents with their worktree paths (persistent type only)
726
- const existingAgents = db.prepare(`
727
- SELECT LOWER(name) as name, worktree_path
728
- FROM agents
729
- WHERE type = 'persistent' AND (status = 'active' OR status IS NULL)
730
- `).all();
731
- db.close();
732
- // Build a set of names that are truly in use (agent exists AND worktree exists)
733
- const inUseNames = new Set();
734
- for (const agent of existingAgents) {
735
- if (agent.worktree_path) {
736
- const fullPath = path.join(workspacePath, agent.worktree_path);
737
- if (fs.existsSync(fullPath)) {
891
+ return withDrizzle(workspacePath, (ddb) => {
892
+ // Get all theme names
893
+ const names = ddb.select({ name: agentThemeNamesTable.name })
894
+ .from(agentThemeNamesTable)
895
+ .where(eq(agentThemeNamesTable.themeId, themeId))
896
+ .orderBy(asc(agentThemeNamesTable.name))
897
+ .all();
898
+ // Get existing staff agents with their worktree paths (persistent type only)
899
+ const existingAgents = ddb.select({
900
+ name: sql `LOWER(${agentsTable.name})`,
901
+ worktreePath: agentsTable.worktreePath,
902
+ })
903
+ .from(agentsTable)
904
+ .where(and(eq(agentsTable.type, 'persistent'), or(eq(agentsTable.status, 'active'), isNull(agentsTable.status))))
905
+ .all();
906
+ // Build a set of names that are truly in use (agent exists AND worktree exists)
907
+ const inUseNames = new Set();
908
+ for (const agent of existingAgents) {
909
+ if (agent.worktreePath) {
910
+ const fullPath = path.join(workspacePath, agent.worktreePath);
911
+ if (fs.existsSync(fullPath)) {
912
+ inUseNames.add(agent.name);
913
+ }
914
+ }
915
+ else {
916
+ // No worktree path means we can't verify - treat as in use to be safe
738
917
  inUseNames.add(agent.name);
739
918
  }
740
919
  }
741
- else {
742
- // No worktree path means we can't verify - treat as in use to be safe
743
- inUseNames.add(agent.name);
744
- }
745
- }
746
- // Filter out names that are truly in use
747
- return names
748
- .map(n => n.name)
749
- .filter(name => !inUseNames.has(name.toLowerCase()));
920
+ // Filter out names that are truly in use
921
+ return names
922
+ .map(n => n.name)
923
+ .filter(name => !inUseNames.has(name.toLowerCase()));
924
+ });
750
925
  }
751
926
  /**
752
927
  * Add names to a theme (case-insensitive uniqueness)
753
928
  */
754
929
  export function addThemeNames(workspacePath, themeId, names) {
755
- const db = openWorkspaceDatabase(workspacePath);
756
- // Check for existing name (case-insensitive)
757
- const checkExisting = db.prepare('SELECT name FROM agent_theme_names WHERE theme_id = ? AND LOWER(name) = LOWER(?)');
758
- const insertName = db.prepare(`
759
- INSERT INTO agent_theme_names (theme_id, name)
760
- VALUES (?, ?)
761
- `);
762
- const transaction = db.transaction(() => {
763
- for (const name of names) {
764
- // Skip if name already exists (case-insensitive)
765
- const existing = checkExisting.get(themeId, name);
766
- if (existing) {
767
- continue;
930
+ withDrizzle(workspacePath, (ddb, sqliteDb) => {
931
+ const transaction = sqliteDb.transaction(() => {
932
+ for (const name of names) {
933
+ // Check for existing name (case-insensitive)
934
+ const existing = ddb.select({ name: agentThemeNamesTable.name })
935
+ .from(agentThemeNamesTable)
936
+ .where(and(eq(agentThemeNamesTable.themeId, themeId), sql `LOWER(${agentThemeNamesTable.name}) = LOWER(${name})`))
937
+ .get();
938
+ if (existing) {
939
+ continue;
940
+ }
941
+ ddb.insert(agentThemeNamesTable).values({
942
+ themeId,
943
+ name,
944
+ }).run();
768
945
  }
769
- insertName.run(themeId, name);
770
- }
946
+ });
947
+ transaction();
771
948
  });
772
- transaction();
773
- db.close();
774
949
  }
775
950
  /**
776
951
  * Add a media item to the database
777
952
  */
778
953
  export function addMediaItemToDatabase(workspacePath, item) {
779
- const db = openWorkspaceDatabase(workspacePath);
780
- db.prepare(`
781
- INSERT OR REPLACE INTO media_items (name, path, source_path, media_type, frame_interval, added_at)
782
- VALUES (?, ?, ?, ?, ?, ?)
783
- `).run(item.name, item.path, item.source_path || null, item.media_type, item.frame_interval || 30, new Date().toISOString());
784
- db.close();
954
+ withDrizzle(workspacePath, (ddb) => {
955
+ const now = new Date().toISOString();
956
+ ddb.insert(mediaItemsTable)
957
+ .values({
958
+ name: item.name,
959
+ path: item.path,
960
+ sourcePath: item.source_path || null,
961
+ mediaType: item.media_type,
962
+ frameInterval: item.frame_interval || 30,
963
+ addedAt: now,
964
+ })
965
+ .onConflictDoUpdate({
966
+ target: mediaItemsTable.name,
967
+ set: {
968
+ path: item.path,
969
+ sourcePath: item.source_path || null,
970
+ mediaType: item.media_type,
971
+ frameInterval: item.frame_interval || 30,
972
+ addedAt: now,
973
+ },
974
+ })
975
+ .run();
976
+ });
785
977
  }
786
978
  /**
787
979
  * Update media item after preprocessing
788
980
  */
789
981
  export function updateMediaItemStatus(workspacePath, name, updates) {
790
- const db = openWorkspaceDatabase(workspacePath);
791
- const sets = ['status = ?'];
792
- const values = [updates.status];
793
- if (updates.duration_seconds !== undefined) {
794
- sets.push('duration_seconds = ?');
795
- values.push(updates.duration_seconds);
796
- }
797
- if (updates.resolution !== undefined) {
798
- sets.push('resolution = ?');
799
- values.push(updates.resolution);
800
- }
801
- if (updates.frame_count !== undefined) {
802
- sets.push('frame_count = ?');
803
- values.push(updates.frame_count);
804
- }
805
- if (updates.has_transcript !== undefined) {
806
- sets.push('has_transcript = ?');
807
- values.push(updates.has_transcript ? 1 : 0);
808
- }
809
- if (updates.error_message !== undefined) {
810
- sets.push('error_message = ?');
811
- values.push(updates.error_message);
812
- }
813
- if (updates.status === 'ready' || updates.status === 'error') {
814
- sets.push('processed_at = ?');
815
- values.push(new Date().toISOString());
816
- }
817
- values.push(name);
818
- db.prepare(`UPDATE media_items SET ${sets.join(', ')} WHERE name = ?`).run(...values);
819
- db.close();
982
+ withDrizzle(workspacePath, (ddb) => {
983
+ const setValues = { status: updates.status };
984
+ if (updates.duration_seconds !== undefined) {
985
+ setValues.durationSeconds = updates.duration_seconds;
986
+ }
987
+ if (updates.resolution !== undefined) {
988
+ setValues.resolution = updates.resolution;
989
+ }
990
+ if (updates.frame_count !== undefined) {
991
+ setValues.frameCount = updates.frame_count;
992
+ }
993
+ if (updates.has_transcript !== undefined) {
994
+ setValues.hasTranscript = updates.has_transcript;
995
+ }
996
+ if (updates.error_message !== undefined) {
997
+ setValues.errorMessage = updates.error_message;
998
+ }
999
+ if (updates.status === 'ready' || updates.status === 'error') {
1000
+ setValues.processedAt = new Date().toISOString();
1001
+ }
1002
+ ddb.update(mediaItemsTable)
1003
+ .set(setValues)
1004
+ .where(eq(mediaItemsTable.name, name))
1005
+ .run();
1006
+ });
820
1007
  }
821
1008
  /**
822
1009
  * Get all media items in workspace
823
1010
  */
824
1011
  export function getWorkspaceMediaItems(workspacePath) {
825
- const db = openWorkspaceDatabase(workspacePath);
826
- const rows = db.prepare('SELECT * FROM media_items ORDER BY added_at').all();
827
- db.close();
828
- return rows.map(row => ({
829
- ...row,
830
- media_type: row.media_type,
831
- has_transcript: Boolean(row.has_transcript),
832
- status: row.status,
833
- }));
1012
+ return withDrizzle(workspacePath, (ddb) => {
1013
+ const rows = ddb.select().from(mediaItemsTable)
1014
+ .orderBy(asc(mediaItemsTable.addedAt))
1015
+ .all();
1016
+ return rows.map(row => ({
1017
+ name: row.name,
1018
+ path: row.path,
1019
+ source_path: row.sourcePath,
1020
+ media_type: row.mediaType,
1021
+ duration_seconds: row.durationSeconds,
1022
+ resolution: row.resolution,
1023
+ frame_count: row.frameCount,
1024
+ has_transcript: Boolean(row.hasTranscript),
1025
+ frame_interval: row.frameInterval,
1026
+ status: row.status,
1027
+ error_message: row.errorMessage,
1028
+ added_at: row.addedAt,
1029
+ processed_at: row.processedAt,
1030
+ }));
1031
+ });
834
1032
  }
835
1033
  /**
836
1034
  * Get a single media item by name
837
1035
  */
838
1036
  export function getMediaItem(workspacePath, name) {
839
- const db = openWorkspaceDatabase(workspacePath);
840
- const row = db.prepare('SELECT * FROM media_items WHERE name = ?').get(name);
841
- db.close();
842
- if (!row)
843
- return null;
844
- return {
845
- ...row,
846
- media_type: row.media_type,
847
- has_transcript: Boolean(row.has_transcript),
848
- status: row.status,
849
- };
1037
+ return withDrizzle(workspacePath, (ddb) => {
1038
+ const row = ddb.select().from(mediaItemsTable)
1039
+ .where(eq(mediaItemsTable.name, name))
1040
+ .get();
1041
+ if (!row)
1042
+ return null;
1043
+ return {
1044
+ name: row.name,
1045
+ path: row.path,
1046
+ source_path: row.sourcePath,
1047
+ media_type: row.mediaType,
1048
+ duration_seconds: row.durationSeconds,
1049
+ resolution: row.resolution,
1050
+ frame_count: row.frameCount,
1051
+ has_transcript: Boolean(row.hasTranscript),
1052
+ frame_interval: row.frameInterval,
1053
+ status: row.status,
1054
+ error_message: row.errorMessage,
1055
+ added_at: row.addedAt,
1056
+ processed_at: row.processedAt,
1057
+ };
1058
+ });
850
1059
  }
851
1060
  /**
852
1061
  * Remove a media item from the database
853
1062
  */
854
1063
  export function removeMediaItemFromDatabase(workspacePath, name) {
855
- const db = openWorkspaceDatabase(workspacePath);
856
- db.prepare('DELETE FROM media_items WHERE name = ?').run(name);
857
- db.close();
1064
+ withDrizzle(workspacePath, (ddb) => {
1065
+ ddb.delete(mediaItemsTable)
1066
+ .where(eq(mediaItemsTable.name, name))
1067
+ .run();
1068
+ });
858
1069
  }
859
1070
  //# sourceMappingURL=index.js.map