devsmind-mcp 2.2.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -606
- package/dist/cli/analyze.d.ts +13 -0
- package/dist/cli/analyze.js +143 -0
- package/dist/cli/analyze.js.map +1 -0
- package/dist/cli/index.js +62 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/integrations/memory.js +7 -2
- package/dist/cli/integrations/memory.js.map +1 -1
- package/dist/cli/integrations/prompt.d.ts +2 -0
- package/dist/cli/integrations/prompt.js +21 -7
- package/dist/cli/integrations/prompt.js.map +1 -1
- package/dist/cli/rule.js +35 -54
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/sync.d.ts +7 -0
- package/dist/cli/sync.js +40 -7
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/workflow.d.ts +8 -0
- package/dist/cli/workflow.js +156 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/db/analyze.d.ts +67 -0
- package/dist/db/analyze.js +163 -0
- package/dist/db/analyze.js.map +1 -0
- package/dist/db/database.d.ts +151 -2
- package/dist/db/database.js +682 -62
- package/dist/db/database.js.map +1 -1
- package/dist/db/schema.d.ts +28 -1
- package/dist/db/schema.js +34 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/db/staging.d.ts +4 -0
- package/dist/db/staging.js +16 -2
- package/dist/db/staging.js.map +1 -1
- package/dist/db/workflow-import.d.ts +22 -0
- package/dist/db/workflow-import.js +116 -0
- package/dist/db/workflow-import.js.map +1 -0
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +369 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/utils/config.d.ts +2 -0
- package/dist/utils/config.js +11 -0
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/git.d.ts +14 -0
- package/dist/utils/git.js +43 -0
- package/dist/utils/git.js.map +1 -0
- package/package.json +1 -1
package/dist/db/database.js
CHANGED
|
@@ -114,6 +114,9 @@ class DevMindDatabase {
|
|
|
114
114
|
)
|
|
115
115
|
`);
|
|
116
116
|
}
|
|
117
|
+
getContext() {
|
|
118
|
+
return this.context;
|
|
119
|
+
}
|
|
117
120
|
getSystemMeta(key) {
|
|
118
121
|
try {
|
|
119
122
|
const stmt = this.db.prepare('SELECT value FROM system_meta WHERE key = ?');
|
|
@@ -183,6 +186,9 @@ class DevMindDatabase {
|
|
|
183
186
|
}
|
|
184
187
|
fs.mkdirSync(p, { recursive: true });
|
|
185
188
|
}
|
|
189
|
+
// NOTE: 'workflows/' is intentionally NOT wiped — workflow data is long-lived
|
|
190
|
+
// cross-session state that survives a node/history reindex. It will be
|
|
191
|
+
// restored from workflows/*/workflow.json on the next syncFromDisk().
|
|
186
192
|
this.vacuum();
|
|
187
193
|
}
|
|
188
194
|
/**
|
|
@@ -232,11 +238,12 @@ class DevMindDatabase {
|
|
|
232
238
|
}
|
|
233
239
|
// --- Node Operations ---
|
|
234
240
|
upsertNode(node) {
|
|
241
|
+
const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
|
|
235
242
|
const existing = this.getNode(node.id);
|
|
236
243
|
if (existing) {
|
|
237
244
|
let finalPath = existing.file_path;
|
|
238
245
|
const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
239
|
-
const incoming =
|
|
246
|
+
const incoming = canonicalFp.trim();
|
|
240
247
|
if (!paths.includes(incoming)) {
|
|
241
248
|
paths.push(incoming);
|
|
242
249
|
finalPath = paths.join(', ');
|
|
@@ -257,9 +264,9 @@ class DevMindDatabase {
|
|
|
257
264
|
INSERT INTO nodes (id, type, name, file_path, signature)
|
|
258
265
|
VALUES (?, ?, ?, ?, ?)
|
|
259
266
|
`);
|
|
260
|
-
stmt.run(node.id, node.type, node.name,
|
|
267
|
+
stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
|
|
261
268
|
}
|
|
262
|
-
this.writeGraphToDisk(
|
|
269
|
+
this.writeGraphToDisk(canonicalFp);
|
|
263
270
|
}
|
|
264
271
|
getNode(id) {
|
|
265
272
|
const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
|
|
@@ -267,8 +274,8 @@ class DevMindDatabase {
|
|
|
267
274
|
if (direct)
|
|
268
275
|
return direct;
|
|
269
276
|
if (!id.includes('#')) {
|
|
270
|
-
const suffixStmt = this.db.prepare(
|
|
271
|
-
const matches = suffixStmt.all(`%#${id}`);
|
|
277
|
+
const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
|
|
278
|
+
const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
|
|
272
279
|
if (matches.length === 1) {
|
|
273
280
|
return matches[0];
|
|
274
281
|
}
|
|
@@ -314,20 +321,22 @@ class DevMindDatabase {
|
|
|
314
321
|
this.writeGraphToDisk(p);
|
|
315
322
|
}
|
|
316
323
|
}
|
|
317
|
-
|
|
324
|
+
/** `newFilePath`: pass when the rename is a file move (analyze's rename migration), leave undefined for a pure symbol-id rename where the file itself is unchanged. */
|
|
325
|
+
renameNode(oldId, newId, newName, newFilePath) {
|
|
318
326
|
const node = this.getNode(oldId);
|
|
319
327
|
if (!node) {
|
|
320
328
|
throw new Error(`Node not found: ${oldId}`);
|
|
321
329
|
}
|
|
322
330
|
const name = newName || (node.name === oldId ? newId : node.name);
|
|
331
|
+
const filePath = newFilePath || node.file_path;
|
|
323
332
|
this.db.pragma('foreign_keys = OFF');
|
|
324
333
|
try {
|
|
325
334
|
const runTx = this.db.transaction(() => {
|
|
326
335
|
const insertStmt = this.db.prepare(`
|
|
327
|
-
INSERT INTO nodes (id, type, name, file_path, signature, created_at)
|
|
328
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
336
|
+
INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
|
|
337
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
329
338
|
`);
|
|
330
|
-
insertStmt.run(newId, node.type, name, node.
|
|
339
|
+
insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
|
|
331
340
|
const updateSourceStmt = this.db.prepare(`
|
|
332
341
|
UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
|
|
333
342
|
`);
|
|
@@ -345,8 +354,13 @@ class DevMindDatabase {
|
|
|
345
354
|
});
|
|
346
355
|
runTx();
|
|
347
356
|
if (node.file_path) {
|
|
357
|
+
// Rewrite the OLD file's graph JSON too when the file itself moved, so the
|
|
358
|
+
// stale node entry doesn't linger under the old path's JSON on disk.
|
|
348
359
|
this.writeGraphToDisk(node.file_path);
|
|
349
360
|
}
|
|
361
|
+
if (filePath && filePath !== node.file_path) {
|
|
362
|
+
this.writeGraphToDisk(filePath);
|
|
363
|
+
}
|
|
350
364
|
// Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
|
|
351
365
|
// (which still reference oldId on disk). The DB was already repointed to newId above,
|
|
352
366
|
// so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
|
|
@@ -357,7 +371,7 @@ class DevMindDatabase {
|
|
|
357
371
|
// longer exists in the DB) and re-insert it right back, undoing the rename.
|
|
358
372
|
const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
|
|
359
373
|
for (const row of historyIds) {
|
|
360
|
-
this.patchHistoryDiskIdentity(row.id, newId, name, node.type,
|
|
374
|
+
this.patchHistoryDiskIdentity(row.id, newId, name, node.type, filePath, node.signature);
|
|
361
375
|
}
|
|
362
376
|
}
|
|
363
377
|
finally {
|
|
@@ -741,7 +755,17 @@ class DevMindDatabase {
|
|
|
741
755
|
return result;
|
|
742
756
|
}
|
|
743
757
|
updateHistory(params) {
|
|
744
|
-
const { node_id, code_snapshot
|
|
758
|
+
const { node_id, code_snapshot } = params;
|
|
759
|
+
let reasoning = params.reasoning;
|
|
760
|
+
// The calling AI has no reliable way to know who the human running this
|
|
761
|
+
// machine actually is -- it can only guess ("Claude Code", "AI Assistant",
|
|
762
|
+
// etc). Whenever this project has a configured developer identity (from
|
|
763
|
+
// .env's DEVELOPER_NAME, set by `devsmind init`), that's authoritative and
|
|
764
|
+
// always overrides whatever the agent supplied, so history is attributed
|
|
765
|
+
// to the real developer regardless of what the agent wrote in this field.
|
|
766
|
+
if (typeof reasoning === 'object' && this.context?.developer?.name) {
|
|
767
|
+
reasoning = { ...reasoning, developer: this.context.developer.name };
|
|
768
|
+
}
|
|
745
769
|
const node = this.getNode(node_id);
|
|
746
770
|
const resolvedId = node ? node.id : node_id;
|
|
747
771
|
const formattedReasoning = formatReasoning(reasoning);
|
|
@@ -753,20 +777,30 @@ class DevMindDatabase {
|
|
|
753
777
|
const lastUpdate = new Date(latest.updated_at).getTime();
|
|
754
778
|
const nowTime = new Date(nowStr).getTime();
|
|
755
779
|
const diffMs = nowTime - lastUpdate;
|
|
756
|
-
// If updated < 1 hour ago, update same record
|
|
780
|
+
// If updated < 1 hour ago, update the same record IN PLACE (no new row — this is what
|
|
781
|
+
// keeps db/graph/history from bloating with one entry per commit during an active editing
|
|
782
|
+
// session). code_snapshot is always the latest state (git already owns version history for
|
|
783
|
+
// code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
|
|
784
|
+
// session is still real and still worth keeping; losing it silently is worse than a few
|
|
785
|
+
// extra lines in one file. This also keeps any workflow step whose history_ids point at
|
|
786
|
+
// this row valid: it never loses what it originally linked to, only gains more below it.
|
|
757
787
|
if (diffMs < 3600000) {
|
|
788
|
+
const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
|
|
789
|
+
const mergedReasoning = previousReasoning.trim().length > 0
|
|
790
|
+
? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
|
|
791
|
+
: formattedReasoning;
|
|
758
792
|
const updateStmt = this.db.prepare(`
|
|
759
793
|
UPDATE history
|
|
760
794
|
SET code_snapshot = '', reasoning = ?, updated_at = ?
|
|
761
795
|
WHERE id = ?
|
|
762
796
|
`);
|
|
763
|
-
updateStmt.run(
|
|
797
|
+
updateStmt.run(mergedReasoning, nowStr, latest.id);
|
|
764
798
|
// Write/Update on disk
|
|
765
|
-
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot,
|
|
799
|
+
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
|
|
766
800
|
return {
|
|
767
801
|
...latest,
|
|
768
802
|
code_snapshot,
|
|
769
|
-
reasoning:
|
|
803
|
+
reasoning: mergedReasoning,
|
|
770
804
|
updated_at: nowStr
|
|
771
805
|
};
|
|
772
806
|
}
|
|
@@ -858,11 +892,11 @@ class DevMindDatabase {
|
|
|
858
892
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
859
893
|
FROM history h
|
|
860
894
|
JOIN nodes n ON h.node_id = n.id
|
|
861
|
-
WHERE h.reasoning LIKE ?
|
|
895
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
862
896
|
ORDER BY h.updated_at DESC
|
|
863
897
|
LIMIT ?
|
|
864
898
|
`);
|
|
865
|
-
const query = `%Developer: %${developer}%`;
|
|
899
|
+
const query = `%Developer: %${this.likeEscape(developer)}%`;
|
|
866
900
|
return stmt.all(query, limit);
|
|
867
901
|
}
|
|
868
902
|
getChangesByRequirement(requirementId) {
|
|
@@ -870,10 +904,10 @@ class DevMindDatabase {
|
|
|
870
904
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
871
905
|
FROM history h
|
|
872
906
|
JOIN nodes n ON h.node_id = n.id
|
|
873
|
-
WHERE h.reasoning LIKE ?
|
|
907
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
874
908
|
ORDER BY h.updated_at DESC
|
|
875
909
|
`);
|
|
876
|
-
const query = `%Requirement: %${requirementId}%`;
|
|
910
|
+
const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
|
|
877
911
|
return stmt.all(query);
|
|
878
912
|
}
|
|
879
913
|
searchDecisions(query) {
|
|
@@ -881,10 +915,10 @@ class DevMindDatabase {
|
|
|
881
915
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
882
916
|
FROM history h
|
|
883
917
|
JOIN nodes n ON h.node_id = n.id
|
|
884
|
-
WHERE h.reasoning LIKE ?
|
|
918
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
885
919
|
ORDER BY h.updated_at DESC
|
|
886
920
|
`);
|
|
887
|
-
const wildcard = `%Decision: %${query}%`;
|
|
921
|
+
const wildcard = `%Decision: %${this.likeEscape(query)}%`;
|
|
888
922
|
return stmt.all(wildcard);
|
|
889
923
|
}
|
|
890
924
|
searchCode(params) {
|
|
@@ -957,7 +991,8 @@ class DevMindDatabase {
|
|
|
957
991
|
getOrphanedNodes() {
|
|
958
992
|
const stmt = this.db.prepare(`
|
|
959
993
|
SELECT * FROM nodes
|
|
960
|
-
WHERE
|
|
994
|
+
WHERE deprecated = 0
|
|
995
|
+
AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
|
|
961
996
|
AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
|
|
962
997
|
`);
|
|
963
998
|
return stmt.all();
|
|
@@ -992,48 +1027,469 @@ class DevMindDatabase {
|
|
|
992
1027
|
const rows = stmt.all();
|
|
993
1028
|
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
994
1029
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1030
|
+
// ─── `devsmind analyze` read-only health checks ────────────────────────────
|
|
1031
|
+
// All pure queries/graph traversal, no mutation, no LLM calls.
|
|
1032
|
+
/** Nodes whose total (in + out) connection degree meets/exceeds `threshold` — architectural bottleneck candidates. */
|
|
1033
|
+
getGodEntities(threshold = 15) {
|
|
1034
|
+
const stmt = this.db.prepare(`
|
|
1035
|
+
SELECT * FROM (
|
|
1036
|
+
SELECT n.id, n.name, n.file_path, (
|
|
1037
|
+
(SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
|
|
1038
|
+
(SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
|
|
1039
|
+
) AS degree
|
|
1040
|
+
FROM nodes n
|
|
1041
|
+
WHERE n.deprecated = 0
|
|
1042
|
+
)
|
|
1043
|
+
WHERE degree >= ?
|
|
1044
|
+
ORDER BY degree DESC
|
|
1045
|
+
`);
|
|
1046
|
+
return stmt.all(threshold);
|
|
1047
|
+
}
|
|
1048
|
+
/** DFS cycle detection over the connection graph, capped at `maxCycles` reported paths. */
|
|
1049
|
+
getCircularDependencies(maxCycles = 50) {
|
|
1050
|
+
const edges = this.getAllConnections();
|
|
1051
|
+
const adjacency = new Map();
|
|
1052
|
+
for (const e of edges) {
|
|
1053
|
+
if (!adjacency.has(e.source_node_id))
|
|
1054
|
+
adjacency.set(e.source_node_id, []);
|
|
1055
|
+
adjacency.get(e.source_node_id).push(e.target_node_id);
|
|
1056
|
+
}
|
|
1057
|
+
const cycles = [];
|
|
1058
|
+
const visited = new Set();
|
|
1059
|
+
const stack = [];
|
|
1060
|
+
const onStack = new Set();
|
|
1061
|
+
const dfs = (node) => {
|
|
1062
|
+
if (cycles.length >= maxCycles)
|
|
1063
|
+
return;
|
|
1064
|
+
if (onStack.has(node)) {
|
|
1065
|
+
const start = stack.indexOf(node);
|
|
1066
|
+
cycles.push([...stack.slice(start), node]);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (visited.has(node))
|
|
1070
|
+
return;
|
|
1071
|
+
visited.add(node);
|
|
1072
|
+
stack.push(node);
|
|
1073
|
+
onStack.add(node);
|
|
1074
|
+
for (const next of adjacency.get(node) || []) {
|
|
1075
|
+
if (cycles.length >= maxCycles)
|
|
1076
|
+
break;
|
|
1077
|
+
dfs(next);
|
|
1078
|
+
}
|
|
1079
|
+
stack.pop();
|
|
1080
|
+
onStack.delete(node);
|
|
1081
|
+
};
|
|
1082
|
+
for (const node of adjacency.keys()) {
|
|
1083
|
+
if (cycles.length >= maxCycles)
|
|
1084
|
+
break;
|
|
1085
|
+
if (!visited.has(node))
|
|
1086
|
+
dfs(node);
|
|
1087
|
+
}
|
|
1088
|
+
return cycles;
|
|
1089
|
+
}
|
|
1090
|
+
/** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
|
|
1091
|
+
getDanglingEdges() {
|
|
1092
|
+
const stmt = this.db.prepare(`
|
|
1093
|
+
SELECT * FROM node_connections
|
|
1094
|
+
WHERE source_node_id NOT IN (SELECT id FROM nodes)
|
|
1095
|
+
OR target_node_id NOT IN (SELECT id FROM nodes)
|
|
1096
|
+
`);
|
|
1097
|
+
return stmt.all();
|
|
1098
|
+
}
|
|
1099
|
+
/** Deletes a single dangling `node_connections` row. The edge itself is invalid data — no history/graph JSON to rewrite. */
|
|
1100
|
+
deleteDanglingEdge(sourceId, targetId) {
|
|
1101
|
+
this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? AND target_node_id = ?').run(sourceId, targetId);
|
|
1102
|
+
}
|
|
1103
|
+
/** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
|
|
1104
|
+
getDuplicateNodeIds() {
|
|
1105
|
+
const stmt = this.db.prepare(`
|
|
1106
|
+
SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
|
|
1107
|
+
FROM nodes
|
|
1108
|
+
WHERE deprecated = 0
|
|
1109
|
+
GROUP BY lower_id
|
|
1110
|
+
HAVING COUNT(*) > 1
|
|
1111
|
+
`);
|
|
1112
|
+
const rows = stmt.all();
|
|
1113
|
+
return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
|
|
1114
|
+
}
|
|
1115
|
+
/** History rows whose flattened `reasoning` text has no non-empty `Developer:` line — can't be attributed to anyone. */
|
|
1116
|
+
getHistoryMissingDeveloper() {
|
|
1117
|
+
const stmt = this.db.prepare('SELECT id, node_id, updated_at, reasoning FROM history');
|
|
1118
|
+
const rows = stmt.all();
|
|
1119
|
+
return rows
|
|
1120
|
+
.filter(r => {
|
|
1121
|
+
// [ \t]* (not \s*) so the match can't swallow the newline and bleed into the
|
|
1122
|
+
// next "Model:" line when Developer is blank, which would wrongly capture
|
|
1123
|
+
// "Model: <value>" as if it were the developer's name.
|
|
1124
|
+
const match = /Developer:[ \t]*([^\n]*)/i.exec(r.reasoning || '');
|
|
1125
|
+
return !match || !match[1].trim();
|
|
1126
|
+
})
|
|
1127
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* History rows with a blank code snapshot — usually a silent AST extraction failure.
|
|
1131
|
+
* The `history.code_snapshot` DB column is always written as `''` (the real content
|
|
1132
|
+
* lives only in the per-row JSON on disk, see `populateHistoryFromDisk`), so this
|
|
1133
|
+
* must read through the populated rows rather than querying the column directly.
|
|
1134
|
+
*/
|
|
1135
|
+
getEmptyCodeSnapshots() {
|
|
1136
|
+
return this.getAllHistory()
|
|
1137
|
+
.filter(h => !h.code_snapshot || h.code_snapshot.trim() === '')
|
|
1138
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1139
|
+
}
|
|
1140
|
+
// ─── Workflow Context Vault ─────────────────────────────────────────────
|
|
1141
|
+
// Persistent, cross-session feature memory. Steps link to existing `history`
|
|
1142
|
+
// rows rather than duplicating code/reasoning; artifacts are plain files on
|
|
1143
|
+
// disk under `.devmind/workflows/<id>/`, with only the path stored in the DB.
|
|
1144
|
+
workflowsDir() {
|
|
1145
|
+
return path.join(path.dirname(this.dbPath), 'workflows');
|
|
1146
|
+
}
|
|
1147
|
+
/** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
|
|
1148
|
+
writeWorkflowToDisk(workflowId) {
|
|
1149
|
+
try {
|
|
1150
|
+
const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
|
|
1151
|
+
if (!workflow)
|
|
1152
|
+
return;
|
|
1153
|
+
const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1154
|
+
const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(workflowId);
|
|
1155
|
+
const activeId = this.getSystemMeta('active_workflow_id');
|
|
1156
|
+
const data = {
|
|
1157
|
+
id: workflow.id,
|
|
1158
|
+
name: workflow.name,
|
|
1159
|
+
description: workflow.description,
|
|
1160
|
+
status: workflow.status,
|
|
1161
|
+
created_at: workflow.created_at,
|
|
1162
|
+
updated_at: workflow.updated_at,
|
|
1163
|
+
is_active: activeId === workflowId,
|
|
1164
|
+
steps: steps.map(s => ({
|
|
1165
|
+
id: s.id,
|
|
1166
|
+
step_index: s.step_index,
|
|
1167
|
+
summary: s.summary,
|
|
1168
|
+
pending_tasks: s.pending_tasks,
|
|
1169
|
+
history_ids: s.history_ids,
|
|
1170
|
+
session_id: s.session_id,
|
|
1171
|
+
created_at: s.created_at
|
|
1172
|
+
})),
|
|
1173
|
+
artifact_index: artifacts.map(a => ({
|
|
1174
|
+
id: a.id,
|
|
1175
|
+
step_id: a.step_id,
|
|
1176
|
+
type: a.type,
|
|
1177
|
+
source_name: a.source_name,
|
|
1178
|
+
file_path: a.file_path,
|
|
1179
|
+
created_at: a.created_at
|
|
1180
|
+
}))
|
|
1181
|
+
};
|
|
1182
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1183
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1184
|
+
fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
createWorkflow(name, description) {
|
|
1191
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1192
|
+
const now = new Date().toISOString();
|
|
1193
|
+
this.db.prepare(`
|
|
1194
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1195
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
1196
|
+
`).run(id, name, description, now, now);
|
|
1197
|
+
this.setSystemMeta('active_workflow_id', id);
|
|
1198
|
+
this.writeWorkflowToDisk(id);
|
|
1199
|
+
return { id, name, description, status: 'active', created_at: now, updated_at: now };
|
|
1200
|
+
}
|
|
1201
|
+
getWorkflow(id) {
|
|
1202
|
+
const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
|
|
1203
|
+
return row || null;
|
|
1204
|
+
}
|
|
1205
|
+
getActiveWorkflow() {
|
|
1206
|
+
const id = this.getSystemMeta('active_workflow_id');
|
|
1207
|
+
return id ? this.getWorkflow(id) : null;
|
|
1208
|
+
}
|
|
1209
|
+
listWorkflows(status) {
|
|
1210
|
+
if (status) {
|
|
1211
|
+
return this.db.prepare('SELECT * FROM workflows WHERE status = ? ORDER BY updated_at DESC').all(status);
|
|
1212
|
+
}
|
|
1213
|
+
return this.db.prepare('SELECT * FROM workflows ORDER BY updated_at DESC').all();
|
|
1214
|
+
}
|
|
1215
|
+
/** Pauses the currently active workflow (if any) and clears the active pointer. */
|
|
1216
|
+
pauseWorkflow() {
|
|
1217
|
+
const active = this.getActiveWorkflow();
|
|
1218
|
+
if (!active)
|
|
1219
|
+
return null;
|
|
1220
|
+
const now = new Date().toISOString();
|
|
1221
|
+
this.db.prepare(`UPDATE workflows SET status = 'paused', updated_at = ? WHERE id = ?`).run(now, active.id);
|
|
1222
|
+
this.setSystemMeta('active_workflow_id', '');
|
|
1223
|
+
this.writeWorkflowToDisk(active.id);
|
|
1224
|
+
return { ...active, status: 'paused', updated_at: now };
|
|
1225
|
+
}
|
|
1226
|
+
/** Resumes `id`, auto-pausing whatever was previously active (only one workflow is active at a time). */
|
|
1227
|
+
resumeWorkflow(id) {
|
|
1228
|
+
const workflow = this.getWorkflow(id);
|
|
1229
|
+
if (!workflow)
|
|
1230
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1231
|
+
const currentActive = this.getActiveWorkflow();
|
|
1232
|
+
if (currentActive && currentActive.id !== id)
|
|
1233
|
+
this.pauseWorkflow();
|
|
1234
|
+
const now = new Date().toISOString();
|
|
1235
|
+
this.db.prepare(`UPDATE workflows SET status = 'active', updated_at = ? WHERE id = ?`).run(now, id);
|
|
1236
|
+
this.setSystemMeta('active_workflow_id', id);
|
|
1237
|
+
this.writeWorkflowToDisk(id);
|
|
1238
|
+
return { ...workflow, status: 'active', updated_at: now };
|
|
1239
|
+
}
|
|
1240
|
+
completeWorkflow(id) {
|
|
1241
|
+
const workflow = this.getWorkflow(id);
|
|
1242
|
+
if (!workflow)
|
|
1243
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1244
|
+
const now = new Date().toISOString();
|
|
1245
|
+
this.db.prepare(`UPDATE workflows SET status = 'completed', updated_at = ? WHERE id = ?`).run(now, id);
|
|
1246
|
+
if (this.getSystemMeta('active_workflow_id') === id)
|
|
1247
|
+
this.setSystemMeta('active_workflow_id', '');
|
|
1248
|
+
this.writeWorkflowToDisk(id);
|
|
1249
|
+
return { ...workflow, status: 'completed', updated_at: now };
|
|
1250
|
+
}
|
|
1251
|
+
addWorkflowStep(workflowId, opts) {
|
|
1252
|
+
if (!this.getWorkflow(workflowId))
|
|
1253
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1254
|
+
const id = crypto.randomUUID();
|
|
1255
|
+
const now = new Date().toISOString();
|
|
1256
|
+
const nextIndex = (this.db.prepare('SELECT MAX(step_index) AS m FROM workflow_steps WHERE workflow_id = ?').get(workflowId).m ?? 0) + 1;
|
|
1257
|
+
const historyIdsJson = opts.historyIds && opts.historyIds.length ? JSON.stringify(opts.historyIds) : null;
|
|
1258
|
+
this.db.prepare(`
|
|
1259
|
+
INSERT INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
|
|
1260
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1261
|
+
`).run(id, workflowId, nextIndex, opts.summary, opts.pendingTasks || null, historyIdsJson, opts.sessionId || null, now);
|
|
1262
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1263
|
+
this.writeWorkflowToDisk(workflowId);
|
|
1264
|
+
return { id, workflow_id: workflowId, step_index: nextIndex, summary: opts.summary, pending_tasks: opts.pendingTasks || null, history_ids: historyIdsJson, session_id: opts.sessionId || null, created_at: now };
|
|
1265
|
+
}
|
|
1266
|
+
/** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
|
|
1267
|
+
addWorkflowArtifact(workflowId, opts) {
|
|
1268
|
+
if (!this.getWorkflow(workflowId))
|
|
1269
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1270
|
+
const id = crypto.randomUUID();
|
|
1271
|
+
const now = new Date().toISOString();
|
|
1272
|
+
const safeName = opts.sourceName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact.md';
|
|
1273
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1274
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1275
|
+
const filePath = path.join(dir, `${id}_${safeName}`);
|
|
1276
|
+
fs.writeFileSync(filePath, opts.content, 'utf-8');
|
|
1277
|
+
this.db.prepare(`
|
|
1278
|
+
INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
|
|
1279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1280
|
+
`).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
|
|
1281
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1282
|
+
this.writeWorkflowToDisk(workflowId);
|
|
1283
|
+
return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
|
|
1284
|
+
}
|
|
1285
|
+
getWorkflowContext(id, opts) {
|
|
1286
|
+
const workflow = this.getWorkflow(id);
|
|
1287
|
+
if (!workflow)
|
|
1288
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1289
|
+
const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(id);
|
|
1290
|
+
const artifactRows = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
|
|
1291
|
+
const artifacts = artifactRows.map(a => {
|
|
1292
|
+
if (!opts?.includeArtifactContent)
|
|
1293
|
+
return a;
|
|
1294
|
+
try {
|
|
1295
|
+
const content = fs.existsSync(a.file_path) ? fs.readFileSync(a.file_path, 'utf-8') : undefined;
|
|
1296
|
+
return { ...a, content };
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
return a;
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
return { workflow, steps, artifacts };
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Returns steps for a workflow with optional pagination.
|
|
1306
|
+
* Use `last_n` to get only the most recent N steps (tail), or `limit`/`offset` for
|
|
1307
|
+
* arbitrary pagination. Without any option, all steps are returned.
|
|
1308
|
+
*/
|
|
1309
|
+
getWorkflowSteps(workflowId, opts) {
|
|
1310
|
+
if (!this.getWorkflow(workflowId))
|
|
1311
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1312
|
+
if (opts?.last_n && opts.last_n > 0) {
|
|
1313
|
+
// Fetch the last N steps by descending step_index, then reverse to chronological
|
|
1314
|
+
const rows = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index DESC LIMIT ?').all(workflowId, opts.last_n);
|
|
1315
|
+
return rows.reverse();
|
|
1316
|
+
}
|
|
1317
|
+
if (opts?.limit) {
|
|
1318
|
+
return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC LIMIT ? OFFSET ?').all(workflowId, opts.limit, opts.offset ?? 0);
|
|
1319
|
+
}
|
|
1320
|
+
return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Reads a single workflow artifact's file content from disk.
|
|
1324
|
+
* Accepts either an artifact_id or a source_name (first match used).
|
|
1325
|
+
*/
|
|
1326
|
+
readWorkflowArtifact(workflowId, artifactId) {
|
|
1327
|
+
if (!this.getWorkflow(workflowId))
|
|
1328
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1329
|
+
const row = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND id = ?').get(workflowId, artifactId);
|
|
1330
|
+
if (!row)
|
|
1331
|
+
throw new Error(`Artifact not found: ${artifactId} in workflow ${workflowId}`);
|
|
1332
|
+
if (!fs.existsSync(row.file_path))
|
|
1333
|
+
throw new Error(`Artifact file missing on disk: ${row.file_path}`);
|
|
1334
|
+
const content = fs.readFileSync(row.file_path, 'utf-8');
|
|
1335
|
+
return { artifact: row, content };
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Full-text keyword search across all workflows' step summaries, pending_tasks,
|
|
1339
|
+
* and artifact source names. Optionally also searches artifact file content.
|
|
1340
|
+
* Returns a list of matches grouped by workflow.
|
|
1341
|
+
*/
|
|
1342
|
+
searchWorkflows(query, opts) {
|
|
1343
|
+
const lq = `%${query.toLowerCase()}%`;
|
|
1344
|
+
// Find matching steps
|
|
1345
|
+
const matchedStepRows = this.db.prepare(`
|
|
1346
|
+
SELECT ws.* FROM workflow_steps ws
|
|
1347
|
+
JOIN workflows w ON w.id = ws.workflow_id
|
|
1348
|
+
WHERE (LOWER(ws.summary) LIKE ? OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ?)
|
|
1349
|
+
${opts?.status ? 'AND w.status = ?' : ''}
|
|
1350
|
+
ORDER BY ws.workflow_id, ws.step_index ASC
|
|
1351
|
+
`).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
|
|
1352
|
+
// Find matching artifacts by source_name
|
|
1353
|
+
const matchedArtifactRows = this.db.prepare(`
|
|
1354
|
+
SELECT wa.* FROM workflow_artifacts wa
|
|
1355
|
+
JOIN workflows w ON w.id = wa.workflow_id
|
|
1356
|
+
WHERE LOWER(wa.source_name) LIKE ?
|
|
1357
|
+
${opts?.status ? 'AND w.status = ?' : ''}
|
|
1358
|
+
ORDER BY wa.workflow_id, wa.created_at ASC
|
|
1359
|
+
`).all(...(opts?.status ? [lq, opts.status] : [lq]));
|
|
1360
|
+
// If content search requested, also scan artifact files
|
|
1361
|
+
const contentMatchedArtifactIds = new Set();
|
|
1362
|
+
const artifactContentSnippets = new Map();
|
|
1363
|
+
if (opts?.include_artifact_content) {
|
|
1364
|
+
const allArtifacts = this.db.prepare(`SELECT wa.* FROM workflow_artifacts wa JOIN workflows w ON w.id = wa.workflow_id${opts.status ? ' WHERE w.status = ?' : ''}`).all(...(opts.status ? [opts.status] : []));
|
|
1365
|
+
const lqPlain = query.toLowerCase();
|
|
1366
|
+
for (const a of allArtifacts) {
|
|
1367
|
+
if (contentMatchedArtifactIds.has(a.id))
|
|
1368
|
+
continue;
|
|
1369
|
+
try {
|
|
1370
|
+
if (fs.existsSync(a.file_path)) {
|
|
1371
|
+
const text = fs.readFileSync(a.file_path, 'utf-8');
|
|
1372
|
+
const idx = text.toLowerCase().indexOf(lqPlain);
|
|
1373
|
+
if (idx !== -1) {
|
|
1374
|
+
contentMatchedArtifactIds.add(a.id);
|
|
1375
|
+
const start = Math.max(0, idx - 80);
|
|
1376
|
+
const end = Math.min(text.length, idx + query.length + 80);
|
|
1377
|
+
artifactContentSnippets.set(a.id, (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''));
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch { /* skip unreadable */ }
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
// Collect all relevant workflow IDs
|
|
1385
|
+
const workflowIdSet = new Set([
|
|
1386
|
+
...matchedStepRows.map(s => s.workflow_id),
|
|
1387
|
+
...matchedArtifactRows.map(a => a.workflow_id),
|
|
1388
|
+
...Array.from(contentMatchedArtifactIds).map(id => {
|
|
1389
|
+
const r = this.db.prepare('SELECT workflow_id FROM workflow_artifacts WHERE id = ?').get(id);
|
|
1390
|
+
return r?.workflow_id || '';
|
|
1391
|
+
}).filter(Boolean)
|
|
1000
1392
|
]);
|
|
1001
|
-
|
|
1393
|
+
const results = [];
|
|
1394
|
+
for (const wid of workflowIdSet) {
|
|
1395
|
+
const workflow = this.getWorkflow(wid);
|
|
1396
|
+
if (!workflow)
|
|
1397
|
+
continue;
|
|
1398
|
+
const steps = matchedStepRows.filter(s => s.workflow_id === wid);
|
|
1399
|
+
const artByName = matchedArtifactRows.filter(a => a.workflow_id === wid);
|
|
1400
|
+
const artByContent = opts?.include_artifact_content
|
|
1401
|
+
? this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ?').all(wid).filter(a => contentMatchedArtifactIds.has(a.id) && !artByName.find(x => x.id === a.id))
|
|
1402
|
+
: [];
|
|
1403
|
+
const allArtifacts = [
|
|
1404
|
+
...artByName.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) })),
|
|
1405
|
+
...artByContent.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) }))
|
|
1406
|
+
];
|
|
1407
|
+
results.push({ workflow, matched_steps: steps, matched_artifacts: allArtifacts });
|
|
1408
|
+
}
|
|
1409
|
+
// Sort by most recently updated workflow first
|
|
1410
|
+
results.sort((a, b) => b.workflow.updated_at.localeCompare(a.workflow.updated_at));
|
|
1411
|
+
return results;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Imports an existing flow/architecture doc as a paused workflow (not active — importing
|
|
1415
|
+
* a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
|
|
1416
|
+
* same doc overwrites its existing `imported_doc` artifact file in place instead of
|
|
1417
|
+
* creating a duplicate workflow every time the source docs are re-imported.
|
|
1418
|
+
*/
|
|
1419
|
+
importWorkflowDoc(name, description, content, sourceFileName) {
|
|
1420
|
+
const existing = this.db.prepare('SELECT * FROM workflows WHERE name = ?').get(name);
|
|
1421
|
+
const now = new Date().toISOString();
|
|
1422
|
+
if (existing) {
|
|
1423
|
+
this.db.prepare(`UPDATE workflows SET description = ?, updated_at = ? WHERE id = ?`).run(description, now, existing.id);
|
|
1424
|
+
const existingArtifact = this.db.prepare(`SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND type = 'imported_doc' ORDER BY created_at ASC LIMIT 1`).get(existing.id);
|
|
1425
|
+
if (existingArtifact) {
|
|
1426
|
+
fs.writeFileSync(existingArtifact.file_path, content, 'utf-8');
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
this.addWorkflowArtifact(existing.id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1430
|
+
}
|
|
1431
|
+
this.writeWorkflowToDisk(existing.id);
|
|
1432
|
+
return { workflow: { ...existing, description, updated_at: now }, created: false };
|
|
1433
|
+
}
|
|
1434
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1435
|
+
this.db.prepare(`
|
|
1436
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1437
|
+
VALUES (?, ?, ?, 'paused', ?, ?)
|
|
1438
|
+
`).run(id, name, description, now, now);
|
|
1439
|
+
this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
|
|
1440
|
+
this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1441
|
+
// writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
|
|
1442
|
+
return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
|
|
1443
|
+
}
|
|
1444
|
+
static SPURIOUS_NODE_NAMES = new Set([
|
|
1445
|
+
'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
|
|
1446
|
+
'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
|
|
1447
|
+
'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
|
|
1448
|
+
]);
|
|
1449
|
+
/**
|
|
1450
|
+
* Read-only detection shared by `pruneSpuriousNodes` (which acts on it) and `devsmind
|
|
1451
|
+
* analyze`'s dry-run report (which just lists it). Never mutates the DB.
|
|
1452
|
+
*/
|
|
1453
|
+
findSpuriousAndMissingFileNodes(workspaceRoot) {
|
|
1002
1454
|
const stmt = this.db.prepare(`
|
|
1003
1455
|
SELECT id, name, file_path FROM nodes
|
|
1004
1456
|
WHERE deprecated = 0
|
|
1005
1457
|
`);
|
|
1006
1458
|
const candidates = stmt.all();
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
const affectedFilePaths = new Set();
|
|
1459
|
+
const spurious = [];
|
|
1460
|
+
const missingFile = [];
|
|
1010
1461
|
for (const node of candidates) {
|
|
1011
1462
|
const lowerName = node.name.toLowerCase();
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1463
|
+
if (DevMindDatabase.SPURIOUS_NODE_NAMES.has(lowerName)) {
|
|
1464
|
+
spurious.push(node);
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1016
1467
|
if (node.file_path) {
|
|
1017
1468
|
const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
1018
1469
|
if (paths.length > 0) {
|
|
1019
1470
|
const allMissing = paths.every(p => {
|
|
1020
|
-
const resolvedPath = path.isAbsolute(p)
|
|
1021
|
-
? p
|
|
1022
|
-
: path.resolve(workspaceRoot, p);
|
|
1471
|
+
const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
|
|
1023
1472
|
return !fs.existsSync(resolvedPath);
|
|
1024
1473
|
});
|
|
1025
|
-
if (allMissing)
|
|
1026
|
-
|
|
1027
|
-
}
|
|
1474
|
+
if (allMissing)
|
|
1475
|
+
missingFile.push(node);
|
|
1028
1476
|
}
|
|
1029
1477
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1478
|
+
}
|
|
1479
|
+
return { spurious, missingFile };
|
|
1480
|
+
}
|
|
1481
|
+
pruneSpuriousNodes(workspaceRoot) {
|
|
1482
|
+
const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
|
|
1483
|
+
const candidates = [...spurious, ...missingFile];
|
|
1484
|
+
const idsToDelete = [];
|
|
1485
|
+
const namesDeleted = [];
|
|
1486
|
+
const affectedFilePaths = new Set();
|
|
1487
|
+
for (const node of candidates) {
|
|
1488
|
+
idsToDelete.push(node.id);
|
|
1489
|
+
namesDeleted.push(`${node.name} (${node.id})`);
|
|
1490
|
+
if (node.file_path) {
|
|
1491
|
+
for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1492
|
+
affectedFilePaths.add(p);
|
|
1037
1493
|
}
|
|
1038
1494
|
}
|
|
1039
1495
|
}
|
|
@@ -1126,41 +1582,120 @@ class DevMindDatabase {
|
|
|
1126
1582
|
toRepoRelativePath(absolutePath) {
|
|
1127
1583
|
if (!absolutePath || !this.context)
|
|
1128
1584
|
return absolutePath;
|
|
1129
|
-
const abs =
|
|
1585
|
+
const abs = (0, config_1.canonicalizePath)(absolutePath).replace(/\\/g, '/');
|
|
1586
|
+
const absLower = abs.toLowerCase();
|
|
1130
1587
|
for (const repo of this.context.config.repos) {
|
|
1131
1588
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1132
1589
|
if (repoPath) {
|
|
1133
|
-
const normalizedRepoPath =
|
|
1134
|
-
|
|
1590
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath).replace(/\\/g, '/');
|
|
1591
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1592
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + '/')) {
|
|
1135
1593
|
const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
|
|
1136
1594
|
return `{${repo.name}}/${relative}`;
|
|
1137
1595
|
}
|
|
1138
1596
|
}
|
|
1139
1597
|
}
|
|
1140
1598
|
// Fallback: resolve relative to workspace root
|
|
1141
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1599
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1142
1600
|
return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
|
|
1143
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Rejects a resolved path that escapes its expected root (e.g. via a stored
|
|
1604
|
+
* `{repo}/../../..` path traveling outside the repo) by clamping it back to
|
|
1605
|
+
* the root itself. node_id/file_path values flow in from AI-supplied tool
|
|
1606
|
+
* calls, so a resolve must never be trusted to stay inside root on its own.
|
|
1607
|
+
*/
|
|
1608
|
+
clampToRoot(root, resolved) {
|
|
1609
|
+
const normalizedRoot = (0, config_1.canonicalizePath)(root);
|
|
1610
|
+
const normalizedResolved = (0, config_1.canonicalizePath)(resolved);
|
|
1611
|
+
const rootLower = normalizedRoot.toLowerCase();
|
|
1612
|
+
const resolvedLower = normalizedResolved.toLowerCase();
|
|
1613
|
+
if (resolvedLower === rootLower || resolvedLower.startsWith(rootLower + path.sep)) {
|
|
1614
|
+
return normalizedResolved;
|
|
1615
|
+
}
|
|
1616
|
+
console.warn(`⚠️ Path traversal blocked: "${resolved}" escapes root "${root}"`);
|
|
1617
|
+
return normalizedRoot;
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* True if `absPath` sits inside a configured repo root or the workspace root itself.
|
|
1621
|
+
* Used to reject `stage_change`/`update_history` file paths that would otherwise let a
|
|
1622
|
+
* tool call read/write any file on disk (absolute path, or a `../` escape) instead of
|
|
1623
|
+
* just repo source — nothing upstream of this validates that the AI-supplied path is
|
|
1624
|
+
* actually inside the project.
|
|
1625
|
+
*/
|
|
1626
|
+
isPathAllowed(absPath) {
|
|
1627
|
+
const abs = (0, config_1.canonicalizePath)(absPath);
|
|
1628
|
+
const absLower = abs.toLowerCase();
|
|
1629
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1630
|
+
const workspaceRootLower = workspaceRoot.toLowerCase();
|
|
1631
|
+
if (absLower === workspaceRootLower || absLower.startsWith(workspaceRootLower + path.sep))
|
|
1632
|
+
return true;
|
|
1633
|
+
if (this.context) {
|
|
1634
|
+
for (const repo of this.context.config.repos) {
|
|
1635
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1636
|
+
if (repoPath) {
|
|
1637
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath);
|
|
1638
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1639
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + path.sep))
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return false;
|
|
1645
|
+
}
|
|
1144
1646
|
toAbsolutePath(repoRelativePath) {
|
|
1145
1647
|
if (!repoRelativePath)
|
|
1146
1648
|
return repoRelativePath;
|
|
1147
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1649
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1148
1650
|
const match = repoRelativePath.match(/^\{([^}]+)\}\/(.*)$/);
|
|
1149
1651
|
if (match && this.context) {
|
|
1150
1652
|
const repoName = match[1];
|
|
1151
1653
|
const relativePath = match[2];
|
|
1152
1654
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repoName);
|
|
1153
1655
|
if (repoPath) {
|
|
1154
|
-
return path.resolve(repoPath, relativePath);
|
|
1656
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(repoPath, path.resolve(repoPath, relativePath)));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
// Heuristic: if it doesn't start with {repoName} but contains a configured repo name in the path
|
|
1660
|
+
if (this.context) {
|
|
1661
|
+
for (const repo of this.context.config.repos) {
|
|
1662
|
+
// Find if repo.name appears as a folder in the path, e.g. "harrir-express-backend/tests/..."
|
|
1663
|
+
const escapedRepoName = repo.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1664
|
+
const regex = new RegExp('(?:^|/|\\\\)' + escapedRepoName + '(?:/|\\\\)(.*)$', 'i');
|
|
1665
|
+
const m = repoRelativePath.match(regex);
|
|
1666
|
+
if (m) {
|
|
1667
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1668
|
+
if (repoPath) {
|
|
1669
|
+
const relativePath = m[1];
|
|
1670
|
+
return (0, config_1.canonicalizePath)(path.resolve(repoPath, relativePath));
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1155
1673
|
}
|
|
1156
1674
|
}
|
|
1157
1675
|
// Fallback: resolve relative to workspace root
|
|
1158
|
-
return path.resolve(workspaceRoot, repoRelativePath);
|
|
1676
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
|
|
1159
1677
|
}
|
|
1160
1678
|
syncFromDisk() {
|
|
1161
1679
|
this.db.pragma('foreign_keys = OFF');
|
|
1162
1680
|
try {
|
|
1163
1681
|
const workspaceRoot = path.dirname(this.dbPath);
|
|
1682
|
+
// 0. Auto-heal any legacy relative path records in SQLite
|
|
1683
|
+
try {
|
|
1684
|
+
const legacyNodes = this.db.prepare("SELECT id, file_path FROM nodes WHERE file_path NOT LIKE 'c:%' AND file_path NOT LIKE 'C:%' AND file_path NOT LIKE '/%'").all();
|
|
1685
|
+
if (legacyNodes.length > 0) {
|
|
1686
|
+
const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
|
|
1687
|
+
const healTx = this.db.transaction(() => {
|
|
1688
|
+
for (const n of legacyNodes) {
|
|
1689
|
+
const abs = this.toAbsolutePath(n.file_path);
|
|
1690
|
+
updateStmt.run(abs, n.id);
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
healTx();
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
// ignore legacy errors
|
|
1698
|
+
}
|
|
1164
1699
|
// 1. Sync History JSONs
|
|
1165
1700
|
const historyDir = path.join(workspaceRoot, 'history');
|
|
1166
1701
|
if (fs.existsSync(historyDir)) {
|
|
@@ -1265,6 +1800,65 @@ class DevMindDatabase {
|
|
|
1265
1800
|
syncGraphTx();
|
|
1266
1801
|
}
|
|
1267
1802
|
}
|
|
1803
|
+
// 3. Sync Workflow JSONs
|
|
1804
|
+
const workflowsDir = this.workflowsDir();
|
|
1805
|
+
if (fs.existsSync(workflowsDir)) {
|
|
1806
|
+
const upsertWorkflow = this.db.prepare(`
|
|
1807
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1808
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1809
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1810
|
+
name = excluded.name,
|
|
1811
|
+
description = excluded.description,
|
|
1812
|
+
status = excluded.status,
|
|
1813
|
+
updated_at = excluded.updated_at
|
|
1814
|
+
`);
|
|
1815
|
+
const upsertStep = this.db.prepare(`
|
|
1816
|
+
INSERT OR IGNORE INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
|
|
1817
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1818
|
+
`);
|
|
1819
|
+
const upsertArtifact = this.db.prepare(`
|
|
1820
|
+
INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
|
|
1821
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1822
|
+
`);
|
|
1823
|
+
// Track which workflow.json has is_active:true with the latest updated_at
|
|
1824
|
+
let bestActiveId = null;
|
|
1825
|
+
let bestActiveUpdatedAt = '';
|
|
1826
|
+
const syncWorkflowsTx = this.db.transaction(() => {
|
|
1827
|
+
const subdirs = fs.readdirSync(workflowsDir);
|
|
1828
|
+
for (const subdir of subdirs) {
|
|
1829
|
+
const jsonPath = path.join(workflowsDir, subdir, 'workflow.json');
|
|
1830
|
+
if (!fs.existsSync(jsonPath))
|
|
1831
|
+
continue;
|
|
1832
|
+
try {
|
|
1833
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
1834
|
+
if (!data.id || !data.name)
|
|
1835
|
+
continue;
|
|
1836
|
+
upsertWorkflow.run(data.id, data.name, data.description || '', data.status || 'paused', data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
|
|
1837
|
+
for (const s of (data.steps || [])) {
|
|
1838
|
+
if (!s.id)
|
|
1839
|
+
continue;
|
|
1840
|
+
upsertStep.run(s.id, data.id, s.step_index, s.summary || '', s.pending_tasks || null, s.history_ids || null, s.session_id || null, s.created_at || new Date().toISOString());
|
|
1841
|
+
}
|
|
1842
|
+
for (const a of (data.artifact_index || [])) {
|
|
1843
|
+
if (!a.id)
|
|
1844
|
+
continue;
|
|
1845
|
+
upsertArtifact.run(a.id, data.id, a.step_id || null, a.type || 'unknown', a.source_name || '', a.file_path || '', a.created_at || new Date().toISOString());
|
|
1846
|
+
}
|
|
1847
|
+
// Track which workflow declared itself active most recently
|
|
1848
|
+
if (data.is_active && data.updated_at > bestActiveUpdatedAt) {
|
|
1849
|
+
bestActiveId = data.id;
|
|
1850
|
+
bestActiveUpdatedAt = data.updated_at;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
catch { /* skip malformed */ }
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
syncWorkflowsTx();
|
|
1857
|
+
// Restore active_workflow_id if not already set and a JSON claims active status
|
|
1858
|
+
if (bestActiveId && !this.getSystemMeta('active_workflow_id')) {
|
|
1859
|
+
this.setSystemMeta('active_workflow_id', bestActiveId);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1268
1862
|
}
|
|
1269
1863
|
catch (err) {
|
|
1270
1864
|
console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
|
|
@@ -1281,9 +1875,9 @@ class DevMindDatabase {
|
|
|
1281
1875
|
try {
|
|
1282
1876
|
if (!filePath)
|
|
1283
1877
|
return;
|
|
1284
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1878
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1285
1879
|
// Clean/resolve the file path
|
|
1286
|
-
const absPath =
|
|
1880
|
+
const absPath = (0, config_1.canonicalizePath)(filePath);
|
|
1287
1881
|
const repoRelPath = this.toRepoRelativePath(absPath);
|
|
1288
1882
|
// E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
|
|
1289
1883
|
const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
|
|
@@ -1297,16 +1891,18 @@ class DevMindDatabase {
|
|
|
1297
1891
|
// is durable across a syncFromDisk() restart and propagates to teammates via git —
|
|
1298
1892
|
// otherwise the node's history JSON would resurrect it as active on the next start.
|
|
1299
1893
|
const absEsc = this.likeEscape(absPath);
|
|
1894
|
+
const absLower = absPath.toLowerCase();
|
|
1895
|
+
const absEscLower = absEsc.toLowerCase();
|
|
1300
1896
|
const stmtNodes = this.db.prepare(`
|
|
1301
1897
|
SELECT * FROM nodes
|
|
1302
1898
|
WHERE (
|
|
1303
|
-
file_path = ? OR
|
|
1304
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1305
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1306
|
-
file_path LIKE ? ESCAPE '\\'
|
|
1899
|
+
LOWER(file_path) = ? OR
|
|
1900
|
+
LOWER(file_path) LIKE ? ESCAPE '\\' OR
|
|
1901
|
+
LOWER(file_path) LIKE ? ESCAPE '\\' OR
|
|
1902
|
+
LOWER(file_path) LIKE ? ESCAPE '\\'
|
|
1307
1903
|
)
|
|
1308
1904
|
`);
|
|
1309
|
-
const nodes = stmtNodes.all(
|
|
1905
|
+
const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
|
|
1310
1906
|
if (nodes.length === 0) {
|
|
1311
1907
|
// If no nodes left, delete the JSON file if it exists
|
|
1312
1908
|
if (fs.existsSync(graphJsonPath)) {
|
|
@@ -1349,6 +1945,30 @@ class DevMindDatabase {
|
|
|
1349
1945
|
console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
|
|
1350
1946
|
}
|
|
1351
1947
|
}
|
|
1948
|
+
/** Force-syncs all database nodes and workflows to disk JSON files. */
|
|
1949
|
+
syncToDisk() {
|
|
1950
|
+
try {
|
|
1951
|
+
const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
|
|
1952
|
+
const filePaths = new Set();
|
|
1953
|
+
for (const row of rows) {
|
|
1954
|
+
if (row.file_path) {
|
|
1955
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1956
|
+
filePaths.add(p);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
for (const filePath of filePaths) {
|
|
1961
|
+
this.writeGraphToDisk(filePath);
|
|
1962
|
+
}
|
|
1963
|
+
const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
|
|
1964
|
+
for (const row of workflowRows) {
|
|
1965
|
+
this.writeWorkflowToDisk(row.id);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
catch (err) {
|
|
1969
|
+
console.warn('⚠️ DevsMind: Failed to sync database to disk:', err);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1352
1972
|
}
|
|
1353
1973
|
exports.DevMindDatabase = DevMindDatabase;
|
|
1354
1974
|
//# sourceMappingURL=database.js.map
|