devsmind-mcp 2.2.2 → 2.4.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/init.js +9 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/integrations/memory.js +9 -3
- 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/prune.js +4 -3
- package/dist/cli/prune.js.map +1 -1
- package/dist/cli/rule.js +43 -83
- 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 +167 -0
- package/dist/db/analyze.js.map +1 -0
- package/dist/db/database.d.ts +195 -2
- package/dist/db/database.js +870 -74
- 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 +641 -48
- package/dist/mcp/server.js.map +1 -1
- package/dist/utils/ast.d.ts +98 -0
- package/dist/utils/ast.js +262 -10
- package/dist/utils/ast.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/edit.d.ts +41 -0
- package/dist/utils/edit.js +163 -0
- package/dist/utils/edit.js.map +1 -0
- 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
|
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.DevMindDatabase = void 0;
|
|
40
40
|
exports.formatReasoning = formatReasoning;
|
|
41
|
+
exports.parseReasoningBlocks = parseReasoningBlocks;
|
|
41
42
|
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
42
43
|
const crypto = __importStar(require("crypto"));
|
|
43
44
|
const fs = __importStar(require("fs"));
|
|
@@ -76,6 +77,47 @@ function formatReasoning(r) {
|
|
|
76
77
|
];
|
|
77
78
|
return lines.join('\n');
|
|
78
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Inverse of `formatReasoning`. A single history row accumulates every later update appended
|
|
82
|
+
* under a `── Update @ … ──` separator, so one stored blob can hold several changes — this
|
|
83
|
+
* splits them back apart and returns them NEWEST FIRST.
|
|
84
|
+
*
|
|
85
|
+
* Reasoning written before the structured format (or by a caller passing a bare string) has no
|
|
86
|
+
* labels to read; rather than drop it, the whole chunk is surfaced as `what_changed`.
|
|
87
|
+
*/
|
|
88
|
+
function parseReasoningBlocks(raw) {
|
|
89
|
+
if (!raw || typeof raw !== 'string')
|
|
90
|
+
return [];
|
|
91
|
+
const chunks = raw
|
|
92
|
+
.split(/\n*── Update @ [^\n]*──\n/g)
|
|
93
|
+
.map(c => c.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
const parsed = chunks.map(chunk => {
|
|
96
|
+
const field = (label) => {
|
|
97
|
+
const m = chunk.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
|
|
98
|
+
const v = m?.[1]?.trim();
|
|
99
|
+
return v ? v : undefined;
|
|
100
|
+
};
|
|
101
|
+
const what = field('What changed');
|
|
102
|
+
const why = field('Why');
|
|
103
|
+
const goal = field('Goal');
|
|
104
|
+
// No recognised labels → free-text reasoning; keep it rather than return an empty shell.
|
|
105
|
+
if (!what && !why && !goal) {
|
|
106
|
+
return { what_changed: chunk, why: '', goal: '' };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
what_changed: what || '',
|
|
110
|
+
why: why || '',
|
|
111
|
+
goal: goal || '',
|
|
112
|
+
requirement: field('Requirement'),
|
|
113
|
+
previous_state: field('Previous state'),
|
|
114
|
+
decision: field('Decision'),
|
|
115
|
+
developer: field('Developer'),
|
|
116
|
+
model: field('Model')
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
return parsed.reverse();
|
|
120
|
+
}
|
|
79
121
|
class DevMindDatabase {
|
|
80
122
|
db;
|
|
81
123
|
dbPath;
|
|
@@ -114,6 +156,9 @@ class DevMindDatabase {
|
|
|
114
156
|
)
|
|
115
157
|
`);
|
|
116
158
|
}
|
|
159
|
+
getContext() {
|
|
160
|
+
return this.context;
|
|
161
|
+
}
|
|
117
162
|
getSystemMeta(key) {
|
|
118
163
|
try {
|
|
119
164
|
const stmt = this.db.prepare('SELECT value FROM system_meta WHERE key = ?');
|
|
@@ -132,9 +177,16 @@ class DevMindDatabase {
|
|
|
132
177
|
`);
|
|
133
178
|
stmt.run(key, value, value);
|
|
134
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Nodes declared in one file. Both sides are folded to a canonical form before comparing:
|
|
182
|
+
* a stored `c:\x\y.ts` and a caller's `C:/x/y.ts` are the same file on Windows, and a raw
|
|
183
|
+
* `=` match silently returns nothing — which reads as "this file has no nodes" rather than
|
|
184
|
+
* as an error. There is no index on file_path, so this was already a full scan; normalizing
|
|
185
|
+
* in SQL costs nothing extra.
|
|
186
|
+
*/
|
|
135
187
|
getNodesByFilePath(filePath) {
|
|
136
|
-
const stmt = this.db.prepare(
|
|
137
|
-
return stmt.all(filePath);
|
|
188
|
+
const stmt = this.db.prepare(`SELECT * FROM nodes WHERE deprecated = 0 AND REPLACE(LOWER(file_path), '\\', '/') = ?`);
|
|
189
|
+
return stmt.all((0, ast_1.normalizeFsPath)(filePath));
|
|
138
190
|
}
|
|
139
191
|
close() {
|
|
140
192
|
this.db.close();
|
|
@@ -183,6 +235,9 @@ class DevMindDatabase {
|
|
|
183
235
|
}
|
|
184
236
|
fs.mkdirSync(p, { recursive: true });
|
|
185
237
|
}
|
|
238
|
+
// NOTE: 'workflows/' is intentionally NOT wiped — workflow data is long-lived
|
|
239
|
+
// cross-session state that survives a node/history reindex. It will be
|
|
240
|
+
// restored from workflows/*/workflow.json on the next syncFromDisk().
|
|
186
241
|
this.vacuum();
|
|
187
242
|
}
|
|
188
243
|
/**
|
|
@@ -232,11 +287,12 @@ class DevMindDatabase {
|
|
|
232
287
|
}
|
|
233
288
|
// --- Node Operations ---
|
|
234
289
|
upsertNode(node) {
|
|
290
|
+
const canonicalFp = (0, config_1.canonicalizePath)(node.file_path);
|
|
235
291
|
const existing = this.getNode(node.id);
|
|
236
292
|
if (existing) {
|
|
237
293
|
let finalPath = existing.file_path;
|
|
238
294
|
const paths = existing.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
239
|
-
const incoming =
|
|
295
|
+
const incoming = canonicalFp.trim();
|
|
240
296
|
if (!paths.includes(incoming)) {
|
|
241
297
|
paths.push(incoming);
|
|
242
298
|
finalPath = paths.join(', ');
|
|
@@ -257,9 +313,9 @@ class DevMindDatabase {
|
|
|
257
313
|
INSERT INTO nodes (id, type, name, file_path, signature)
|
|
258
314
|
VALUES (?, ?, ?, ?, ?)
|
|
259
315
|
`);
|
|
260
|
-
stmt.run(node.id, node.type, node.name,
|
|
316
|
+
stmt.run(node.id, node.type, node.name, canonicalFp, node.signature || null);
|
|
261
317
|
}
|
|
262
|
-
this.writeGraphToDisk(
|
|
318
|
+
this.writeGraphToDisk(canonicalFp);
|
|
263
319
|
}
|
|
264
320
|
getNode(id) {
|
|
265
321
|
const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
|
|
@@ -267,8 +323,8 @@ class DevMindDatabase {
|
|
|
267
323
|
if (direct)
|
|
268
324
|
return direct;
|
|
269
325
|
if (!id.includes('#')) {
|
|
270
|
-
const suffixStmt = this.db.prepare(
|
|
271
|
-
const matches = suffixStmt.all(`%#${id}`);
|
|
326
|
+
const suffixStmt = this.db.prepare("SELECT * FROM nodes WHERE id LIKE ? ESCAPE '\\' AND deprecated = 0");
|
|
327
|
+
const matches = suffixStmt.all(`%#${this.likeEscape(id)}`);
|
|
272
328
|
if (matches.length === 1) {
|
|
273
329
|
return matches[0];
|
|
274
330
|
}
|
|
@@ -314,39 +370,53 @@ class DevMindDatabase {
|
|
|
314
370
|
this.writeGraphToDisk(p);
|
|
315
371
|
}
|
|
316
372
|
}
|
|
317
|
-
|
|
373
|
+
/** `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. */
|
|
374
|
+
renameNode(oldId, newId, newName, newFilePath) {
|
|
318
375
|
const node = this.getNode(oldId);
|
|
319
376
|
if (!node) {
|
|
320
377
|
throw new Error(`Node not found: ${oldId}`);
|
|
321
378
|
}
|
|
322
|
-
|
|
379
|
+
// getNode() resolves a bare/unqualified id (e.g. "createCart") to the node's fully-qualified
|
|
380
|
+
// one via a suffix match — but node_connections/history are keyed by the FULLY-QUALIFIED id
|
|
381
|
+
// only. Every statement below must use node.id, not the raw oldId parameter: using oldId
|
|
382
|
+
// directly makes each UPDATE a silent no-op whenever the caller passed a bare id (matching
|
|
383
|
+
// no rows, throwing no error), leaving the new id's row empty/disconnected while the old
|
|
384
|
+
// node's history and edges stay put under the id that was supposedly just renamed away.
|
|
385
|
+
const resolvedOldId = node.id;
|
|
386
|
+
const name = newName || (node.name === resolvedOldId ? newId : node.name);
|
|
387
|
+
const filePath = newFilePath || node.file_path;
|
|
323
388
|
this.db.pragma('foreign_keys = OFF');
|
|
324
389
|
try {
|
|
325
390
|
const runTx = this.db.transaction(() => {
|
|
326
391
|
const insertStmt = this.db.prepare(`
|
|
327
|
-
INSERT INTO nodes (id, type, name, file_path, signature, created_at)
|
|
328
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
392
|
+
INSERT INTO nodes (id, type, name, file_path, signature, created_at, deprecated)
|
|
393
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
329
394
|
`);
|
|
330
|
-
insertStmt.run(newId, node.type, name, node.
|
|
395
|
+
insertStmt.run(newId, node.type, name, filePath, node.signature, node.created_at, node.deprecated ? 1 : 0);
|
|
331
396
|
const updateSourceStmt = this.db.prepare(`
|
|
332
397
|
UPDATE node_connections SET source_node_id = ? WHERE source_node_id = ?
|
|
333
398
|
`);
|
|
334
|
-
updateSourceStmt.run(newId,
|
|
399
|
+
updateSourceStmt.run(newId, resolvedOldId);
|
|
335
400
|
const updateTargetStmt = this.db.prepare(`
|
|
336
401
|
UPDATE node_connections SET target_node_id = ? WHERE target_node_id = ?
|
|
337
402
|
`);
|
|
338
|
-
updateTargetStmt.run(newId,
|
|
403
|
+
updateTargetStmt.run(newId, resolvedOldId);
|
|
339
404
|
const updateHistoryStmt = this.db.prepare(`
|
|
340
405
|
UPDATE history SET node_id = ? WHERE node_id = ?
|
|
341
406
|
`);
|
|
342
|
-
updateHistoryStmt.run(newId,
|
|
407
|
+
updateHistoryStmt.run(newId, resolvedOldId);
|
|
343
408
|
const deleteOldStmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
|
|
344
|
-
deleteOldStmt.run(
|
|
409
|
+
deleteOldStmt.run(resolvedOldId);
|
|
345
410
|
});
|
|
346
411
|
runTx();
|
|
347
412
|
if (node.file_path) {
|
|
413
|
+
// Rewrite the OLD file's graph JSON too when the file itself moved, so the
|
|
414
|
+
// stale node entry doesn't linger under the old path's JSON on disk.
|
|
348
415
|
this.writeGraphToDisk(node.file_path);
|
|
349
416
|
}
|
|
417
|
+
if (filePath && filePath !== node.file_path) {
|
|
418
|
+
this.writeGraphToDisk(filePath);
|
|
419
|
+
}
|
|
350
420
|
// Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
|
|
351
421
|
// (which still reference oldId on disk). The DB was already repointed to newId above,
|
|
352
422
|
// so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
|
|
@@ -357,7 +427,7 @@ class DevMindDatabase {
|
|
|
357
427
|
// longer exists in the DB) and re-insert it right back, undoing the rename.
|
|
358
428
|
const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
|
|
359
429
|
for (const row of historyIds) {
|
|
360
|
-
this.patchHistoryDiskIdentity(row.id, newId, name, node.type,
|
|
430
|
+
this.patchHistoryDiskIdentity(row.id, newId, name, node.type, filePath, node.signature);
|
|
361
431
|
}
|
|
362
432
|
}
|
|
363
433
|
finally {
|
|
@@ -741,7 +811,17 @@ class DevMindDatabase {
|
|
|
741
811
|
return result;
|
|
742
812
|
}
|
|
743
813
|
updateHistory(params) {
|
|
744
|
-
const { node_id, code_snapshot
|
|
814
|
+
const { node_id, code_snapshot } = params;
|
|
815
|
+
let reasoning = params.reasoning;
|
|
816
|
+
// The calling AI has no reliable way to know who the human running this
|
|
817
|
+
// machine actually is -- it can only guess ("Claude Code", "AI Assistant",
|
|
818
|
+
// etc). Whenever this project has a configured developer identity (from
|
|
819
|
+
// .env's DEVELOPER_NAME, set by `devsmind init`), that's authoritative and
|
|
820
|
+
// always overrides whatever the agent supplied, so history is attributed
|
|
821
|
+
// to the real developer regardless of what the agent wrote in this field.
|
|
822
|
+
if (typeof reasoning === 'object' && this.context?.developer?.name) {
|
|
823
|
+
reasoning = { ...reasoning, developer: this.context.developer.name };
|
|
824
|
+
}
|
|
745
825
|
const node = this.getNode(node_id);
|
|
746
826
|
const resolvedId = node ? node.id : node_id;
|
|
747
827
|
const formattedReasoning = formatReasoning(reasoning);
|
|
@@ -753,20 +833,30 @@ class DevMindDatabase {
|
|
|
753
833
|
const lastUpdate = new Date(latest.updated_at).getTime();
|
|
754
834
|
const nowTime = new Date(nowStr).getTime();
|
|
755
835
|
const diffMs = nowTime - lastUpdate;
|
|
756
|
-
// If updated < 1 hour ago, update same record
|
|
836
|
+
// If updated < 1 hour ago, update the same record IN PLACE (no new row — this is what
|
|
837
|
+
// keeps db/graph/history from bloating with one entry per commit during an active editing
|
|
838
|
+
// session). code_snapshot is always the latest state (git already owns version history for
|
|
839
|
+
// code). reasoning is APPENDED, not overwritten — an earlier commit's "why" in this same
|
|
840
|
+
// session is still real and still worth keeping; losing it silently is worse than a few
|
|
841
|
+
// extra lines in one file. This also keeps any workflow step whose history_ids point at
|
|
842
|
+
// this row valid: it never loses what it originally linked to, only gains more below it.
|
|
757
843
|
if (diffMs < 3600000) {
|
|
844
|
+
const previousReasoning = typeof latest.reasoning === 'string' ? latest.reasoning : '';
|
|
845
|
+
const mergedReasoning = previousReasoning.trim().length > 0
|
|
846
|
+
? `${previousReasoning}\n\n── Update @ ${nowStr} ──\n${formattedReasoning}`
|
|
847
|
+
: formattedReasoning;
|
|
758
848
|
const updateStmt = this.db.prepare(`
|
|
759
849
|
UPDATE history
|
|
760
850
|
SET code_snapshot = '', reasoning = ?, updated_at = ?
|
|
761
851
|
WHERE id = ?
|
|
762
852
|
`);
|
|
763
|
-
updateStmt.run(
|
|
853
|
+
updateStmt.run(mergedReasoning, nowStr, latest.id);
|
|
764
854
|
// Write/Update on disk
|
|
765
|
-
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot,
|
|
855
|
+
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, mergedReasoning);
|
|
766
856
|
return {
|
|
767
857
|
...latest,
|
|
768
858
|
code_snapshot,
|
|
769
|
-
reasoning:
|
|
859
|
+
reasoning: mergedReasoning,
|
|
770
860
|
updated_at: nowStr
|
|
771
861
|
};
|
|
772
862
|
}
|
|
@@ -803,10 +893,14 @@ class DevMindDatabase {
|
|
|
803
893
|
const stmt = this.db.prepare(`
|
|
804
894
|
SELECT DISTINCT n.* FROM nodes n
|
|
805
895
|
LEFT JOIN history h ON n.id = h.node_id
|
|
806
|
-
WHERE n.name LIKE ? OR n.id LIKE ? OR h.reasoning LIKE ?
|
|
896
|
+
WHERE n.name LIKE ? ESCAPE '\\' OR n.id LIKE ? ESCAPE '\\' OR h.reasoning LIKE ? ESCAPE '\\'
|
|
807
897
|
LIMIT 50
|
|
808
898
|
`);
|
|
809
|
-
|
|
899
|
+
// Escaped so a query containing '%'/'_' (a real identifier fragment like "CartService_addItem"
|
|
900
|
+
// matches the literal underscore, not "any single character") searches for those characters
|
|
901
|
+
// rather than acting as SQL LIKE wildcards — sibling methods (getDeveloperActivity,
|
|
902
|
+
// searchDecisions) already do this; this one didn't.
|
|
903
|
+
const wildcard = `%${this.likeEscape(query)}%`;
|
|
810
904
|
const identifierMatches = stmt.all(wildcard, wildcard, wildcard);
|
|
811
905
|
if (identifierMatches.length > 0) {
|
|
812
906
|
return identifierMatches.map(n => ({ ...n, matched_via: 'identifier' }));
|
|
@@ -816,7 +910,97 @@ class DevMindDatabase {
|
|
|
816
910
|
is_regex: opts.is_regex,
|
|
817
911
|
case_insensitive: opts.case_insensitive
|
|
818
912
|
});
|
|
819
|
-
|
|
913
|
+
if (codeMatches.length > 0) {
|
|
914
|
+
return codeMatches.map(m => ({ ...m, matched_via: 'code' }));
|
|
915
|
+
}
|
|
916
|
+
const tokens = this.tokenizeQuery(query);
|
|
917
|
+
if (tokens.length === 0) {
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
return this.fuzzySearchNodes(tokens);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Splits a query string into lowercase word tokens for the fuzzy fallback
|
|
924
|
+
* stage of {@link searchNodes}. This is request-scoped tokenization only —
|
|
925
|
+
* nothing is persisted or indexed; the result is discarded after the call.
|
|
926
|
+
*/
|
|
927
|
+
tokenizeQuery(query) {
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
for (const raw of query.toLowerCase().split(/[^a-z0-9]+/i)) {
|
|
930
|
+
if (raw.length >= 2)
|
|
931
|
+
seen.add(raw);
|
|
932
|
+
}
|
|
933
|
+
return Array.from(seen);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Word-split relevance-ranked fallback for {@link searchNodes}. Runs only
|
|
937
|
+
* when the exact identifier and code stages both return nothing. Scores
|
|
938
|
+
* every non-deprecated node by how many distinct query tokens appear as a
|
|
939
|
+
* substring of its file_path/name/id (highest signal), latest reasoning,
|
|
940
|
+
* or code content (lowest signal, one point per matching line). No new
|
|
941
|
+
* data is written or synced — this is a plain in-memory scan reusing the
|
|
942
|
+
* same node/history sources searchCode already reads.
|
|
943
|
+
*/
|
|
944
|
+
fuzzySearchNodes(tokens) {
|
|
945
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
946
|
+
const stmt = this.db.prepare(`
|
|
947
|
+
SELECT n.*, h.id AS latest_history_id, h.reasoning AS reasoning
|
|
948
|
+
FROM nodes n
|
|
949
|
+
LEFT JOIN history h ON h.id = (
|
|
950
|
+
SELECT id FROM history WHERE node_id = n.id ORDER BY updated_at DESC LIMIT 1
|
|
951
|
+
)
|
|
952
|
+
WHERE n.deprecated = 0
|
|
953
|
+
`);
|
|
954
|
+
const rows = stmt.all();
|
|
955
|
+
const FIELD_WEIGHT = { path: 3, identifier: 3, reasoning: 2, code: 1 };
|
|
956
|
+
const scored = [];
|
|
957
|
+
for (const row of rows) {
|
|
958
|
+
const { latest_history_id, reasoning, ...node } = row;
|
|
959
|
+
const matchedTerms = new Set();
|
|
960
|
+
let score = 0;
|
|
961
|
+
const filePathLower = (node.file_path || '').toLowerCase();
|
|
962
|
+
const nameLower = (node.name || '').toLowerCase();
|
|
963
|
+
const idLower = (node.id || '').toLowerCase();
|
|
964
|
+
const reasoningLower = (reasoning || '').toLowerCase();
|
|
965
|
+
let code = '';
|
|
966
|
+
if (latest_history_id) {
|
|
967
|
+
const historyFile = path.join(historyDir, `${latest_history_id}.json`);
|
|
968
|
+
if (fs.existsSync(historyFile)) {
|
|
969
|
+
try {
|
|
970
|
+
const data = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
971
|
+
code = (data.code_snapshot || '').toLowerCase();
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
// Skip corrupted or unreadable history files
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
for (const token of tokens) {
|
|
979
|
+
let tokenMatched = false;
|
|
980
|
+
if (filePathLower.includes(token)) {
|
|
981
|
+
score += FIELD_WEIGHT.path;
|
|
982
|
+
tokenMatched = true;
|
|
983
|
+
}
|
|
984
|
+
if (nameLower.includes(token) || idLower.includes(token)) {
|
|
985
|
+
score += FIELD_WEIGHT.identifier;
|
|
986
|
+
tokenMatched = true;
|
|
987
|
+
}
|
|
988
|
+
if (reasoningLower.includes(token)) {
|
|
989
|
+
score += FIELD_WEIGHT.reasoning;
|
|
990
|
+
tokenMatched = true;
|
|
991
|
+
}
|
|
992
|
+
if (code && code.includes(token)) {
|
|
993
|
+
score += FIELD_WEIGHT.code;
|
|
994
|
+
tokenMatched = true;
|
|
995
|
+
}
|
|
996
|
+
if (tokenMatched)
|
|
997
|
+
matchedTerms.add(token);
|
|
998
|
+
}
|
|
999
|
+
if (score > 0) {
|
|
1000
|
+
scored.push({ ...node, matched_via: 'fuzzy', matched_terms: Array.from(matchedTerms), score });
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, 20);
|
|
820
1004
|
}
|
|
821
1005
|
getRecentChanges(hours = 24, analyzeImpact = true) {
|
|
822
1006
|
const stmt = this.db.prepare(`
|
|
@@ -858,11 +1042,11 @@ class DevMindDatabase {
|
|
|
858
1042
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
859
1043
|
FROM history h
|
|
860
1044
|
JOIN nodes n ON h.node_id = n.id
|
|
861
|
-
WHERE h.reasoning LIKE ?
|
|
1045
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
862
1046
|
ORDER BY h.updated_at DESC
|
|
863
1047
|
LIMIT ?
|
|
864
1048
|
`);
|
|
865
|
-
const query = `%Developer: %${developer}%`;
|
|
1049
|
+
const query = `%Developer: %${this.likeEscape(developer)}%`;
|
|
866
1050
|
return stmt.all(query, limit);
|
|
867
1051
|
}
|
|
868
1052
|
getChangesByRequirement(requirementId) {
|
|
@@ -870,10 +1054,10 @@ class DevMindDatabase {
|
|
|
870
1054
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
871
1055
|
FROM history h
|
|
872
1056
|
JOIN nodes n ON h.node_id = n.id
|
|
873
|
-
WHERE h.reasoning LIKE ?
|
|
1057
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
874
1058
|
ORDER BY h.updated_at DESC
|
|
875
1059
|
`);
|
|
876
|
-
const query = `%Requirement: %${requirementId}%`;
|
|
1060
|
+
const query = `%Requirement: %${this.likeEscape(requirementId)}%`;
|
|
877
1061
|
return stmt.all(query);
|
|
878
1062
|
}
|
|
879
1063
|
searchDecisions(query) {
|
|
@@ -881,10 +1065,10 @@ class DevMindDatabase {
|
|
|
881
1065
|
SELECT h.node_id, n.name as node_name, h.updated_at, h.reasoning
|
|
882
1066
|
FROM history h
|
|
883
1067
|
JOIN nodes n ON h.node_id = n.id
|
|
884
|
-
WHERE h.reasoning LIKE ?
|
|
1068
|
+
WHERE h.reasoning LIKE ? ESCAPE '\\'
|
|
885
1069
|
ORDER BY h.updated_at DESC
|
|
886
1070
|
`);
|
|
887
|
-
const wildcard = `%Decision: %${query}%`;
|
|
1071
|
+
const wildcard = `%Decision: %${this.likeEscape(query)}%`;
|
|
888
1072
|
return stmt.all(wildcard);
|
|
889
1073
|
}
|
|
890
1074
|
searchCode(params) {
|
|
@@ -957,7 +1141,8 @@ class DevMindDatabase {
|
|
|
957
1141
|
getOrphanedNodes() {
|
|
958
1142
|
const stmt = this.db.prepare(`
|
|
959
1143
|
SELECT * FROM nodes
|
|
960
|
-
WHERE
|
|
1144
|
+
WHERE deprecated = 0
|
|
1145
|
+
AND id NOT IN (SELECT DISTINCT source_node_id FROM node_connections)
|
|
961
1146
|
AND id NOT IN (SELECT DISTINCT target_node_id FROM node_connections)
|
|
962
1147
|
`);
|
|
963
1148
|
return stmt.all();
|
|
@@ -974,8 +1159,15 @@ class DevMindDatabase {
|
|
|
974
1159
|
params.push(filter.type);
|
|
975
1160
|
}
|
|
976
1161
|
if (filter?.file_path) {
|
|
977
|
-
|
|
978
|
-
|
|
1162
|
+
// file_path is stored with OS-native separators (backslashes on Windows), but the tool's
|
|
1163
|
+
// own schema example is forward-slash ("src/components") — a raw LIKE against the
|
|
1164
|
+
// unmodified column means that exact example returns nothing on Windows unless the
|
|
1165
|
+
// caller happens to pass backslashes instead. Normalize both sides to forward slashes
|
|
1166
|
+
// (getNodesByFilePath a few hundred lines up already does the equivalent for exact
|
|
1167
|
+
// matches; this just extends the same fix to the substring-filter path) and escape LIKE
|
|
1168
|
+
// metacharacters so a literal '%' or '_' in a path segment can't be misread as a wildcard.
|
|
1169
|
+
sql += " AND REPLACE(file_path, '\\', '/') LIKE ? ESCAPE '\\'";
|
|
1170
|
+
params.push(`%${this.likeEscape(filter.file_path.replace(/\\/g, '/'))}%`);
|
|
979
1171
|
}
|
|
980
1172
|
if (!filter?.include_deprecated) {
|
|
981
1173
|
sql += ' AND deprecated = 0';
|
|
@@ -992,48 +1184,471 @@ class DevMindDatabase {
|
|
|
992
1184
|
const rows = stmt.all();
|
|
993
1185
|
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
994
1186
|
}
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1187
|
+
// ─── `devsmind analyze` read-only health checks ────────────────────────────
|
|
1188
|
+
// All pure queries/graph traversal, no mutation, no LLM calls.
|
|
1189
|
+
/** Nodes whose total (in + out) connection degree meets/exceeds `threshold` — architectural bottleneck candidates. */
|
|
1190
|
+
getGodEntities(threshold = 15) {
|
|
1191
|
+
const stmt = this.db.prepare(`
|
|
1192
|
+
SELECT * FROM (
|
|
1193
|
+
SELECT n.id, n.name, n.file_path, (
|
|
1194
|
+
(SELECT COUNT(*) FROM node_connections c WHERE c.source_node_id = n.id) +
|
|
1195
|
+
(SELECT COUNT(*) FROM node_connections c WHERE c.target_node_id = n.id)
|
|
1196
|
+
) AS degree
|
|
1197
|
+
FROM nodes n
|
|
1198
|
+
WHERE n.deprecated = 0
|
|
1199
|
+
)
|
|
1200
|
+
WHERE degree >= ?
|
|
1201
|
+
ORDER BY degree DESC
|
|
1202
|
+
`);
|
|
1203
|
+
return stmt.all(threshold);
|
|
1204
|
+
}
|
|
1205
|
+
/** DFS cycle detection over the connection graph, capped at `maxCycles` reported paths. */
|
|
1206
|
+
getCircularDependencies(maxCycles = 50) {
|
|
1207
|
+
const edges = this.getAllConnections();
|
|
1208
|
+
const adjacency = new Map();
|
|
1209
|
+
for (const e of edges) {
|
|
1210
|
+
if (!adjacency.has(e.source_node_id))
|
|
1211
|
+
adjacency.set(e.source_node_id, []);
|
|
1212
|
+
adjacency.get(e.source_node_id).push(e.target_node_id);
|
|
1213
|
+
}
|
|
1214
|
+
const cycles = [];
|
|
1215
|
+
const visited = new Set();
|
|
1216
|
+
const stack = [];
|
|
1217
|
+
const onStack = new Set();
|
|
1218
|
+
const dfs = (node) => {
|
|
1219
|
+
if (cycles.length >= maxCycles)
|
|
1220
|
+
return;
|
|
1221
|
+
if (onStack.has(node)) {
|
|
1222
|
+
const start = stack.indexOf(node);
|
|
1223
|
+
cycles.push([...stack.slice(start), node]);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (visited.has(node))
|
|
1227
|
+
return;
|
|
1228
|
+
visited.add(node);
|
|
1229
|
+
stack.push(node);
|
|
1230
|
+
onStack.add(node);
|
|
1231
|
+
for (const next of adjacency.get(node) || []) {
|
|
1232
|
+
if (cycles.length >= maxCycles)
|
|
1233
|
+
break;
|
|
1234
|
+
dfs(next);
|
|
1235
|
+
}
|
|
1236
|
+
stack.pop();
|
|
1237
|
+
onStack.delete(node);
|
|
1238
|
+
};
|
|
1239
|
+
for (const node of adjacency.keys()) {
|
|
1240
|
+
if (cycles.length >= maxCycles)
|
|
1241
|
+
break;
|
|
1242
|
+
if (!visited.has(node))
|
|
1243
|
+
dfs(node);
|
|
1244
|
+
}
|
|
1245
|
+
return cycles;
|
|
1246
|
+
}
|
|
1247
|
+
/** node_connections rows whose source or target no longer exists in `nodes` (broken by a non-transactional delete, or a sync race). */
|
|
1248
|
+
getDanglingEdges() {
|
|
1249
|
+
const stmt = this.db.prepare(`
|
|
1250
|
+
SELECT * FROM node_connections
|
|
1251
|
+
WHERE source_node_id NOT IN (SELECT id FROM nodes)
|
|
1252
|
+
OR target_node_id NOT IN (SELECT id FROM nodes)
|
|
1253
|
+
`);
|
|
1254
|
+
return stmt.all();
|
|
1255
|
+
}
|
|
1256
|
+
/** Deletes a single dangling `node_connections` row. The edge itself is invalid data — no history/graph JSON to rewrite. */
|
|
1257
|
+
deleteDanglingEdge(sourceId, targetId) {
|
|
1258
|
+
this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? AND target_node_id = ?').run(sourceId, targetId);
|
|
1259
|
+
}
|
|
1260
|
+
/** Node ids that differ only by case — a real collision risk on Windows's case-insensitive filesystem. */
|
|
1261
|
+
getDuplicateNodeIds() {
|
|
1262
|
+
const stmt = this.db.prepare(`
|
|
1263
|
+
SELECT LOWER(id) AS lower_id, GROUP_CONCAT(id, '|') AS ids
|
|
1264
|
+
FROM nodes
|
|
1265
|
+
WHERE deprecated = 0
|
|
1266
|
+
GROUP BY lower_id
|
|
1267
|
+
HAVING COUNT(*) > 1
|
|
1268
|
+
`);
|
|
1269
|
+
const rows = stmt.all();
|
|
1270
|
+
return rows.map(r => ({ lowerId: r.lower_id, ids: r.ids.split('|') }));
|
|
1271
|
+
}
|
|
1272
|
+
/** History rows whose flattened `reasoning` text has no non-empty `Developer:` line — can't be attributed to anyone. */
|
|
1273
|
+
getHistoryMissingDeveloper() {
|
|
1274
|
+
const stmt = this.db.prepare('SELECT id, node_id, updated_at, reasoning FROM history');
|
|
1275
|
+
const rows = stmt.all();
|
|
1276
|
+
return rows
|
|
1277
|
+
.filter(r => {
|
|
1278
|
+
// [ \t]* (not \s*) so the match can't swallow the newline and bleed into the
|
|
1279
|
+
// next "Model:" line when Developer is blank, which would wrongly capture
|
|
1280
|
+
// "Model: <value>" as if it were the developer's name.
|
|
1281
|
+
const match = /Developer:[ \t]*([^\n]*)/i.exec(r.reasoning || '');
|
|
1282
|
+
return !match || !match[1].trim();
|
|
1283
|
+
})
|
|
1284
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* History rows with a blank code snapshot — usually a silent AST extraction failure.
|
|
1288
|
+
* The `history.code_snapshot` DB column is always written as `''` (the real content
|
|
1289
|
+
* lives only in the per-row JSON on disk, see `populateHistoryFromDisk`), so this
|
|
1290
|
+
* must read through the populated rows rather than querying the column directly.
|
|
1291
|
+
*/
|
|
1292
|
+
getEmptyCodeSnapshots() {
|
|
1293
|
+
return this.getAllHistory()
|
|
1294
|
+
.filter(h => !h.code_snapshot || h.code_snapshot.trim() === '')
|
|
1295
|
+
.map(({ id, node_id, updated_at }) => ({ id, node_id, updated_at }));
|
|
1296
|
+
}
|
|
1297
|
+
// ─── Workflow Context Vault ─────────────────────────────────────────────
|
|
1298
|
+
// Persistent, cross-session feature memory. Steps link to existing `history`
|
|
1299
|
+
// rows rather than duplicating code/reasoning; artifacts are plain files on
|
|
1300
|
+
// disk under `.devmind/workflows/<id>/`, with only the path stored in the DB.
|
|
1301
|
+
workflowsDir() {
|
|
1302
|
+
return path.join(path.dirname(this.dbPath), 'workflows');
|
|
1303
|
+
}
|
|
1304
|
+
/** Serializes the workflow + its steps + artifact index to disk so teammates can sync it via git. */
|
|
1305
|
+
writeWorkflowToDisk(workflowId) {
|
|
1306
|
+
try {
|
|
1307
|
+
const workflow = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(workflowId);
|
|
1308
|
+
if (!workflow)
|
|
1309
|
+
return;
|
|
1310
|
+
const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1311
|
+
const artifacts = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(workflowId);
|
|
1312
|
+
const activeId = this.getSystemMeta('active_workflow_id');
|
|
1313
|
+
const data = {
|
|
1314
|
+
id: workflow.id,
|
|
1315
|
+
name: workflow.name,
|
|
1316
|
+
description: workflow.description,
|
|
1317
|
+
status: workflow.status,
|
|
1318
|
+
created_at: workflow.created_at,
|
|
1319
|
+
updated_at: workflow.updated_at,
|
|
1320
|
+
is_active: activeId === workflowId,
|
|
1321
|
+
steps: steps.map(s => ({
|
|
1322
|
+
id: s.id,
|
|
1323
|
+
step_index: s.step_index,
|
|
1324
|
+
summary: s.summary,
|
|
1325
|
+
pending_tasks: s.pending_tasks,
|
|
1326
|
+
history_ids: s.history_ids,
|
|
1327
|
+
session_id: s.session_id,
|
|
1328
|
+
created_at: s.created_at
|
|
1329
|
+
})),
|
|
1330
|
+
artifact_index: artifacts.map(a => ({
|
|
1331
|
+
id: a.id,
|
|
1332
|
+
step_id: a.step_id,
|
|
1333
|
+
type: a.type,
|
|
1334
|
+
source_name: a.source_name,
|
|
1335
|
+
file_path: a.file_path,
|
|
1336
|
+
created_at: a.created_at
|
|
1337
|
+
}))
|
|
1338
|
+
};
|
|
1339
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1340
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1341
|
+
fs.writeFileSync(path.join(dir, 'workflow.json'), JSON.stringify(data, null, 2), 'utf-8');
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
console.warn('⚠️ DevsMind: Failed to write workflow JSON to disk:', err);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
createWorkflow(name, description) {
|
|
1348
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1349
|
+
const now = new Date().toISOString();
|
|
1350
|
+
this.db.prepare(`
|
|
1351
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1352
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
1353
|
+
`).run(id, name, description, now, now);
|
|
1354
|
+
this.setSystemMeta('active_workflow_id', id);
|
|
1355
|
+
this.writeWorkflowToDisk(id);
|
|
1356
|
+
return { id, name, description, status: 'active', created_at: now, updated_at: now };
|
|
1357
|
+
}
|
|
1358
|
+
getWorkflow(id) {
|
|
1359
|
+
const row = this.db.prepare('SELECT * FROM workflows WHERE id = ?').get(id);
|
|
1360
|
+
return row || null;
|
|
1361
|
+
}
|
|
1362
|
+
getActiveWorkflow() {
|
|
1363
|
+
const id = this.getSystemMeta('active_workflow_id');
|
|
1364
|
+
return id ? this.getWorkflow(id) : null;
|
|
1365
|
+
}
|
|
1366
|
+
listWorkflows(status) {
|
|
1367
|
+
if (status) {
|
|
1368
|
+
return this.db.prepare('SELECT * FROM workflows WHERE status = ? ORDER BY updated_at DESC').all(status);
|
|
1369
|
+
}
|
|
1370
|
+
return this.db.prepare('SELECT * FROM workflows ORDER BY updated_at DESC').all();
|
|
1371
|
+
}
|
|
1372
|
+
/** Pauses the currently active workflow (if any) and clears the active pointer. */
|
|
1373
|
+
pauseWorkflow() {
|
|
1374
|
+
const active = this.getActiveWorkflow();
|
|
1375
|
+
if (!active)
|
|
1376
|
+
return null;
|
|
1377
|
+
const now = new Date().toISOString();
|
|
1378
|
+
this.db.prepare(`UPDATE workflows SET status = 'paused', updated_at = ? WHERE id = ?`).run(now, active.id);
|
|
1379
|
+
this.setSystemMeta('active_workflow_id', '');
|
|
1380
|
+
this.writeWorkflowToDisk(active.id);
|
|
1381
|
+
return { ...active, status: 'paused', updated_at: now };
|
|
1382
|
+
}
|
|
1383
|
+
/** Resumes `id`, auto-pausing whatever was previously active (only one workflow is active at a time). */
|
|
1384
|
+
resumeWorkflow(id) {
|
|
1385
|
+
const workflow = this.getWorkflow(id);
|
|
1386
|
+
if (!workflow)
|
|
1387
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1388
|
+
const currentActive = this.getActiveWorkflow();
|
|
1389
|
+
if (currentActive && currentActive.id !== id)
|
|
1390
|
+
this.pauseWorkflow();
|
|
1391
|
+
const now = new Date().toISOString();
|
|
1392
|
+
this.db.prepare(`UPDATE workflows SET status = 'active', updated_at = ? WHERE id = ?`).run(now, id);
|
|
1393
|
+
this.setSystemMeta('active_workflow_id', id);
|
|
1394
|
+
this.writeWorkflowToDisk(id);
|
|
1395
|
+
return { ...workflow, status: 'active', updated_at: now };
|
|
1396
|
+
}
|
|
1397
|
+
completeWorkflow(id) {
|
|
1398
|
+
const workflow = this.getWorkflow(id);
|
|
1399
|
+
if (!workflow)
|
|
1400
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1401
|
+
const now = new Date().toISOString();
|
|
1402
|
+
this.db.prepare(`UPDATE workflows SET status = 'completed', updated_at = ? WHERE id = ?`).run(now, id);
|
|
1403
|
+
if (this.getSystemMeta('active_workflow_id') === id)
|
|
1404
|
+
this.setSystemMeta('active_workflow_id', '');
|
|
1405
|
+
this.writeWorkflowToDisk(id);
|
|
1406
|
+
return { ...workflow, status: 'completed', updated_at: now };
|
|
1407
|
+
}
|
|
1408
|
+
addWorkflowStep(workflowId, opts) {
|
|
1409
|
+
if (!this.getWorkflow(workflowId))
|
|
1410
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1411
|
+
const id = crypto.randomUUID();
|
|
1412
|
+
const now = new Date().toISOString();
|
|
1413
|
+
const nextIndex = (this.db.prepare('SELECT MAX(step_index) AS m FROM workflow_steps WHERE workflow_id = ?').get(workflowId).m ?? 0) + 1;
|
|
1414
|
+
const historyIdsJson = opts.historyIds && opts.historyIds.length ? JSON.stringify(opts.historyIds) : null;
|
|
1415
|
+
this.db.prepare(`
|
|
1416
|
+
INSERT INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
|
|
1417
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1418
|
+
`).run(id, workflowId, nextIndex, opts.summary, opts.pendingTasks || null, historyIdsJson, opts.sessionId || null, now);
|
|
1419
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1420
|
+
this.writeWorkflowToDisk(workflowId);
|
|
1421
|
+
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 };
|
|
1422
|
+
}
|
|
1423
|
+
/** Writes `content` to `.devmind/workflows/<workflowId>/<artifactId>_<sourceName>` and records the DB row. */
|
|
1424
|
+
addWorkflowArtifact(workflowId, opts) {
|
|
1425
|
+
if (!this.getWorkflow(workflowId))
|
|
1426
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1427
|
+
const id = crypto.randomUUID();
|
|
1428
|
+
const now = new Date().toISOString();
|
|
1429
|
+
const safeName = opts.sourceName.replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact.md';
|
|
1430
|
+
const dir = path.join(this.workflowsDir(), workflowId);
|
|
1431
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1432
|
+
const filePath = path.join(dir, `${id}_${safeName}`);
|
|
1433
|
+
fs.writeFileSync(filePath, opts.content, 'utf-8');
|
|
1434
|
+
this.db.prepare(`
|
|
1435
|
+
INSERT INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
|
|
1436
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1437
|
+
`).run(id, workflowId, opts.stepId || null, opts.type, opts.sourceName, filePath, now);
|
|
1438
|
+
this.db.prepare(`UPDATE workflows SET updated_at = ? WHERE id = ?`).run(now, workflowId);
|
|
1439
|
+
this.writeWorkflowToDisk(workflowId);
|
|
1440
|
+
return { id, workflow_id: workflowId, step_id: opts.stepId || null, type: opts.type, source_name: opts.sourceName, file_path: filePath, created_at: now };
|
|
1441
|
+
}
|
|
1442
|
+
getWorkflowContext(id, opts) {
|
|
1443
|
+
const workflow = this.getWorkflow(id);
|
|
1444
|
+
if (!workflow)
|
|
1445
|
+
throw new Error(`Workflow not found: ${id}`);
|
|
1446
|
+
const steps = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(id);
|
|
1447
|
+
const artifactRows = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? ORDER BY created_at ASC').all(id);
|
|
1448
|
+
const artifacts = artifactRows.map(a => {
|
|
1449
|
+
if (!opts?.includeArtifactContent)
|
|
1450
|
+
return a;
|
|
1451
|
+
try {
|
|
1452
|
+
const content = fs.existsSync(a.file_path) ? fs.readFileSync(a.file_path, 'utf-8') : undefined;
|
|
1453
|
+
return { ...a, content };
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
return a;
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
return { workflow, steps, artifacts };
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Returns steps for a workflow with optional pagination.
|
|
1463
|
+
* Use `last_n` to get only the most recent N steps (tail), or `limit`/`offset` for
|
|
1464
|
+
* arbitrary pagination. Without any option, all steps are returned.
|
|
1465
|
+
*/
|
|
1466
|
+
getWorkflowSteps(workflowId, opts) {
|
|
1467
|
+
if (!this.getWorkflow(workflowId))
|
|
1468
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1469
|
+
if (opts?.last_n && opts.last_n > 0) {
|
|
1470
|
+
// Fetch the last N steps by descending step_index, then reverse to chronological
|
|
1471
|
+
const rows = this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index DESC LIMIT ?').all(workflowId, opts.last_n);
|
|
1472
|
+
return rows.reverse();
|
|
1473
|
+
}
|
|
1474
|
+
if (opts?.limit) {
|
|
1475
|
+
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);
|
|
1476
|
+
}
|
|
1477
|
+
return this.db.prepare('SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY step_index ASC').all(workflowId);
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Reads a single workflow artifact's file content from disk.
|
|
1481
|
+
* Accepts either an artifact_id or a source_name (first match used).
|
|
1482
|
+
*/
|
|
1483
|
+
readWorkflowArtifact(workflowId, artifactId) {
|
|
1484
|
+
if (!this.getWorkflow(workflowId))
|
|
1485
|
+
throw new Error(`Workflow not found: ${workflowId}`);
|
|
1486
|
+
const row = this.db.prepare('SELECT * FROM workflow_artifacts WHERE workflow_id = ? AND id = ?').get(workflowId, artifactId);
|
|
1487
|
+
if (!row)
|
|
1488
|
+
throw new Error(`Artifact not found: ${artifactId} in workflow ${workflowId}`);
|
|
1489
|
+
if (!fs.existsSync(row.file_path))
|
|
1490
|
+
throw new Error(`Artifact file missing on disk: ${row.file_path}`);
|
|
1491
|
+
const content = fs.readFileSync(row.file_path, 'utf-8');
|
|
1492
|
+
return { artifact: row, content };
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Full-text keyword search across all workflows' step summaries, pending_tasks,
|
|
1496
|
+
* and artifact source names. Optionally also searches artifact file content.
|
|
1497
|
+
* Returns a list of matches grouped by workflow.
|
|
1498
|
+
*/
|
|
1499
|
+
searchWorkflows(query, opts) {
|
|
1500
|
+
// Escaped so a query containing '%' or '_' matches those characters literally instead of
|
|
1501
|
+
// acting as SQL LIKE wildcards — otherwise `query: "%"` matches every row in the project.
|
|
1502
|
+
const lq = `%${this.likeEscape(query.toLowerCase())}%`;
|
|
1503
|
+
// Find matching steps
|
|
1504
|
+
const matchedStepRows = this.db.prepare(`
|
|
1505
|
+
SELECT ws.* FROM workflow_steps ws
|
|
1506
|
+
JOIN workflows w ON w.id = ws.workflow_id
|
|
1507
|
+
WHERE (LOWER(ws.summary) LIKE ? ESCAPE '\\' OR LOWER(IFNULL(ws.pending_tasks,'')) LIKE ? ESCAPE '\\')
|
|
1508
|
+
${opts?.status ? 'AND w.status = ?' : ''}
|
|
1509
|
+
ORDER BY ws.workflow_id, ws.step_index ASC
|
|
1510
|
+
`).all(...(opts?.status ? [lq, lq, opts.status] : [lq, lq]));
|
|
1511
|
+
// Find matching artifacts by source_name
|
|
1512
|
+
const matchedArtifactRows = this.db.prepare(`
|
|
1513
|
+
SELECT wa.* FROM workflow_artifacts wa
|
|
1514
|
+
JOIN workflows w ON w.id = wa.workflow_id
|
|
1515
|
+
WHERE LOWER(wa.source_name) LIKE ? ESCAPE '\\'
|
|
1516
|
+
${opts?.status ? 'AND w.status = ?' : ''}
|
|
1517
|
+
ORDER BY wa.workflow_id, wa.created_at ASC
|
|
1518
|
+
`).all(...(opts?.status ? [lq, opts.status] : [lq]));
|
|
1519
|
+
// If content search requested, also scan artifact files
|
|
1520
|
+
const contentMatchedArtifactIds = new Set();
|
|
1521
|
+
const artifactContentSnippets = new Map();
|
|
1522
|
+
if (opts?.include_artifact_content) {
|
|
1523
|
+
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] : []));
|
|
1524
|
+
const lqPlain = query.toLowerCase();
|
|
1525
|
+
for (const a of allArtifacts) {
|
|
1526
|
+
if (contentMatchedArtifactIds.has(a.id))
|
|
1527
|
+
continue;
|
|
1528
|
+
try {
|
|
1529
|
+
if (fs.existsSync(a.file_path)) {
|
|
1530
|
+
const text = fs.readFileSync(a.file_path, 'utf-8');
|
|
1531
|
+
const idx = text.toLowerCase().indexOf(lqPlain);
|
|
1532
|
+
if (idx !== -1) {
|
|
1533
|
+
contentMatchedArtifactIds.add(a.id);
|
|
1534
|
+
const start = Math.max(0, idx - 80);
|
|
1535
|
+
const end = Math.min(text.length, idx + query.length + 80);
|
|
1536
|
+
artifactContentSnippets.set(a.id, (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
catch { /* skip unreadable */ }
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// Collect all relevant workflow IDs
|
|
1544
|
+
const workflowIdSet = new Set([
|
|
1545
|
+
...matchedStepRows.map(s => s.workflow_id),
|
|
1546
|
+
...matchedArtifactRows.map(a => a.workflow_id),
|
|
1547
|
+
...Array.from(contentMatchedArtifactIds).map(id => {
|
|
1548
|
+
const r = this.db.prepare('SELECT workflow_id FROM workflow_artifacts WHERE id = ?').get(id);
|
|
1549
|
+
return r?.workflow_id || '';
|
|
1550
|
+
}).filter(Boolean)
|
|
1000
1551
|
]);
|
|
1001
|
-
|
|
1552
|
+
const results = [];
|
|
1553
|
+
for (const wid of workflowIdSet) {
|
|
1554
|
+
const workflow = this.getWorkflow(wid);
|
|
1555
|
+
if (!workflow)
|
|
1556
|
+
continue;
|
|
1557
|
+
const steps = matchedStepRows.filter(s => s.workflow_id === wid);
|
|
1558
|
+
const artByName = matchedArtifactRows.filter(a => a.workflow_id === wid);
|
|
1559
|
+
const artByContent = opts?.include_artifact_content
|
|
1560
|
+
? 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))
|
|
1561
|
+
: [];
|
|
1562
|
+
const allArtifacts = [
|
|
1563
|
+
...artByName.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) })),
|
|
1564
|
+
...artByContent.map(a => ({ ...a, content_snippet: artifactContentSnippets.get(a.id) }))
|
|
1565
|
+
];
|
|
1566
|
+
results.push({ workflow, matched_steps: steps, matched_artifacts: allArtifacts });
|
|
1567
|
+
}
|
|
1568
|
+
// Sort by most recently updated workflow first
|
|
1569
|
+
results.sort((a, b) => b.workflow.updated_at.localeCompare(a.workflow.updated_at));
|
|
1570
|
+
return results;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Imports an existing flow/architecture doc as a paused workflow (not active — importing
|
|
1574
|
+
* a doc isn't the same as declaring active work). Idempotent on `name`: re-importing the
|
|
1575
|
+
* same doc overwrites its existing `imported_doc` artifact file in place instead of
|
|
1576
|
+
* creating a duplicate workflow every time the source docs are re-imported.
|
|
1577
|
+
*/
|
|
1578
|
+
importWorkflowDoc(name, description, content, sourceFileName) {
|
|
1579
|
+
const existing = this.db.prepare('SELECT * FROM workflows WHERE name = ?').get(name);
|
|
1580
|
+
const now = new Date().toISOString();
|
|
1581
|
+
if (existing) {
|
|
1582
|
+
this.db.prepare(`UPDATE workflows SET description = ?, updated_at = ? WHERE id = ?`).run(description, now, existing.id);
|
|
1583
|
+
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);
|
|
1584
|
+
if (existingArtifact) {
|
|
1585
|
+
fs.writeFileSync(existingArtifact.file_path, content, 'utf-8');
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
this.addWorkflowArtifact(existing.id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1589
|
+
}
|
|
1590
|
+
this.writeWorkflowToDisk(existing.id);
|
|
1591
|
+
return { workflow: { ...existing, description, updated_at: now }, created: false };
|
|
1592
|
+
}
|
|
1593
|
+
const id = `wf_${crypto.randomUUID()}`;
|
|
1594
|
+
this.db.prepare(`
|
|
1595
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1596
|
+
VALUES (?, ?, ?, 'paused', ?, ?)
|
|
1597
|
+
`).run(id, name, description, now, now);
|
|
1598
|
+
this.addWorkflowStep(id, { summary: `Imported existing flow documentation: ${sourceFileName}` });
|
|
1599
|
+
this.addWorkflowArtifact(id, { type: 'imported_doc', sourceName: sourceFileName, content });
|
|
1600
|
+
// writeWorkflowToDisk is already called inside addWorkflowArtifact/addWorkflowStep above
|
|
1601
|
+
return { workflow: { id, name, description, status: 'paused', created_at: now, updated_at: now }, created: true };
|
|
1602
|
+
}
|
|
1603
|
+
static SPURIOUS_NODE_NAMES = new Set([
|
|
1604
|
+
'promise', 'map', 'set', 'json', 'console', 'error', 'object', 'function', 'array', 'string', 'number', 'boolean', 'regexp', 'date', 'math',
|
|
1605
|
+
'any', 'void', 'unknown', 'never', 'null', 'undefined', 'dict', 'list',
|
|
1606
|
+
'data', 'useeffect', 'val', 'temp', 'result', 'item', 'key', 'value', 'err', 'req', 'res', 'args', 'params', 'response', 'request'
|
|
1607
|
+
]);
|
|
1608
|
+
/**
|
|
1609
|
+
* Read-only detection shared by `pruneSpuriousNodes` (which acts on it) and `devsmind
|
|
1610
|
+
* analyze`'s dry-run report (which just lists it). Never mutates the DB.
|
|
1611
|
+
*/
|
|
1612
|
+
findSpuriousAndMissingFileNodes(workspaceRoot) {
|
|
1002
1613
|
const stmt = this.db.prepare(`
|
|
1003
1614
|
SELECT id, name, file_path FROM nodes
|
|
1004
1615
|
WHERE deprecated = 0
|
|
1005
1616
|
`);
|
|
1006
1617
|
const candidates = stmt.all();
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
const affectedFilePaths = new Set();
|
|
1618
|
+
const spurious = [];
|
|
1619
|
+
const missingFile = [];
|
|
1010
1620
|
for (const node of candidates) {
|
|
1011
1621
|
const lowerName = node.name.toLowerCase();
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1622
|
+
if (DevMindDatabase.SPURIOUS_NODE_NAMES.has(lowerName)) {
|
|
1623
|
+
spurious.push(node);
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1016
1626
|
if (node.file_path) {
|
|
1017
1627
|
const paths = node.file_path.split(',').map(p => p.trim()).filter(Boolean);
|
|
1018
1628
|
if (paths.length > 0) {
|
|
1019
1629
|
const allMissing = paths.every(p => {
|
|
1020
|
-
const resolvedPath = path.isAbsolute(p)
|
|
1021
|
-
? p
|
|
1022
|
-
: path.resolve(workspaceRoot, p);
|
|
1630
|
+
const resolvedPath = path.isAbsolute(p) ? p : path.resolve(workspaceRoot, p);
|
|
1023
1631
|
return !fs.existsSync(resolvedPath);
|
|
1024
1632
|
});
|
|
1025
|
-
if (allMissing)
|
|
1026
|
-
|
|
1027
|
-
}
|
|
1633
|
+
if (allMissing)
|
|
1634
|
+
missingFile.push(node);
|
|
1028
1635
|
}
|
|
1029
1636
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1637
|
+
}
|
|
1638
|
+
return { spurious, missingFile };
|
|
1639
|
+
}
|
|
1640
|
+
pruneSpuriousNodes(workspaceRoot) {
|
|
1641
|
+
const { spurious, missingFile } = this.findSpuriousAndMissingFileNodes(workspaceRoot);
|
|
1642
|
+
const candidates = [...spurious, ...missingFile];
|
|
1643
|
+
const idsToDelete = [];
|
|
1644
|
+
const namesDeleted = [];
|
|
1645
|
+
const affectedFilePaths = new Set();
|
|
1646
|
+
for (const node of candidates) {
|
|
1647
|
+
idsToDelete.push(node.id);
|
|
1648
|
+
namesDeleted.push(`${node.name} (${node.id})`);
|
|
1649
|
+
if (node.file_path) {
|
|
1650
|
+
for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
1651
|
+
affectedFilePaths.add(p);
|
|
1037
1652
|
}
|
|
1038
1653
|
}
|
|
1039
1654
|
}
|
|
@@ -1126,41 +1741,137 @@ class DevMindDatabase {
|
|
|
1126
1741
|
toRepoRelativePath(absolutePath) {
|
|
1127
1742
|
if (!absolutePath || !this.context)
|
|
1128
1743
|
return absolutePath;
|
|
1129
|
-
const abs =
|
|
1744
|
+
const abs = (0, config_1.canonicalizePath)(absolutePath).replace(/\\/g, '/');
|
|
1745
|
+
const absLower = abs.toLowerCase();
|
|
1130
1746
|
for (const repo of this.context.config.repos) {
|
|
1131
1747
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1132
1748
|
if (repoPath) {
|
|
1133
|
-
const normalizedRepoPath =
|
|
1134
|
-
|
|
1749
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath).replace(/\\/g, '/');
|
|
1750
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1751
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + '/')) {
|
|
1135
1752
|
const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
|
|
1136
1753
|
return `{${repo.name}}/${relative}`;
|
|
1137
1754
|
}
|
|
1138
1755
|
}
|
|
1139
1756
|
}
|
|
1140
1757
|
// Fallback: resolve relative to workspace root
|
|
1141
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1758
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1142
1759
|
return path.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
|
|
1143
1760
|
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Rejects a resolved path that escapes its expected root (e.g. via a stored
|
|
1763
|
+
* `{repo}/../../..` path traveling outside the repo) by clamping it back to
|
|
1764
|
+
* the root itself. node_id/file_path values flow in from AI-supplied tool
|
|
1765
|
+
* calls, so a resolve must never be trusted to stay inside root on its own.
|
|
1766
|
+
*/
|
|
1767
|
+
clampToRoot(root, resolved) {
|
|
1768
|
+
const normalizedRoot = (0, config_1.canonicalizePath)(root);
|
|
1769
|
+
const normalizedResolved = (0, config_1.canonicalizePath)(resolved);
|
|
1770
|
+
const rootLower = normalizedRoot.toLowerCase();
|
|
1771
|
+
const resolvedLower = normalizedResolved.toLowerCase();
|
|
1772
|
+
if (resolvedLower === rootLower || resolvedLower.startsWith(rootLower + path.sep)) {
|
|
1773
|
+
return normalizedResolved;
|
|
1774
|
+
}
|
|
1775
|
+
console.warn(`⚠️ Path traversal blocked: "${resolved}" escapes root "${root}"`);
|
|
1776
|
+
return normalizedRoot;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* True if `absPath` sits inside a configured repo root or the workspace root itself.
|
|
1780
|
+
* Used to reject `stage_change`/`update_history` file paths that would otherwise let a
|
|
1781
|
+
* tool call read/write any file on disk (absolute path, or a `../` escape) instead of
|
|
1782
|
+
* just repo source — nothing upstream of this validates that the AI-supplied path is
|
|
1783
|
+
* actually inside the project.
|
|
1784
|
+
*/
|
|
1785
|
+
/**
|
|
1786
|
+
* Gate for every AI-facing write (edit_node, stage_change, the legacy update_history):
|
|
1787
|
+
* true only for paths inside a configured repo. `.devmind` itself — this project's OWN
|
|
1788
|
+
* config, brain.db, and cached graph JSON — is never writable through these tools, even
|
|
1789
|
+
* though it sits next to (and, before this check, was indistinguishable from) real source:
|
|
1790
|
+
* without this, a write tool built to "never refuse a file type" would just as happily
|
|
1791
|
+
* rewrite devsmind's own config.json as it would application source.
|
|
1792
|
+
*/
|
|
1793
|
+
isPathAllowed(absPath) {
|
|
1794
|
+
const abs = (0, config_1.canonicalizePath)(absPath);
|
|
1795
|
+
const absLower = abs.toLowerCase();
|
|
1796
|
+
const devmindDirLower = (0, config_1.canonicalizePath)(path.dirname(this.dbPath)).toLowerCase();
|
|
1797
|
+
if (absLower === devmindDirLower || absLower.startsWith(devmindDirLower + path.sep))
|
|
1798
|
+
return false;
|
|
1799
|
+
if (this.context) {
|
|
1800
|
+
for (const repo of this.context.config.repos) {
|
|
1801
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1802
|
+
if (repoPath) {
|
|
1803
|
+
const normalizedRepoPath = (0, config_1.canonicalizePath)(repoPath);
|
|
1804
|
+
const normalizedRepoPathLower = normalizedRepoPath.toLowerCase();
|
|
1805
|
+
if (absLower === normalizedRepoPathLower || absLower.startsWith(normalizedRepoPathLower + path.sep))
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1144
1812
|
toAbsolutePath(repoRelativePath) {
|
|
1145
1813
|
if (!repoRelativePath)
|
|
1146
1814
|
return repoRelativePath;
|
|
1147
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
1815
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1148
1816
|
const match = repoRelativePath.match(/^\{([^}]+)\}\/(.*)$/);
|
|
1149
1817
|
if (match && this.context) {
|
|
1150
1818
|
const repoName = match[1];
|
|
1151
1819
|
const relativePath = match[2];
|
|
1152
1820
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repoName);
|
|
1153
1821
|
if (repoPath) {
|
|
1154
|
-
return path.resolve(repoPath, relativePath);
|
|
1822
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(repoPath, path.resolve(repoPath, relativePath)));
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
// Heuristic: if it doesn't start with {repoName} but contains a configured repo name in the path
|
|
1826
|
+
if (this.context) {
|
|
1827
|
+
for (const repo of this.context.config.repos) {
|
|
1828
|
+
// Find if repo.name appears as a folder in the path, e.g. "harrir-express-backend/tests/..."
|
|
1829
|
+
const escapedRepoName = repo.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1830
|
+
const regex = new RegExp('(?:^|/|\\\\)' + escapedRepoName + '(?:/|\\\\)(.*)$', 'i');
|
|
1831
|
+
const m = repoRelativePath.match(regex);
|
|
1832
|
+
if (m) {
|
|
1833
|
+
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
1834
|
+
if (repoPath) {
|
|
1835
|
+
const relativePath = m[1];
|
|
1836
|
+
return (0, config_1.canonicalizePath)(path.resolve(repoPath, relativePath));
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1155
1839
|
}
|
|
1156
1840
|
}
|
|
1157
1841
|
// Fallback: resolve relative to workspace root
|
|
1158
|
-
return path.resolve(workspaceRoot, repoRelativePath);
|
|
1842
|
+
return (0, config_1.canonicalizePath)(this.clampToRoot(workspaceRoot, path.resolve(workspaceRoot, repoRelativePath)));
|
|
1159
1843
|
}
|
|
1160
1844
|
syncFromDisk() {
|
|
1161
1845
|
this.db.pragma('foreign_keys = OFF');
|
|
1162
1846
|
try {
|
|
1163
1847
|
const workspaceRoot = path.dirname(this.dbPath);
|
|
1848
|
+
// 0. Auto-heal any legacy relative path records in SQLite.
|
|
1849
|
+
//
|
|
1850
|
+
// Runs on every server start, so getting "already absolute" wrong is not a one-time
|
|
1851
|
+
// migration slip — it recurs forever. The original check only recognized the C: drive
|
|
1852
|
+
// and POSIX roots ('c:%'/'C:%'/'/%'); SQL LIKE has no character-range syntax, so it could
|
|
1853
|
+
// not express "any drive letter" or a UNC path (\\server\share\...) in one pattern. Every
|
|
1854
|
+
// node on a D:, E:, ... drive or a UNC path was misclassified as relative, run through
|
|
1855
|
+
// toAbsolutePath() -> clampToRoot(), and silently rewritten to the workspace root — i.e.
|
|
1856
|
+
// real file_paths for an entire class of valid Windows paths got destroyed on restart.
|
|
1857
|
+
// path.isAbsolute() classifies all of these correctly in one call.
|
|
1858
|
+
try {
|
|
1859
|
+
const legacyNodes = this.db.prepare('SELECT id, file_path FROM nodes').all()
|
|
1860
|
+
.filter(n => n.file_path && !path.isAbsolute(n.file_path));
|
|
1861
|
+
if (legacyNodes.length > 0) {
|
|
1862
|
+
const updateStmt = this.db.prepare('UPDATE nodes SET file_path = ? WHERE id = ?');
|
|
1863
|
+
const healTx = this.db.transaction(() => {
|
|
1864
|
+
for (const n of legacyNodes) {
|
|
1865
|
+
const abs = this.toAbsolutePath(n.file_path);
|
|
1866
|
+
updateStmt.run(abs, n.id);
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
healTx();
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
catch (err) {
|
|
1873
|
+
// ignore legacy errors
|
|
1874
|
+
}
|
|
1164
1875
|
// 1. Sync History JSONs
|
|
1165
1876
|
const historyDir = path.join(workspaceRoot, 'history');
|
|
1166
1877
|
if (fs.existsSync(historyDir)) {
|
|
@@ -1265,6 +1976,65 @@ class DevMindDatabase {
|
|
|
1265
1976
|
syncGraphTx();
|
|
1266
1977
|
}
|
|
1267
1978
|
}
|
|
1979
|
+
// 3. Sync Workflow JSONs
|
|
1980
|
+
const workflowsDir = this.workflowsDir();
|
|
1981
|
+
if (fs.existsSync(workflowsDir)) {
|
|
1982
|
+
const upsertWorkflow = this.db.prepare(`
|
|
1983
|
+
INSERT INTO workflows (id, name, description, status, created_at, updated_at)
|
|
1984
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1985
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1986
|
+
name = excluded.name,
|
|
1987
|
+
description = excluded.description,
|
|
1988
|
+
status = excluded.status,
|
|
1989
|
+
updated_at = excluded.updated_at
|
|
1990
|
+
`);
|
|
1991
|
+
const upsertStep = this.db.prepare(`
|
|
1992
|
+
INSERT OR IGNORE INTO workflow_steps (id, workflow_id, step_index, summary, pending_tasks, history_ids, session_id, created_at)
|
|
1993
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1994
|
+
`);
|
|
1995
|
+
const upsertArtifact = this.db.prepare(`
|
|
1996
|
+
INSERT OR IGNORE INTO workflow_artifacts (id, workflow_id, step_id, type, source_name, file_path, created_at)
|
|
1997
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1998
|
+
`);
|
|
1999
|
+
// Track which workflow.json has is_active:true with the latest updated_at
|
|
2000
|
+
let bestActiveId = null;
|
|
2001
|
+
let bestActiveUpdatedAt = '';
|
|
2002
|
+
const syncWorkflowsTx = this.db.transaction(() => {
|
|
2003
|
+
const subdirs = fs.readdirSync(workflowsDir);
|
|
2004
|
+
for (const subdir of subdirs) {
|
|
2005
|
+
const jsonPath = path.join(workflowsDir, subdir, 'workflow.json');
|
|
2006
|
+
if (!fs.existsSync(jsonPath))
|
|
2007
|
+
continue;
|
|
2008
|
+
try {
|
|
2009
|
+
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
|
|
2010
|
+
if (!data.id || !data.name)
|
|
2011
|
+
continue;
|
|
2012
|
+
upsertWorkflow.run(data.id, data.name, data.description || '', data.status || 'paused', data.created_at || new Date().toISOString(), data.updated_at || new Date().toISOString());
|
|
2013
|
+
for (const s of (data.steps || [])) {
|
|
2014
|
+
if (!s.id)
|
|
2015
|
+
continue;
|
|
2016
|
+
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());
|
|
2017
|
+
}
|
|
2018
|
+
for (const a of (data.artifact_index || [])) {
|
|
2019
|
+
if (!a.id)
|
|
2020
|
+
continue;
|
|
2021
|
+
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());
|
|
2022
|
+
}
|
|
2023
|
+
// Track which workflow declared itself active most recently
|
|
2024
|
+
if (data.is_active && data.updated_at > bestActiveUpdatedAt) {
|
|
2025
|
+
bestActiveId = data.id;
|
|
2026
|
+
bestActiveUpdatedAt = data.updated_at;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
catch { /* skip malformed */ }
|
|
2030
|
+
}
|
|
2031
|
+
});
|
|
2032
|
+
syncWorkflowsTx();
|
|
2033
|
+
// Restore active_workflow_id if not already set and a JSON claims active status
|
|
2034
|
+
if (bestActiveId && !this.getSystemMeta('active_workflow_id')) {
|
|
2035
|
+
this.setSystemMeta('active_workflow_id', bestActiveId);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
1268
2038
|
}
|
|
1269
2039
|
catch (err) {
|
|
1270
2040
|
console.warn('⚠️ SQLite warning: Failed to sync from disk:', err);
|
|
@@ -1281,9 +2051,9 @@ class DevMindDatabase {
|
|
|
1281
2051
|
try {
|
|
1282
2052
|
if (!filePath)
|
|
1283
2053
|
return;
|
|
1284
|
-
const workspaceRoot = path.dirname(this.dbPath);
|
|
2054
|
+
const workspaceRoot = (0, config_1.canonicalizePath)(path.dirname(this.dbPath));
|
|
1285
2055
|
// Clean/resolve the file path
|
|
1286
|
-
const absPath =
|
|
2056
|
+
const absPath = (0, config_1.canonicalizePath)(filePath);
|
|
1287
2057
|
const repoRelPath = this.toRepoRelativePath(absPath);
|
|
1288
2058
|
// E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
|
|
1289
2059
|
const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
|
|
@@ -1297,16 +2067,18 @@ class DevMindDatabase {
|
|
|
1297
2067
|
// is durable across a syncFromDisk() restart and propagates to teammates via git —
|
|
1298
2068
|
// otherwise the node's history JSON would resurrect it as active on the next start.
|
|
1299
2069
|
const absEsc = this.likeEscape(absPath);
|
|
2070
|
+
const absLower = absPath.toLowerCase();
|
|
2071
|
+
const absEscLower = absEsc.toLowerCase();
|
|
1300
2072
|
const stmtNodes = this.db.prepare(`
|
|
1301
2073
|
SELECT * FROM nodes
|
|
1302
2074
|
WHERE (
|
|
1303
|
-
file_path = ? OR
|
|
1304
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1305
|
-
file_path LIKE ? ESCAPE '\\' OR
|
|
1306
|
-
file_path LIKE ? ESCAPE '\\'
|
|
2075
|
+
LOWER(file_path) = ? OR
|
|
2076
|
+
LOWER(file_path) LIKE ? ESCAPE '\\' OR
|
|
2077
|
+
LOWER(file_path) LIKE ? ESCAPE '\\' OR
|
|
2078
|
+
LOWER(file_path) LIKE ? ESCAPE '\\'
|
|
1307
2079
|
)
|
|
1308
2080
|
`);
|
|
1309
|
-
const nodes = stmtNodes.all(
|
|
2081
|
+
const nodes = stmtNodes.all(absLower, `${absEscLower}, %`, `%, ${absEscLower}`, `%, ${absEscLower}, %`);
|
|
1310
2082
|
if (nodes.length === 0) {
|
|
1311
2083
|
// If no nodes left, delete the JSON file if it exists
|
|
1312
2084
|
if (fs.existsSync(graphJsonPath)) {
|
|
@@ -1349,6 +2121,30 @@ class DevMindDatabase {
|
|
|
1349
2121
|
console.warn('⚠️ SQLite warning: Failed to write graph JSON to disk:', err);
|
|
1350
2122
|
}
|
|
1351
2123
|
}
|
|
2124
|
+
/** Force-syncs all database nodes and workflows to disk JSON files. */
|
|
2125
|
+
syncToDisk() {
|
|
2126
|
+
try {
|
|
2127
|
+
const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all();
|
|
2128
|
+
const filePaths = new Set();
|
|
2129
|
+
for (const row of rows) {
|
|
2130
|
+
if (row.file_path) {
|
|
2131
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
2132
|
+
filePaths.add(p);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
for (const filePath of filePaths) {
|
|
2137
|
+
this.writeGraphToDisk(filePath);
|
|
2138
|
+
}
|
|
2139
|
+
const workflowRows = this.db.prepare('SELECT id FROM workflows').all();
|
|
2140
|
+
for (const row of workflowRows) {
|
|
2141
|
+
this.writeWorkflowToDisk(row.id);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
catch (err) {
|
|
2145
|
+
console.warn('⚠️ DevsMind: Failed to sync database to disk:', err);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
1352
2148
|
}
|
|
1353
2149
|
exports.DevMindDatabase = DevMindDatabase;
|
|
1354
2150
|
//# sourceMappingURL=database.js.map
|