@programinglive/dev-workflow-mcp-server 1.7.0 → 1.7.10

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.
@@ -0,0 +1,291 @@
1
+ import pg from "pg";
2
+ import { DbAdapter } from "./base.js";
3
+
4
+ const { Pool } = pg;
5
+
6
+ const FREQUENCY_FORMATS = {
7
+ daily: "YYYY-MM-DD",
8
+ monthly: "YYYY-MM",
9
+ yearly: "YYYY",
10
+ };
11
+
12
+ export class PostgresAdapter extends DbAdapter {
13
+ constructor(config = {}) {
14
+ super(config);
15
+ this.pool = null;
16
+ }
17
+
18
+ async connect() {
19
+ if (!this.pool) {
20
+ this.pool = new Pool({
21
+ connectionString: this.config.connectionUrl,
22
+ // pg pool handles connections automatically
23
+ });
24
+ await this.migrate();
25
+ }
26
+ }
27
+
28
+ async migrate() {
29
+ const client = await this.pool.connect();
30
+ try {
31
+ await client.query(`
32
+ CREATE TABLE IF NOT EXISTS workflow_history (
33
+ id SERIAL PRIMARY KEY,
34
+ user_id TEXT NOT NULL,
35
+ project_path TEXT DEFAULT '',
36
+ task_description TEXT NOT NULL,
37
+ task_type TEXT NOT NULL,
38
+ commit_message TEXT,
39
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
40
+ forced BOOLEAN DEFAULT FALSE,
41
+ force_reason TEXT,
42
+ dropped BOOLEAN DEFAULT FALSE,
43
+ drop_reason TEXT,
44
+ UNIQUE (user_id, project_path, timestamp, task_description)
45
+ )
46
+ `);
47
+
48
+ await client.query(`
49
+ CREATE TABLE IF NOT EXISTS project_summary (
50
+ user_id TEXT,
51
+ project_path TEXT DEFAULT '',
52
+ total_tasks INTEGER DEFAULT 0,
53
+ task_types JSONB,
54
+ last_active TIMESTAMP,
55
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
56
+ recent_tasks JSONB,
57
+ PRIMARY KEY (user_id, project_path)
58
+ )
59
+ `);
60
+
61
+ await client.query(`
62
+ CREATE TABLE IF NOT EXISTS workflow_state (
63
+ user_id TEXT,
64
+ project_path TEXT DEFAULT '',
65
+ state_json JSONB,
66
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
67
+ PRIMARY KEY (user_id, project_path)
68
+ )
69
+ `);
70
+ } finally {
71
+ client.release();
72
+ }
73
+ }
74
+
75
+ async insertHistoryEntry(userId, projectPath, entry) {
76
+ await this.connect();
77
+ const normalizedProjectPath = projectPath || '';
78
+
79
+ const query = `
80
+ INSERT INTO workflow_history
81
+ (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
82
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
83
+ ON CONFLICT (user_id, project_path, timestamp, task_description) DO UPDATE SET
84
+ commit_message=EXCLUDED.commit_message,
85
+ forced=EXCLUDED.forced,
86
+ force_reason=EXCLUDED.force_reason,
87
+ dropped=EXCLUDED.dropped,
88
+ drop_reason=EXCLUDED.drop_reason
89
+ `;
90
+
91
+ await this.pool.query(query, [
92
+ userId,
93
+ normalizedProjectPath,
94
+ entry.taskDescription,
95
+ entry.taskType,
96
+ entry.commitMessage,
97
+ entry.timestamp || new Date().toISOString(),
98
+ entry.forced || false,
99
+ entry.forceReason || null,
100
+ entry.dropped || false,
101
+ entry.dropReason || null
102
+ ]);
103
+
104
+ await this.updateSummaryForUser(userId, normalizedProjectPath);
105
+ }
106
+
107
+ async updateSummaryForUser(userId, projectPath) {
108
+ await this.connect();
109
+ const normalizedProjectPath = projectPath || '';
110
+
111
+ const res = await this.pool.query(`
112
+ SELECT task_type, timestamp, task_description
113
+ FROM workflow_history
114
+ WHERE user_id = $1 AND project_path = $2
115
+ ORDER BY timestamp DESC
116
+ LIMIT 20
117
+ `, [userId, normalizedProjectPath]);
118
+
119
+ const rows = res.rows;
120
+
121
+ const taskTypes = {};
122
+ for (const row of rows) {
123
+ taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
124
+ }
125
+
126
+ const recentTasks = rows.slice(0, 5).map(r => ({
127
+ description: r.task_description,
128
+ type: r.task_type,
129
+ timestamp: r.timestamp,
130
+ }));
131
+
132
+ const lastActive = rows[0]?.timestamp || null;
133
+
134
+ const upsert = `
135
+ INSERT INTO project_summary
136
+ (user_id, project_path, total_tasks, task_types, last_active, recent_tasks, updated_at)
137
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
138
+ ON CONFLICT (user_id, project_path) DO UPDATE SET
139
+ total_tasks=EXCLUDED.total_tasks,
140
+ task_types=EXCLUDED.task_types,
141
+ last_active=EXCLUDED.last_active,
142
+ recent_tasks=EXCLUDED.recent_tasks,
143
+ updated_at=EXCLUDED.updated_at
144
+ `;
145
+
146
+ await this.pool.query(upsert, [
147
+ userId,
148
+ normalizedProjectPath,
149
+ rows.length,
150
+ JSON.stringify(taskTypes),
151
+ lastActive,
152
+ JSON.stringify(recentTasks),
153
+ new Date().toISOString()
154
+ ]);
155
+ }
156
+
157
+ async getSummaryForUser(userId, projectPath) {
158
+ await this.connect();
159
+ const normalizedProjectPath = projectPath || '';
160
+
161
+ const res = await this.pool.query(`
162
+ SELECT * FROM project_summary WHERE user_id = $1 AND project_path = $2
163
+ `, [userId, normalizedProjectPath]);
164
+
165
+ const row = res.rows[0];
166
+ if (!row) return null;
167
+
168
+ return {
169
+ totalTasks: row.total_tasks,
170
+ taskTypes: row.task_types, // pg parses jsonb automatically
171
+ lastActive: row.last_active,
172
+ recentTasks: row.recent_tasks,
173
+ updatedAt: row.updated_at,
174
+ };
175
+ }
176
+
177
+ async getHistoryForUser(userId, projectPath, options = {}) {
178
+ await this.connect();
179
+ const normalizedProjectPath = projectPath || '';
180
+
181
+ const { page = 1, pageSize = 20, startDate, endDate } = options;
182
+ const sanitizedPage = Math.max(1, Number.isFinite(Number(page)) ? Math.floor(Number(page)) : 1);
183
+ const sanitizedPageSize = Math.min(100, Math.max(1, Number.isFinite(Number(pageSize)) ? Math.floor(Number(pageSize)) : 20));
184
+ const offset = (sanitizedPage - 1) * sanitizedPageSize;
185
+
186
+ const clauses = [];
187
+ const params = [userId, normalizedProjectPath];
188
+ let paramIdx = 3;
189
+
190
+ if (startDate) {
191
+ clauses.push(`DATE(timestamp) >= DATE($${paramIdx++})`);
192
+ params.push(startDate);
193
+ }
194
+ if (endDate) {
195
+ clauses.push(`DATE(timestamp) <= DATE($${paramIdx++})`);
196
+ params.push(endDate);
197
+ }
198
+
199
+ const where = [`user_id = $1`, `project_path = $2`, ...clauses].join(" AND ");
200
+
201
+ const countRes = await this.pool.query(
202
+ `SELECT COUNT(*) as total FROM workflow_history WHERE ${where}`,
203
+ params
204
+ );
205
+ const total = parseInt(countRes.rows[0].total, 10);
206
+
207
+ const entriesRes = await this.pool.query(`
208
+ SELECT *
209
+ FROM workflow_history
210
+ WHERE ${where}
211
+ ORDER BY timestamp DESC
212
+ LIMIT $${paramIdx++}
213
+ OFFSET $${paramIdx++}
214
+ `, [...params, sanitizedPageSize, offset]);
215
+
216
+ return {
217
+ entries: entriesRes.rows,
218
+ total,
219
+ page: sanitizedPage,
220
+ pageSize: sanitizedPageSize,
221
+ };
222
+ }
223
+
224
+ async getHistorySummary(userId, projectPath, options = {}) {
225
+ await this.connect();
226
+ const normalizedProjectPath = projectPath || '';
227
+
228
+ const { startDate, endDate, frequency = "daily" } = options;
229
+ const format = FREQUENCY_FORMATS[frequency] || FREQUENCY_FORMATS.daily;
230
+
231
+ const clauses = [];
232
+ const params = [userId, normalizedProjectPath];
233
+ let paramIdx = 3;
234
+
235
+ if (startDate) {
236
+ clauses.push(`DATE(timestamp) >= DATE($${paramIdx++})`);
237
+ params.push(startDate);
238
+ }
239
+ if (endDate) {
240
+ clauses.push(`DATE(timestamp) <= DATE($${paramIdx++})`);
241
+ params.push(endDate);
242
+ }
243
+
244
+ const where = [`user_id = $1`, `project_path = $2`, ...clauses].join(" AND ");
245
+
246
+ const res = await this.pool.query(`
247
+ SELECT to_char(timestamp, '${format}') AS period, COUNT(*) AS count
248
+ FROM workflow_history
249
+ WHERE ${where}
250
+ GROUP BY period
251
+ ORDER BY period DESC
252
+ `, params);
253
+
254
+ return res.rows;
255
+ }
256
+
257
+ async saveState(userId, projectPath, state) {
258
+ await this.connect();
259
+ const normalizedProjectPath = projectPath || '';
260
+
261
+ const query = `
262
+ INSERT INTO workflow_state
263
+ (user_id, project_path, state_json, updated_at)
264
+ VALUES ($1, $2, $3, $4)
265
+ ON CONFLICT (user_id, project_path) DO UPDATE SET
266
+ state_json=EXCLUDED.state_json,
267
+ updated_at=EXCLUDED.updated_at
268
+ `;
269
+
270
+ await this.pool.query(query, [
271
+ userId,
272
+ normalizedProjectPath,
273
+ JSON.stringify(state),
274
+ new Date().toISOString()
275
+ ]);
276
+ }
277
+
278
+ async getState(userId, projectPath) {
279
+ await this.connect();
280
+ const normalizedProjectPath = projectPath || '';
281
+
282
+ const res = await this.pool.query(`
283
+ SELECT state_json FROM workflow_state WHERE user_id = $1 AND project_path = $2
284
+ `, [userId, normalizedProjectPath]);
285
+
286
+ const row = res.rows[0];
287
+ if (!row) return null;
288
+
289
+ return row.state_json; // pg automatically parses JSONB
290
+ }
291
+ }
@@ -0,0 +1,343 @@
1
+ import Database from "better-sqlite3";
2
+ import { existsSync } from "fs";
3
+ import path from "path";
4
+ import { DbAdapter } from "./base.js";
5
+
6
+ const DB_FILENAME = "dev-workflow.db";
7
+ const FREQUENCY_FORMATS = {
8
+ daily: "%Y-%m-%d",
9
+ monthly: "%Y-%m",
10
+ yearly: "%Y",
11
+ };
12
+
13
+ export class SqliteAdapter extends DbAdapter {
14
+ constructor(config = {}) {
15
+ super(config);
16
+ this.db = null;
17
+ }
18
+
19
+ getDbPath() {
20
+ // If a specific path is provided in config, use it
21
+ if (this.config.dbPath) {
22
+ return this.config.dbPath;
23
+ }
24
+
25
+ // Otherwise, attempt to find project root relative to CWD
26
+ const initCwd = process.env.INIT_CWD || process.cwd();
27
+ let projectRoot = initCwd;
28
+ while (projectRoot !== path.dirname(projectRoot)) {
29
+ if (existsSync(path.join(projectRoot, "package.json"))) break;
30
+ projectRoot = path.dirname(projectRoot);
31
+ }
32
+ return path.join(projectRoot, ".state", DB_FILENAME);
33
+ }
34
+
35
+ async connect() {
36
+ if (!this.db) {
37
+ const dbPath = this.getDbPath();
38
+ this.db = new Database(dbPath);
39
+ this.db.pragma("journal_mode = WAL");
40
+ await this.migrate();
41
+ }
42
+ }
43
+
44
+ async migrate() {
45
+ // 1. Create tables if they don't exist (new installs get the correct schema immediately)
46
+ const createHistory = `
47
+ CREATE TABLE IF NOT EXISTS workflow_history (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ user_id TEXT NOT NULL,
50
+ project_path TEXT DEFAULT '',
51
+ task_description TEXT NOT NULL,
52
+ task_type TEXT NOT NULL,
53
+ commit_message TEXT,
54
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
55
+ forced INTEGER DEFAULT 0,
56
+ force_reason TEXT,
57
+ dropped INTEGER DEFAULT 0,
58
+ drop_reason TEXT,
59
+ UNIQUE(user_id, project_path, timestamp, task_description)
60
+ )
61
+ `;
62
+ // Note: project_summary definition
63
+ const createSummary = `
64
+ CREATE TABLE IF NOT EXISTS project_summary (
65
+ user_id TEXT,
66
+ project_path TEXT DEFAULT '',
67
+ total_tasks INTEGER DEFAULT 0,
68
+ task_types TEXT DEFAULT '{}',
69
+ last_active DATETIME,
70
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
71
+ recent_tasks TEXT DEFAULT '[]',
72
+ PRIMARY KEY (user_id, project_path)
73
+ )
74
+ `;
75
+ const createState = `
76
+ CREATE TABLE IF NOT EXISTS workflow_state (
77
+ user_id TEXT,
78
+ project_path TEXT DEFAULT '',
79
+ state_json TEXT NOT NULL,
80
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
81
+ PRIMARY KEY (user_id, project_path)
82
+ )
83
+ `;
84
+
85
+ this.db.exec(createHistory);
86
+
87
+ // 2. MIGRATION: Handle existing databases (workflow_history)
88
+ try {
89
+ const tableInfo = this.db.prepare("PRAGMA table_info(workflow_history)").all();
90
+ if (!tableInfo.some(col => col.name === 'project_path')) {
91
+ // Add column to existing table
92
+ this.db.exec("ALTER TABLE workflow_history ADD COLUMN project_path TEXT DEFAULT ''");
93
+
94
+ // Re-create index to include new column
95
+ this.db.exec("DROP INDEX IF EXISTS idx_workflow_history_user");
96
+ // We need to fix the UNIQUE constraint if possible, but SQLite ALTER TABLE limitations make that hard.
97
+ // For now, ensuring the column exists allows the new INSERT logic (inserting '') to work.
98
+ // Duplicate entries from old schema (with no project_path) effectively mean project_path='' which matches new default.
99
+ }
100
+ } catch (e) {
101
+ console.error("Migration error (workflow_history):", e.message);
102
+ }
103
+
104
+ // 3. MIGRATION: Handle project_summary (Drop and Recreate if schema mismatch)
105
+ // Since it's a cache and we're changing the Primary Key, it's safer/easier to just drop it.
106
+ let recreateSummary = false;
107
+ try {
108
+ const summaryInfo = this.db.prepare("PRAGMA table_info(project_summary)").all();
109
+ // If table exists but missing project_path, drop it
110
+ if (summaryInfo.length > 0 && !summaryInfo.some(col => col.name === 'project_path')) {
111
+ this.db.exec("DROP TABLE IF EXISTS project_summary");
112
+ recreateSummary = true;
113
+ }
114
+ } catch (e) {
115
+ // Ignore
116
+ }
117
+
118
+ this.db.exec(createSummary); // Will create if we dropped it or if it didn't exist
119
+ this.db.exec(createState);
120
+
121
+ // 4. Create Indexes (Safe now that columns exist)
122
+ const idxHistory = `CREATE INDEX IF NOT EXISTS idx_workflow_history_user_project ON workflow_history(user_id, project_path)`;
123
+ const idxSummary = `CREATE INDEX IF NOT EXISTS idx_project_summary_user_project ON project_summary(user_id, project_path)`;
124
+
125
+ // START FIX: Create explicit UNIQUE index to support ON CONFLICT clause for migrated tables
126
+ // The original table constraint was UNIQUE(user_id, timestamp, task_description).
127
+ // The new Insert statement relies on UNIQUE(user_id, project_path, timestamp, task_description).
128
+ // We must ensure this index exists for ON CONFLICT to work.
129
+ const idxUnique = `CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_history_entry_v2 ON workflow_history(user_id, project_path, timestamp, task_description)`;
130
+ // END FIX
131
+
132
+ this.db.exec(idxHistory);
133
+ this.db.exec(idxSummary);
134
+ this.db.exec(idxUnique);
135
+ }
136
+
137
+ async insertHistoryEntry(userId, projectPath, entry) {
138
+ await this.connect();
139
+ const normalizedProjectPath = projectPath || '';
140
+
141
+ // SQLite upsert syntax
142
+ const stmt = this.db.prepare(`
143
+ INSERT INTO workflow_history
144
+ (user_id, project_path, task_description, task_type, commit_message, timestamp, forced, force_reason, dropped, drop_reason)
145
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
146
+ ON CONFLICT(user_id, project_path, timestamp, task_description) DO UPDATE SET
147
+ commit_message=excluded.commit_message,
148
+ forced=excluded.forced,
149
+ force_reason=excluded.force_reason,
150
+ dropped=excluded.dropped,
151
+ drop_reason=excluded.drop_reason
152
+ `);
153
+
154
+ stmt.run(
155
+ userId,
156
+ normalizedProjectPath,
157
+ entry.taskDescription,
158
+ entry.taskType,
159
+ entry.commitMessage,
160
+ entry.timestamp || new Date().toISOString(),
161
+ entry.forced ? 1 : 0,
162
+ entry.forceReason || null,
163
+ entry.dropped ? 1 : 0,
164
+ entry.dropReason || null
165
+ );
166
+
167
+ await this.updateSummaryForUser(userId, normalizedProjectPath);
168
+ }
169
+
170
+ async updateSummaryForUser(userId, projectPath) {
171
+ await this.connect();
172
+ const normalizedProjectPath = projectPath || '';
173
+
174
+ const getHistory = this.db.prepare(`
175
+ SELECT task_type, timestamp, task_description
176
+ FROM workflow_history
177
+ WHERE user_id = ? AND project_path = ?
178
+ ORDER BY timestamp DESC
179
+ LIMIT 20
180
+ `);
181
+ const rows = getHistory.all(userId, normalizedProjectPath);
182
+
183
+ const taskTypes = {};
184
+ for (const row of rows) {
185
+ taskTypes[row.task_type] = (taskTypes[row.task_type] || 0) + 1;
186
+ }
187
+
188
+ const recentTasks = rows.slice(0, 5).map(r => ({
189
+ description: r.task_description,
190
+ type: r.task_type,
191
+ timestamp: r.timestamp,
192
+ }));
193
+
194
+ const lastActive = rows[0]?.timestamp || null;
195
+
196
+ const upsert = this.db.prepare(`
197
+ INSERT INTO project_summary
198
+ (user_id, project_path, total_tasks, task_types, last_active, recent_tasks, updated_at)
199
+ VALUES (?, ?, ?, ?, ?, ?, ?)
200
+ ON CONFLICT(user_id, project_path) DO UPDATE SET
201
+ total_tasks=excluded.total_tasks,
202
+ task_types=excluded.task_types,
203
+ last_active=excluded.last_active,
204
+ recent_tasks=excluded.recent_tasks,
205
+ updated_at=excluded.updated_at
206
+ `);
207
+
208
+ upsert.run(
209
+ userId,
210
+ normalizedProjectPath,
211
+ rows.length,
212
+ JSON.stringify(taskTypes),
213
+ lastActive,
214
+ JSON.stringify(recentTasks),
215
+ new Date().toISOString()
216
+ );
217
+ }
218
+
219
+ async getSummaryForUser(userId, projectPath) {
220
+ await this.connect();
221
+ const normalizedProjectPath = projectPath || '';
222
+
223
+ const stmt = this.db.prepare(`
224
+ SELECT * FROM project_summary WHERE user_id = ? AND project_path = ?
225
+ `);
226
+ const row = stmt.get(userId, normalizedProjectPath);
227
+
228
+ if (!row) return null;
229
+ return {
230
+ totalTasks: row.total_tasks,
231
+ taskTypes: JSON.parse(row.task_types),
232
+ lastActive: row.last_active,
233
+ recentTasks: JSON.parse(row.recent_tasks),
234
+ updatedAt: row.updated_at,
235
+ };
236
+ }
237
+
238
+ buildHistoryFilters({ startDate, endDate }) {
239
+ const clauses = [];
240
+ const params = [];
241
+ if (startDate) {
242
+ clauses.push("DATE(timestamp) >= DATE(?)");
243
+ params.push(startDate);
244
+ }
245
+ if (endDate) {
246
+ clauses.push("DATE(timestamp) <= DATE(?)");
247
+ params.push(endDate);
248
+ }
249
+ return { clauses, params };
250
+ }
251
+
252
+ async getHistoryForUser(userId, projectPath, options = {}) {
253
+ await this.connect();
254
+ const normalizedProjectPath = projectPath || '';
255
+
256
+ const { page = 1, pageSize = 20, startDate, endDate } = options;
257
+ const sanitizedPage = Math.max(1, Number.isFinite(Number(page)) ? Math.floor(Number(page)) : 1);
258
+ const sanitizedPageSize = Math.min(100, Math.max(1, Number.isFinite(Number(pageSize)) ? Math.floor(Number(pageSize)) : 20));
259
+ const offset = (sanitizedPage - 1) * sanitizedPageSize;
260
+
261
+ const { clauses, params } = this.buildHistoryFilters({ startDate, endDate });
262
+ const where = ["user_id = ?", "project_path = ?", ...clauses].join(" AND ");
263
+
264
+ const countStmt = this.db.prepare(`SELECT COUNT(*) as total FROM workflow_history WHERE ${where}`);
265
+ const total = countStmt.get(userId, normalizedProjectPath, ...params).total;
266
+
267
+ const rowsStmt = this.db.prepare(`
268
+ SELECT *
269
+ FROM workflow_history
270
+ WHERE ${where}
271
+ ORDER BY timestamp DESC
272
+ LIMIT ?
273
+ OFFSET ?
274
+ `);
275
+ const entries = rowsStmt.all(userId, normalizedProjectPath, ...params, sanitizedPageSize, offset);
276
+
277
+ return {
278
+ entries,
279
+ total,
280
+ page: sanitizedPage,
281
+ pageSize: sanitizedPageSize,
282
+ };
283
+ }
284
+
285
+ async getHistorySummary(userId, projectPath, options = {}) {
286
+ await this.connect();
287
+ const normalizedProjectPath = projectPath || '';
288
+
289
+ const { startDate, endDate, frequency = "daily" } = options;
290
+ const format = FREQUENCY_FORMATS[frequency] || FREQUENCY_FORMATS.daily;
291
+ const { clauses, params } = this.buildHistoryFilters({ startDate, endDate });
292
+ const where = ["user_id = ?", "project_path = ?", ...clauses].join(" AND ");
293
+
294
+ const stmt = this.db.prepare(`
295
+ SELECT strftime('${format}', timestamp) AS period, COUNT(*) AS count
296
+ FROM workflow_history
297
+ WHERE ${where}
298
+ GROUP BY period
299
+ ORDER BY period DESC
300
+ `);
301
+
302
+ return stmt
303
+ .all(userId, normalizedProjectPath, ...params)
304
+ .map((row) => ({ period: row.period, count: row.count }));
305
+ }
306
+
307
+ async saveState(userId, projectPath, state) {
308
+ await this.connect();
309
+ const normalizedProjectPath = projectPath || '';
310
+
311
+ const stmt = this.db.prepare(`
312
+ INSERT INTO workflow_state (user_id, project_path, state_json, updated_at)
313
+ VALUES (?, ?, ?, ?)
314
+ ON CONFLICT(user_id, project_path) DO UPDATE SET
315
+ state_json=excluded.state_json,
316
+ updated_at=excluded.updated_at
317
+ `);
318
+
319
+ stmt.run(
320
+ userId,
321
+ normalizedProjectPath,
322
+ JSON.stringify(state),
323
+ new Date().toISOString()
324
+ );
325
+ }
326
+
327
+ async getState(userId, projectPath) {
328
+ await this.connect();
329
+ const normalizedProjectPath = projectPath || '';
330
+
331
+ const stmt = this.db.prepare(`
332
+ SELECT state_json FROM workflow_state WHERE user_id = ? AND project_path = ?
333
+ `);
334
+ const row = stmt.get(userId, normalizedProjectPath);
335
+
336
+ if (!row) return null;
337
+ try {
338
+ return JSON.parse(row.state_json);
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+ }