@programinglive/dev-workflow-mcp-server 1.7.0 → 1.7.9
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/.gitattributes +10 -0
- package/.github/workflows/github-release.yml +17 -0
- package/CHANGELOG.md +146 -1
- package/README.md +74 -2
- package/db/adapters/base.js +46 -0
- package/db/adapters/mysql.js +292 -0
- package/db/adapters/postgres.js +291 -0
- package/db/adapters/sqlite.js +343 -0
- package/db/index.js +52 -183
- package/docs/product/PRD.md +15 -5
- package/docs/release-notes/RELEASE_NOTES.md +129 -0
- package/package.json +6 -3
- package/scripts/release.cjs +11 -4
- package/tests/handlers.test.js +6 -1
- package/tools/handlers.js +22 -11
- package/vite.config.js +8 -0
- package/web/app/docs/page.tsx +81 -81
- package/workflow-state.js +32 -5
- package/.github/workflows/npm-publish.yml +0 -50
- package/web/tests/build.test.js +0 -69
|
@@ -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
|
+
}
|