@yemi33/minions 0.1.2068 → 0.1.2070
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/engine/db/migrations/004-pull-requests.js +129 -0
- package/engine/db/migrations/005-logs.js +39 -0
- package/engine/db/migrations/006-metrics.js +106 -0
- package/engine/lifecycle.js +3 -4
- package/engine/llm.js +2 -2
- package/engine/logs-store.js +190 -0
- package/engine/metrics-store.js +0 -0
- package/engine/pull-requests-store.js +296 -0
- package/engine/queries.js +83 -36
- package/engine/shared.js +127 -12
- package/engine/work-items-store.js +30 -23
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// engine/db/migrations/004-pull-requests.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 3: move pull-requests.json (central + per-project) into a
|
|
4
|
+
// `pull_requests` table.
|
|
5
|
+
//
|
|
6
|
+
// Same hybrid schema as `work_items`: typed projection columns for hot
|
|
7
|
+
// filters (scope/status/agent/branch/timestamps), plus a `data` TEXT
|
|
8
|
+
// column holding the full record JSON so future fields land transparently.
|
|
9
|
+
//
|
|
10
|
+
// `scope` keys the file the record came from: 'central' for
|
|
11
|
+
// <MINIONS_DIR>/pull-requests.json, otherwise the project name (matches
|
|
12
|
+
// the directory under projects/<name>/). Per-scope writes diff against
|
|
13
|
+
// that scope's rows so a mutation on one project's file never touches
|
|
14
|
+
// another's.
|
|
15
|
+
//
|
|
16
|
+
// Backfill: walk central + every projects/<name>/pull-requests.json. The
|
|
17
|
+
// JSON files stay on disk as a dual-write mirror so legacy direct-readers
|
|
18
|
+
// continue to function while we migrate them in Phase 3.5.
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
|
|
23
|
+
function _resolveMinionsDir() {
|
|
24
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
25
|
+
if (envHome) return envHome;
|
|
26
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _toMs(v) {
|
|
30
|
+
if (v == null) return null;
|
|
31
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
32
|
+
const parsed = Date.parse(v);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _readJsonArray(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _listProjectDirs(minionsDir) {
|
|
47
|
+
const projectsRoot = path.join(minionsDir, 'projects');
|
|
48
|
+
let entries;
|
|
49
|
+
try { entries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
|
|
50
|
+
catch { return []; }
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
if (!e.isDirectory()) continue;
|
|
54
|
+
if (e.name.startsWith('.')) continue; // .archived + any other hidden sidecars
|
|
55
|
+
out.push(e.name);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
version: 4,
|
|
62
|
+
description: 'pull_requests: schema + pull-requests.json backfill (central + per-project)',
|
|
63
|
+
up(db) {
|
|
64
|
+
db.exec(`
|
|
65
|
+
CREATE TABLE pull_requests (
|
|
66
|
+
id TEXT NOT NULL,
|
|
67
|
+
scope TEXT NOT NULL,
|
|
68
|
+
status TEXT NOT NULL,
|
|
69
|
+
review_status TEXT,
|
|
70
|
+
agent TEXT,
|
|
71
|
+
branch TEXT,
|
|
72
|
+
pr_number INTEGER,
|
|
73
|
+
created_at INTEGER,
|
|
74
|
+
last_polled_at INTEGER,
|
|
75
|
+
data TEXT NOT NULL,
|
|
76
|
+
updated_at INTEGER NOT NULL,
|
|
77
|
+
PRIMARY KEY (scope, id)
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX idx_pr_status ON pull_requests(status);
|
|
80
|
+
CREATE INDEX idx_pr_scope_status ON pull_requests(scope, status);
|
|
81
|
+
CREATE INDEX idx_pr_agent ON pull_requests(agent);
|
|
82
|
+
CREATE INDEX idx_pr_branch ON pull_requests(branch);
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
const minionsDir = _resolveMinionsDir();
|
|
86
|
+
if (!minionsDir) return;
|
|
87
|
+
|
|
88
|
+
const insert = db.prepare(`
|
|
89
|
+
INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
91
|
+
`);
|
|
92
|
+
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
let inserted = 0;
|
|
95
|
+
|
|
96
|
+
const backfillFile = (scope, filePath) => {
|
|
97
|
+
const rows = _readJsonArray(filePath);
|
|
98
|
+
for (const pr of rows) {
|
|
99
|
+
if (!pr || typeof pr !== 'object' || !pr.id) continue;
|
|
100
|
+
try {
|
|
101
|
+
insert.run(
|
|
102
|
+
String(pr.id),
|
|
103
|
+
scope,
|
|
104
|
+
String(pr.status || 'active'),
|
|
105
|
+
pr.reviewStatus || null,
|
|
106
|
+
pr.agent || null,
|
|
107
|
+
pr.branch || null,
|
|
108
|
+
Number.isFinite(pr.prNumber) ? pr.prNumber : null,
|
|
109
|
+
_toMs(pr.created),
|
|
110
|
+
_toMs(pr.lastPolledAt || pr.lastPolled),
|
|
111
|
+
JSON.stringify(pr),
|
|
112
|
+
now,
|
|
113
|
+
);
|
|
114
|
+
inserted += 1;
|
|
115
|
+
} catch {
|
|
116
|
+
// Duplicate (scope, id) — skip. Defensive against malformed files.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
backfillFile('central', path.join(minionsDir, 'pull-requests.json'));
|
|
122
|
+
for (const projectName of _listProjectDirs(minionsDir)) {
|
|
123
|
+
backfillFile(projectName, path.join(minionsDir, 'projects', projectName, 'pull-requests.json'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// eslint-disable-next-line no-console
|
|
127
|
+
console.log(`[db-migrate] v4: backfilled ${inserted} pull requests; pull-requests.json files kept as dual-write mirrors`);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// engine/db/migrations/005-logs.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 4: replace engine/log.json with a `logs` table.
|
|
4
|
+
//
|
|
5
|
+
// Logs are append-only with a 2500-entry ring buffer (the legacy JSON
|
|
6
|
+
// path trimmed to 2000 when length hit 2500). The table preserves that
|
|
7
|
+
// behaviour:
|
|
8
|
+
// - Each log() emits one row via INSERT.
|
|
9
|
+
// - After insert, a tail-trim DELETEs everything older than the most
|
|
10
|
+
// recent N (default 2500, configurable via ENGINE_DEFAULTS.logCapEntries).
|
|
11
|
+
//
|
|
12
|
+
// Schema is intentionally narrow: an auto-incrementing id (preserves
|
|
13
|
+
// insertion order for the ring-buffer trim), the timestamp in ms (so the
|
|
14
|
+
// dashboard can do range queries without a TEXT parse), a level/message
|
|
15
|
+
// pair the dashboard already keys on, and a `meta` JSON blob for any
|
|
16
|
+
// extra fields the caller passed.
|
|
17
|
+
//
|
|
18
|
+
// No backfill — engine/log.json is ephemeral and the next tick will
|
|
19
|
+
// start populating SQL fresh. The legacy file stays on disk as a
|
|
20
|
+
// dual-write mirror (test fixtures + getEngineLog fallback) until
|
|
21
|
+
// Phase 4.5 migrates the readers.
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
version: 5,
|
|
25
|
+
description: 'logs: append-only table for engine log entries',
|
|
26
|
+
up(db) {
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE TABLE logs (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
ts_ms INTEGER NOT NULL,
|
|
31
|
+
level TEXT NOT NULL,
|
|
32
|
+
message TEXT NOT NULL,
|
|
33
|
+
meta TEXT
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX idx_logs_ts ON logs(ts_ms DESC);
|
|
36
|
+
CREATE INDEX idx_logs_level_ts ON logs(level, ts_ms DESC);
|
|
37
|
+
`);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// engine/db/migrations/006-metrics.js
|
|
2
|
+
//
|
|
3
|
+
// Phase 5: move engine/metrics.json into a `metrics` table.
|
|
4
|
+
//
|
|
5
|
+
// metrics.json's shape is heterogeneous: a flat object whose top-level
|
|
6
|
+
// keys mix per-agent counters (regular agent ids) with reserved keys
|
|
7
|
+
// (`_engine`, `_daily`, `_contextPressure`). The SQL projection uses a
|
|
8
|
+
// single table with a (kind, key) primary key so each top-level entry
|
|
9
|
+
// becomes one row:
|
|
10
|
+
//
|
|
11
|
+
// kind | key | data (JSON blob)
|
|
12
|
+
// --------------------+--------------------+-----------------------------
|
|
13
|
+
// agent | dallas | { tasksCompleted, ... }
|
|
14
|
+
// agent | ripley | { ... }
|
|
15
|
+
// engine_category | command-center | { calls, costUsd, ... }
|
|
16
|
+
// engine_category | doc-chat | { ... }
|
|
17
|
+
// daily | 2026-05-28 | { costUsd, tasks, ... }
|
|
18
|
+
// context_pressure | current | { ... }
|
|
19
|
+
//
|
|
20
|
+
// Engineless extensions land transparently in `data` — no schema bump
|
|
21
|
+
// required to add a new counter field per category. New top-level kinds
|
|
22
|
+
// (e.g. `pipeline`) only need a new sentinel in the reserved-key list
|
|
23
|
+
// inside the store; the table schema is forward-compatible.
|
|
24
|
+
//
|
|
25
|
+
// Backfill: read metrics.json once, split by reserved-key sentinel,
|
|
26
|
+
// INSERT one row per entry. The JSON file stays on disk as a dual-write
|
|
27
|
+
// mirror so legacy direct-readers (none in engine, a few in tests) keep
|
|
28
|
+
// working.
|
|
29
|
+
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
|
|
33
|
+
function _resolveMinionsDir() {
|
|
34
|
+
const envHome = process.env.MINIONS_HOME || process.env.MINIONS_TEST_DIR;
|
|
35
|
+
if (envHome) return envHome;
|
|
36
|
+
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RESERVED_KEYS = new Map([
|
|
40
|
+
['_engine', 'engine_category'],
|
|
41
|
+
['_daily', 'daily'],
|
|
42
|
+
['_contextPressure', 'context_pressure'],
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
version: 6,
|
|
47
|
+
description: 'metrics: schema + metrics.json backfill',
|
|
48
|
+
up(db) {
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE metrics (
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
key TEXT NOT NULL,
|
|
53
|
+
data TEXT NOT NULL,
|
|
54
|
+
updated_at INTEGER NOT NULL,
|
|
55
|
+
PRIMARY KEY (kind, key)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX idx_metrics_kind ON metrics(kind);
|
|
58
|
+
`);
|
|
59
|
+
|
|
60
|
+
const minionsDir = _resolveMinionsDir();
|
|
61
|
+
if (!minionsDir) return;
|
|
62
|
+
const metricsPath = path.join(minionsDir, 'engine', 'metrics.json');
|
|
63
|
+
if (!fs.existsSync(metricsPath)) return;
|
|
64
|
+
|
|
65
|
+
let raw;
|
|
66
|
+
try { raw = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); }
|
|
67
|
+
catch (e) {
|
|
68
|
+
throw new Error(`engine/db/006-metrics: cannot parse metrics.json: ${e.message}`);
|
|
69
|
+
}
|
|
70
|
+
if (!raw || typeof raw !== 'object') return;
|
|
71
|
+
|
|
72
|
+
const insert = db.prepare(`
|
|
73
|
+
INSERT INTO metrics (kind, key, data, updated_at)
|
|
74
|
+
VALUES (?, ?, ?, ?)
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
let inserted = 0;
|
|
79
|
+
|
|
80
|
+
for (const [topKey, topValue] of Object.entries(raw)) {
|
|
81
|
+
const reservedKind = RESERVED_KEYS.get(topKey);
|
|
82
|
+
if (reservedKind) {
|
|
83
|
+
if (reservedKind === 'context_pressure') {
|
|
84
|
+
// Single-blob reserved key — store as one row keyed 'current'.
|
|
85
|
+
insert.run('context_pressure', 'current', JSON.stringify(topValue), now);
|
|
86
|
+
inserted += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Nested reserved key (_engine / _daily) — one row per inner key.
|
|
90
|
+
if (topValue && typeof topValue === 'object') {
|
|
91
|
+
for (const [innerKey, innerValue] of Object.entries(topValue)) {
|
|
92
|
+
insert.run(reservedKind, innerKey, JSON.stringify(innerValue), now);
|
|
93
|
+
inserted += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Regular agent counter row.
|
|
99
|
+
insert.run('agent', topKey, JSON.stringify(topValue), now);
|
|
100
|
+
inserted += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.log(`[db-migrate] v6: backfilled ${inserted} metrics rows; metrics.json kept as dual-write mirror`);
|
|
105
|
+
},
|
|
106
|
+
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -1788,12 +1788,12 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1788
1788
|
// Track reviewer for metrics purposes (separate file, separate lock)
|
|
1789
1789
|
const authorAgentId = (reviewPr.agent || '').toLowerCase();
|
|
1790
1790
|
if (authorAgentId && config.agents?.[authorAgentId]) {
|
|
1791
|
-
shared.
|
|
1791
|
+
shared.mutateMetrics((metrics) => {
|
|
1792
1792
|
if (!metrics[authorAgentId]) metrics[authorAgentId] = { ...DEFAULT_AGENT_METRICS };
|
|
1793
1793
|
if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
|
|
1794
1794
|
metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
|
|
1795
1795
|
return metrics;
|
|
1796
|
-
}
|
|
1796
|
+
});
|
|
1797
1797
|
}
|
|
1798
1798
|
|
|
1799
1799
|
log('info', `Updated ${reviewPr.id} → minions review: ${postReviewStatus || 'waiting'} by ${reviewerName}`);
|
|
@@ -2559,8 +2559,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
2559
2559
|
|
|
2560
2560
|
const agentId = (pr.agent || '').toLowerCase();
|
|
2561
2561
|
if (agentId && config.agents?.[agentId]) {
|
|
2562
|
-
|
|
2563
|
-
mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
2562
|
+
shared.mutateMetrics((metrics) => {
|
|
2564
2563
|
if (!metrics[agentId]) metrics[agentId] = { ...DEFAULT_AGENT_METRICS };
|
|
2565
2564
|
metrics[agentId].prsMerged = (metrics[agentId].prsMerged || 0) + 1;
|
|
2566
2565
|
return metrics;
|
package/engine/llm.js
CHANGED
|
@@ -103,8 +103,8 @@ function flushMetricsBuffer() {
|
|
|
103
103
|
_pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
|
|
104
104
|
|
|
105
105
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
106
|
+
const { mutateMetrics } = require('./shared');
|
|
107
|
+
mutateMetrics((metrics) => {
|
|
108
108
|
if (!metrics._engine) metrics._engine = {};
|
|
109
109
|
for (const [category, delta] of Object.entries(pending.engine)) {
|
|
110
110
|
if (!metrics._engine[category]) {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// engine/logs-store.js — SQL-backed implementation of engine/log.json.
|
|
2
|
+
//
|
|
3
|
+
// Logs differ from work_items / pull_requests / dispatches: they are
|
|
4
|
+
// pure append-only with a tail-cap, so the store exposes a different
|
|
5
|
+
// shape:
|
|
6
|
+
//
|
|
7
|
+
// appendLogsToDbFile(dbFilePath, entries)
|
|
8
|
+
// → bulk INSERT into the `logs` table at the given state.db path,
|
|
9
|
+
// then trim the head if the table exceeded the cap. Routes per
|
|
10
|
+
// dbFilePath so test isolation (each MINIONS_TEST_DIR has its own
|
|
11
|
+
// state.db) keeps working — the legacy `_logPath` capture-at-write
|
|
12
|
+
// pattern was the only correct way to keep test logs out of
|
|
13
|
+
// production storage, and we preserve the same idea here.
|
|
14
|
+
//
|
|
15
|
+
// readRecentLogs(n, { level? })
|
|
16
|
+
// → SELECT ... ORDER BY id DESC LIMIT n against the singleton DB.
|
|
17
|
+
// Honors MINIONS_TEST_DIR / MINIONS_HOME automatically through
|
|
18
|
+
// engine/db.getDb().
|
|
19
|
+
//
|
|
20
|
+
// All writes happen through the per-path appender; the singleton is only
|
|
21
|
+
// used for reads. This way a deferred buffer flush after a test ends
|
|
22
|
+
// can't accidentally drop entries into the production state.db.
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
// Per-dbPath connection cache. Avoids close+reopen thrash on the
|
|
28
|
+
// singleton when buffered entries are routed to multiple test state.db
|
|
29
|
+
// files in quick succession. Connections are closed via
|
|
30
|
+
// closeAllLogWriterConnections() at process shutdown.
|
|
31
|
+
const _connCache = new Map();
|
|
32
|
+
|
|
33
|
+
function _ensureDir(filePath) {
|
|
34
|
+
try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch { /* exists */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _openWriter(dbFilePath) {
|
|
38
|
+
let conn = _connCache.get(dbFilePath);
|
|
39
|
+
if (conn) return conn;
|
|
40
|
+
_ensureDir(dbFilePath);
|
|
41
|
+
// Use the same migration runner / pragma setup as the singleton, but
|
|
42
|
+
// against an explicit path. Re-running runMigrations against an
|
|
43
|
+
// already-migrated DB is a no-op (the version row gates it), so this
|
|
44
|
+
// is safe regardless of whether the singleton has touched the file.
|
|
45
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
46
|
+
const db = new DatabaseSync(dbFilePath);
|
|
47
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
48
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
49
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
50
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
51
|
+
const { runMigrations } = require('./db/migrate');
|
|
52
|
+
runMigrations(db);
|
|
53
|
+
conn = {
|
|
54
|
+
db,
|
|
55
|
+
insertStmt: db.prepare('INSERT INTO logs (ts_ms, level, message, meta) VALUES (?, ?, ?, ?)'),
|
|
56
|
+
countStmt: db.prepare('SELECT COUNT(*) AS n FROM logs'),
|
|
57
|
+
};
|
|
58
|
+
_connCache.set(dbFilePath, conn);
|
|
59
|
+
return conn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _toMs(v) {
|
|
63
|
+
if (v == null) return Date.now();
|
|
64
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : Date.now();
|
|
65
|
+
const parsed = Date.parse(v);
|
|
66
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Strip the routing-only fields (timestamp, level, message, _logPath,
|
|
70
|
+
// _dbPath) so `meta` only contains caller-provided extras.
|
|
71
|
+
const _BUILTIN_KEYS = new Set(['timestamp', 'level', 'message', '_logPath', '_dbPath']);
|
|
72
|
+
function _extractMeta(entry) {
|
|
73
|
+
const meta = {};
|
|
74
|
+
for (const k of Object.keys(entry)) {
|
|
75
|
+
if (_BUILTIN_KEYS.has(k)) continue;
|
|
76
|
+
meta[k] = entry[k];
|
|
77
|
+
}
|
|
78
|
+
return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Append a batch of log entries to a specific state.db file.
|
|
82
|
+
//
|
|
83
|
+
// Bulk-wrapped in a transaction so a partial failure rolls back cleanly
|
|
84
|
+
// and disk fsync only fires once per batch (matters at the 50-entry
|
|
85
|
+
// buffer threshold under high load).
|
|
86
|
+
//
|
|
87
|
+
// After insert, if the table exceeds the ring-buffer cap, trim the
|
|
88
|
+
// oldest entries down to logCapTrimTo. Trim is bounded — only runs when
|
|
89
|
+
// we've actually overflowed, not on every batch.
|
|
90
|
+
function appendLogsToDbFile(dbFilePath, entries) {
|
|
91
|
+
if (!entries || entries.length === 0) return 0;
|
|
92
|
+
let conn;
|
|
93
|
+
try { conn = _openWriter(dbFilePath); }
|
|
94
|
+
catch (e) {
|
|
95
|
+
// Logging must never crash the caller — swallow and bail.
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const shared = require('./shared');
|
|
100
|
+
const cap = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapEntries) || 2500;
|
|
101
|
+
const trimTo = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.logCapTrimTo) || 2000;
|
|
102
|
+
|
|
103
|
+
let inserted = 0;
|
|
104
|
+
try {
|
|
105
|
+
conn.db.exec('BEGIN IMMEDIATE');
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const tsMs = _toMs(entry.timestamp);
|
|
108
|
+
const level = String(entry.level || 'info');
|
|
109
|
+
const message = String(entry.message != null ? entry.message : '');
|
|
110
|
+
const meta = _extractMeta(entry);
|
|
111
|
+
conn.insertStmt.run(tsMs, level, message, meta);
|
|
112
|
+
inserted += 1;
|
|
113
|
+
}
|
|
114
|
+
const total = conn.countStmt.get().n;
|
|
115
|
+
if (total > cap) {
|
|
116
|
+
// Trim the oldest rows. `id` is monotonic via AUTOINCREMENT, so
|
|
117
|
+
// ORDER BY id ASC + LIMIT (total - trimTo) deletes exactly the
|
|
118
|
+
// overflow. Single DELETE; no rowid scan required.
|
|
119
|
+
const toRemove = total - trimTo;
|
|
120
|
+
conn.db.prepare(`
|
|
121
|
+
DELETE FROM logs
|
|
122
|
+
WHERE id IN (SELECT id FROM logs ORDER BY id ASC LIMIT ?)
|
|
123
|
+
`).run(toRemove);
|
|
124
|
+
}
|
|
125
|
+
conn.db.exec('COMMIT');
|
|
126
|
+
} catch {
|
|
127
|
+
try { conn.db.exec('ROLLBACK'); } catch { /* best-effort */ }
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Emit a single events row so dashboards / cache invalidators wake up
|
|
132
|
+
// once per batch instead of per entry. Routed against the singleton
|
|
133
|
+
// (not per-dbPath) because events are a cross-cutting concern keyed
|
|
134
|
+
// off the engine's active state.db.
|
|
135
|
+
try { require('./db-events').emitStateEvent('logs', { batch: inserted }); } catch { /* optional */ }
|
|
136
|
+
return inserted;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read the most recent N entries from the singleton DB (test-aware
|
|
140
|
+
// because getDb() re-resolves MINIONS_TEST_DIR on every call).
|
|
141
|
+
//
|
|
142
|
+
// Returns entries in NEW-EST-FIRST order — matches what the dashboard
|
|
143
|
+
// expects when slicing the tail of the array. Optional `level` filter
|
|
144
|
+
// uses the level index.
|
|
145
|
+
function readRecentLogs(n, opts) {
|
|
146
|
+
const limit = Math.max(1, Math.min(10000, Number(n) || 50));
|
|
147
|
+
const level = opts && opts.level ? String(opts.level) : null;
|
|
148
|
+
const { getDb } = require('./db');
|
|
149
|
+
let db;
|
|
150
|
+
try { db = getDb(); }
|
|
151
|
+
catch { return []; }
|
|
152
|
+
const rows = level
|
|
153
|
+
? db.prepare('SELECT ts_ms, level, message, meta FROM logs WHERE level = ? ORDER BY id DESC LIMIT ?').all(level, limit)
|
|
154
|
+
: db.prepare('SELECT ts_ms, level, message, meta FROM logs ORDER BY id DESC LIMIT ?').all(limit);
|
|
155
|
+
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
const entry = {
|
|
159
|
+
timestamp: new Date(row.ts_ms).toISOString(),
|
|
160
|
+
level: row.level,
|
|
161
|
+
message: row.message,
|
|
162
|
+
};
|
|
163
|
+
if (row.meta) {
|
|
164
|
+
try { Object.assign(entry, JSON.parse(row.meta)); } catch { /* skip malformed meta */ }
|
|
165
|
+
}
|
|
166
|
+
out.push(entry);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Convenience: same shape as the legacy `JSON.parse(log.json).slice(-N)`
|
|
172
|
+
// — chronological order (oldest first). Inverts the readRecentLogs DESC
|
|
173
|
+
// output so existing callers can drop in without re-sorting.
|
|
174
|
+
function readRecentLogsChronological(n, opts) {
|
|
175
|
+
return readRecentLogs(n, opts).reverse();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function closeAllLogWriterConnections() {
|
|
179
|
+
for (const [, conn] of _connCache) {
|
|
180
|
+
try { conn.db.close(); } catch { /* already closed */ }
|
|
181
|
+
}
|
|
182
|
+
_connCache.clear();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
appendLogsToDbFile,
|
|
187
|
+
readRecentLogs,
|
|
188
|
+
readRecentLogsChronological,
|
|
189
|
+
closeAllLogWriterConnections,
|
|
190
|
+
};
|
|
Binary file
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// engine/pull-requests-store.js — SQL-backed implementation of the
|
|
2
|
+
// per-file pull-requests array. Same external contract as the legacy
|
|
3
|
+
// JSON-file reader, modeled directly on work-items-store.js.
|
|
4
|
+
//
|
|
5
|
+
// readPullRequestsForScope(scope) -> [pr, pr, ...]
|
|
6
|
+
// applyPullRequestsMutation(scope, fn) -> diff-then-apply,
|
|
7
|
+
// returns { wrote, result }
|
|
8
|
+
//
|
|
9
|
+
// `scope` is 'central' for the top-level pull-requests.json or a project
|
|
10
|
+
// name for projects/<name>/pull-requests.json.
|
|
11
|
+
//
|
|
12
|
+
// External-edit detection mirrors work-items-store: per-scope last-mirror
|
|
13
|
+
// mtime is recorded after every write; if the JSON file's mtime advances
|
|
14
|
+
// outside our knowledge (test re-seed, operator hand-edit), we treat the
|
|
15
|
+
// JSON as canonical and rebuild SQL before the next read or mutation.
|
|
16
|
+
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
function _toMs(v) {
|
|
21
|
+
if (v == null) return null;
|
|
22
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
23
|
+
const parsed = Date.parse(v);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scopeForFilePath(filePath) {
|
|
28
|
+
const norm = String(filePath).replace(/\\/g, '/');
|
|
29
|
+
const m = norm.match(/projects\/([^/]+)\/pull-requests\.json$/);
|
|
30
|
+
if (m) return m[1];
|
|
31
|
+
return 'central';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _filePathForScope(scope) {
|
|
35
|
+
const shared = require('./shared');
|
|
36
|
+
if (scope === 'central') {
|
|
37
|
+
return path.join(shared.MINIONS_DIR, 'pull-requests.json');
|
|
38
|
+
}
|
|
39
|
+
return path.join(shared.MINIONS_DIR, 'projects', scope, 'pull-requests.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _parseRow(row) {
|
|
43
|
+
if (!row || !row.data) return null;
|
|
44
|
+
try { return JSON.parse(row.data); }
|
|
45
|
+
catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _readJsonArrayFallback(scope) {
|
|
49
|
+
const fp = _filePathForScope(scope);
|
|
50
|
+
let raw;
|
|
51
|
+
try { raw = fs.readFileSync(fp, 'utf8'); }
|
|
52
|
+
catch { return []; }
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.warn(`[pull-requests-store] corrupt JSON in ${fp}: ${e.message}`);
|
|
60
|
+
} catch { /* console may be wrapped in tests */ }
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Track (mtime, size) per scope so back-to-back writes inside the same
|
|
66
|
+
// ms tick still get detected as external edits (mtime-only checks miss
|
|
67
|
+
// them on Windows because NTFS reports ms-rounded mtimeMs).
|
|
68
|
+
const _lastMirrorByScope = new Map();
|
|
69
|
+
|
|
70
|
+
function _statFingerprint(filePath) {
|
|
71
|
+
try {
|
|
72
|
+
const st = fs.statSync(filePath);
|
|
73
|
+
return { mtime: st.mtimeMs, size: st.size };
|
|
74
|
+
}
|
|
75
|
+
catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _hydrateScopeFromJson(db, scope) {
|
|
79
|
+
const jsonItems = _readJsonArrayFallback(scope);
|
|
80
|
+
db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
81
|
+
if (jsonItems.length === 0) return;
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const ins = db.prepare(`
|
|
84
|
+
INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
86
|
+
ON CONFLICT(scope, id) DO NOTHING
|
|
87
|
+
`);
|
|
88
|
+
for (const pr of jsonItems) {
|
|
89
|
+
if (!pr || !pr.id) continue;
|
|
90
|
+
ins.run(
|
|
91
|
+
String(pr.id),
|
|
92
|
+
scope,
|
|
93
|
+
String(pr.status || 'active'),
|
|
94
|
+
pr.reviewStatus || null,
|
|
95
|
+
pr.agent || null,
|
|
96
|
+
pr.branch || null,
|
|
97
|
+
Number.isFinite(pr.prNumber) ? pr.prNumber : null,
|
|
98
|
+
_toMs(pr.created),
|
|
99
|
+
_toMs(pr.lastPolledAt || pr.lastPolled),
|
|
100
|
+
JSON.stringify(pr),
|
|
101
|
+
now,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
107
|
+
const jsonPath = _filePathForScope(scope);
|
|
108
|
+
const current = _statFingerprint(jsonPath);
|
|
109
|
+
const lastMirror = _lastMirrorByScope.get(scope);
|
|
110
|
+
if (current == null) return;
|
|
111
|
+
// In-sync iff BOTH mtime AND size match. Size catches same-ms-tick
|
|
112
|
+
// external writes that mtime alone misses. Resync is idempotent.
|
|
113
|
+
if (lastMirror != null
|
|
114
|
+
&& current.mtime === lastMirror.mtime
|
|
115
|
+
&& current.size === lastMirror.size) return;
|
|
116
|
+
if (lastMirror == null) {
|
|
117
|
+
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
118
|
+
if (sqlHas) {
|
|
119
|
+
_lastMirrorByScope.set(scope, current);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
_hydrateScopeFromJson(db, scope);
|
|
124
|
+
_lastMirrorByScope.set(scope, current);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function readPullRequestsForScope(scope) {
|
|
128
|
+
const { getDb } = require('./db');
|
|
129
|
+
let db;
|
|
130
|
+
try { db = getDb(); }
|
|
131
|
+
catch { return _readJsonArrayFallback(scope); }
|
|
132
|
+
|
|
133
|
+
_resyncScopeIfJsonDiverged(db, scope);
|
|
134
|
+
|
|
135
|
+
const rows = db.prepare(`
|
|
136
|
+
SELECT data FROM pull_requests
|
|
137
|
+
WHERE scope = ?
|
|
138
|
+
ORDER BY rowid
|
|
139
|
+
`).all(scope);
|
|
140
|
+
|
|
141
|
+
if (rows.length === 0) {
|
|
142
|
+
const fallback = _readJsonArrayFallback(scope);
|
|
143
|
+
if (fallback.length > 0) return fallback;
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const pr = _parseRow(row);
|
|
150
|
+
if (pr) out.push(pr);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readAllPullRequests() {
|
|
156
|
+
const { getDb } = require('./db');
|
|
157
|
+
let db;
|
|
158
|
+
try { db = getDb(); }
|
|
159
|
+
catch { return null; }
|
|
160
|
+
|
|
161
|
+
try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
|
|
162
|
+
const knownScopes = db.prepare('SELECT DISTINCT scope FROM pull_requests').all().map(r => r.scope);
|
|
163
|
+
for (const s of knownScopes) {
|
|
164
|
+
if (s === 'central') continue;
|
|
165
|
+
try { _resyncScopeIfJsonDiverged(db, s); } catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const rows = db.prepare('SELECT data, scope FROM pull_requests ORDER BY rowid').all();
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
const pr = _parseRow(row);
|
|
172
|
+
if (!pr) continue;
|
|
173
|
+
pr._scope = row.scope;
|
|
174
|
+
out.push(pr);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _indexById(arr) {
|
|
180
|
+
const out = new Map();
|
|
181
|
+
for (const pr of arr) {
|
|
182
|
+
if (!pr || !pr.id) continue;
|
|
183
|
+
out.set(String(pr.id), pr);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _computePullRequestsDiff(scope, before, after) {
|
|
189
|
+
const beforeMap = _indexById(before);
|
|
190
|
+
const afterMap = _indexById(after);
|
|
191
|
+
const toUpsert = [];
|
|
192
|
+
const toDelete = [];
|
|
193
|
+
for (const [id, pr] of afterMap) {
|
|
194
|
+
const prev = beforeMap.get(id);
|
|
195
|
+
if (!prev) { toUpsert.push(pr); continue; }
|
|
196
|
+
if (JSON.stringify(prev) !== JSON.stringify(pr)) toUpsert.push(pr);
|
|
197
|
+
}
|
|
198
|
+
for (const [id] of beforeMap) {
|
|
199
|
+
if (!afterMap.has(id)) toDelete.push(id);
|
|
200
|
+
}
|
|
201
|
+
return { scope, toUpsert, toDelete };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _applyPullRequestsDiff(db, diff) {
|
|
205
|
+
if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const upsertStmt = db.prepare(`
|
|
208
|
+
INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
|
|
209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
210
|
+
ON CONFLICT(scope, id) DO UPDATE SET
|
|
211
|
+
status = excluded.status,
|
|
212
|
+
review_status = excluded.review_status,
|
|
213
|
+
agent = excluded.agent,
|
|
214
|
+
branch = excluded.branch,
|
|
215
|
+
pr_number = excluded.pr_number,
|
|
216
|
+
created_at = excluded.created_at,
|
|
217
|
+
last_polled_at = excluded.last_polled_at,
|
|
218
|
+
data = excluded.data,
|
|
219
|
+
updated_at = excluded.updated_at
|
|
220
|
+
`);
|
|
221
|
+
const deleteStmt = db.prepare('DELETE FROM pull_requests WHERE scope = ? AND id = ?');
|
|
222
|
+
|
|
223
|
+
for (const pr of diff.toUpsert) {
|
|
224
|
+
upsertStmt.run(
|
|
225
|
+
String(pr.id),
|
|
226
|
+
diff.scope,
|
|
227
|
+
String(pr.status || 'active'),
|
|
228
|
+
pr.reviewStatus || null,
|
|
229
|
+
pr.agent || null,
|
|
230
|
+
pr.branch || null,
|
|
231
|
+
Number.isFinite(pr.prNumber) ? pr.prNumber : null,
|
|
232
|
+
_toMs(pr.created),
|
|
233
|
+
_toMs(pr.lastPolledAt || pr.lastPolled),
|
|
234
|
+
JSON.stringify(pr),
|
|
235
|
+
now,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
for (const id of diff.toDelete) {
|
|
239
|
+
deleteStmt.run(diff.scope, id);
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function applyPullRequestsMutation(scope, mutator) {
|
|
245
|
+
const { getDb, withTransaction } = require('./db');
|
|
246
|
+
let db;
|
|
247
|
+
try { db = getDb(); }
|
|
248
|
+
catch (e) {
|
|
249
|
+
throw new Error(`engine/pull-requests-store: SQLite unavailable (${e.message}); cannot mutate pull_requests`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return withTransaction(db, () => {
|
|
253
|
+
_resyncScopeIfJsonDiverged(db, scope);
|
|
254
|
+
const before = readPullRequestsForScope(scope);
|
|
255
|
+
// Strip the _scope decoration that readAllPullRequests adds, in case
|
|
256
|
+
// a caller round-trips records from there back through here.
|
|
257
|
+
for (const pr of before) { if (pr && pr._scope) delete pr._scope; }
|
|
258
|
+
const beforeSnapshot = JSON.parse(JSON.stringify(before));
|
|
259
|
+
const next = mutator(before);
|
|
260
|
+
const after = (next === undefined || next === null)
|
|
261
|
+
? before
|
|
262
|
+
: (Array.isArray(next) ? next : before);
|
|
263
|
+
const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
|
|
264
|
+
const wrote = _applyPullRequestsDiff(db, diff);
|
|
265
|
+
return { wrote, result: after };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function _mirrorJsonFromSql(scope, filePath) {
|
|
270
|
+
try {
|
|
271
|
+
const shared = require('./shared');
|
|
272
|
+
const items = readPullRequestsForScope(scope);
|
|
273
|
+
for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
|
|
274
|
+
const target = filePath || _filePathForScope(scope);
|
|
275
|
+
shared.safeWrite(target, items);
|
|
276
|
+
const fp = _statFingerprint(target);
|
|
277
|
+
if (fp != null) _lastMirrorByScope.set(scope, fp);
|
|
278
|
+
} catch {
|
|
279
|
+
// Mirror failures are non-fatal — SQL has already committed.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _resetPullRequestsTableForTest() {
|
|
284
|
+
const { getDb } = require('./db');
|
|
285
|
+
try { getDb().exec('DELETE FROM pull_requests'); } catch { /* not initialized */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
scopeForFilePath,
|
|
290
|
+
readPullRequestsForScope,
|
|
291
|
+
readAllPullRequests,
|
|
292
|
+
applyPullRequestsMutation,
|
|
293
|
+
_filePathForScope,
|
|
294
|
+
_mirrorJsonFromSql,
|
|
295
|
+
_resetPullRequestsTableForTest,
|
|
296
|
+
};
|
package/engine/queries.js
CHANGED
|
@@ -327,8 +327,17 @@ function getNotesWithMeta() {
|
|
|
327
327
|
}
|
|
328
328
|
|
|
329
329
|
function getEngineLog() {
|
|
330
|
-
//
|
|
331
|
-
//
|
|
330
|
+
// Phase 4: prefer the SQL store. readRecentLogsChronological returns
|
|
331
|
+
// entries oldest-first (same shape as the legacy `.slice(-50)`), and is
|
|
332
|
+
// test-aware via engine/db.getDb() which re-resolves MINIONS_TEST_DIR.
|
|
333
|
+
// Falls back to the JSON mirror on SQL failure or when the table is
|
|
334
|
+
// empty (fresh install / pre-migration).
|
|
335
|
+
try {
|
|
336
|
+
const store = require('./logs-store');
|
|
337
|
+
const sqlEntries = store.readRecentLogsChronological(50);
|
|
338
|
+
if (Array.isArray(sqlEntries) && sqlEntries.length > 0) return sqlEntries;
|
|
339
|
+
} catch { /* fall through to JSON */ }
|
|
340
|
+
|
|
332
341
|
const logJson = safeRead(shared.currentLogPath());
|
|
333
342
|
if (!logJson) return [];
|
|
334
343
|
try {
|
|
@@ -339,7 +348,16 @@ function getEngineLog() {
|
|
|
339
348
|
}
|
|
340
349
|
|
|
341
350
|
function getMetrics() {
|
|
342
|
-
|
|
351
|
+
// Phase 5: prefer the SQL store. Falls back to the JSON mirror on
|
|
352
|
+
// SQLite failure or when the table is empty (fresh install /
|
|
353
|
+
// pre-migration). Returns the legacy flat object shape.
|
|
354
|
+
let metrics;
|
|
355
|
+
try {
|
|
356
|
+
const store = require('./metrics-store');
|
|
357
|
+
metrics = store.readMetrics() || {};
|
|
358
|
+
} catch {
|
|
359
|
+
metrics = readJsonNoRestore(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
360
|
+
}
|
|
343
361
|
|
|
344
362
|
for (const [agentId, m] of Object.entries(metrics)) {
|
|
345
363
|
if (agentId.startsWith('_')) continue;
|
|
@@ -651,45 +669,74 @@ function getPullRequests(config) {
|
|
|
651
669
|
const projectByName = new Map(projects.map(p => [p.name, p]));
|
|
652
670
|
const allPrs = [];
|
|
653
671
|
const seenIds = new Set();
|
|
654
|
-
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
let projectDirs = [];
|
|
672
|
+
|
|
673
|
+
// Phase 3: prefer the SQL store. Returns every scope's PRs tagged with
|
|
674
|
+
// `_scope`; we re-map to `_project` for backward compatibility with
|
|
675
|
+
// downstream consumers, intersecting with the configured project list
|
|
676
|
+
// to suppress orphan/removed projects (matches legacy behaviour).
|
|
677
|
+
let sqlPrs = null;
|
|
661
678
|
try {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
} catch { /*
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const prPath = projectPrPath(project);
|
|
669
|
-
const prs = readJsonNoRestore(prPath);
|
|
670
|
-
if (!Array.isArray(prs)) continue;
|
|
671
|
-
shared.normalizePrRecords(prs, project);
|
|
672
|
-
const base = project.prUrlBase || '';
|
|
673
|
-
for (const pr of prs) {
|
|
679
|
+
const store = require('./pull-requests-store');
|
|
680
|
+
sqlPrs = store.readAllPullRequests();
|
|
681
|
+
} catch { /* fall through to JSON */ }
|
|
682
|
+
|
|
683
|
+
if (Array.isArray(sqlPrs) && sqlPrs.length > 0) {
|
|
684
|
+
for (const pr of sqlPrs) {
|
|
674
685
|
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
686
|
+
const scope = pr._scope;
|
|
687
|
+
delete pr._scope;
|
|
688
|
+
if (scope === 'central') {
|
|
689
|
+
pr._project = 'central';
|
|
690
|
+
} else {
|
|
691
|
+
const project = projectByName.get(scope);
|
|
692
|
+
if (!project) continue; // orphan/removed project — don't surface
|
|
693
|
+
const base = project.prUrlBase || '';
|
|
694
|
+
if (!pr.url && base) {
|
|
695
|
+
const prNumber = shared.getPrNumber(pr);
|
|
696
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
697
|
+
}
|
|
698
|
+
shared.normalizePrRecords([pr], project);
|
|
699
|
+
pr._project = project.name || 'Project';
|
|
678
700
|
}
|
|
679
|
-
pr._project = project.name || 'Project';
|
|
680
701
|
allPrs.push(pr);
|
|
681
702
|
seenIds.add(pr.id);
|
|
682
703
|
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
704
|
+
} else {
|
|
705
|
+
// SQL empty or unavailable — fall back to per-file JSON read. Matches
|
|
706
|
+
// the original layout: per-project dirs first, central last.
|
|
707
|
+
let projectDirs = [];
|
|
708
|
+
try {
|
|
709
|
+
projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
|
|
710
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
|
|
711
|
+
} catch { /* projects dir missing */ }
|
|
712
|
+
for (const dirName of projectDirs) {
|
|
713
|
+
const project = projectByName.get(dirName);
|
|
714
|
+
if (!project) continue;
|
|
715
|
+
const prPath = projectPrPath(project);
|
|
716
|
+
const prs = readJsonNoRestore(prPath);
|
|
717
|
+
if (!Array.isArray(prs)) continue;
|
|
718
|
+
shared.normalizePrRecords(prs, project);
|
|
719
|
+
const base = project.prUrlBase || '';
|
|
720
|
+
for (const pr of prs) {
|
|
721
|
+
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
722
|
+
if (!pr.url && base) {
|
|
723
|
+
const prNumber = shared.getPrNumber(pr);
|
|
724
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
725
|
+
}
|
|
726
|
+
pr._project = project.name || 'Project';
|
|
727
|
+
allPrs.push(pr);
|
|
728
|
+
seenIds.add(pr.id);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
|
|
732
|
+
if (centralPrs) {
|
|
733
|
+
shared.normalizePrRecords(centralPrs, null);
|
|
734
|
+
for (const pr of centralPrs) {
|
|
735
|
+
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
736
|
+
pr._project = 'central';
|
|
737
|
+
allPrs.push(pr);
|
|
738
|
+
seenIds.add(pr.id);
|
|
739
|
+
}
|
|
693
740
|
}
|
|
694
741
|
}
|
|
695
742
|
allPrs.sort((a, b) => {
|
package/engine/shared.js
CHANGED
|
@@ -285,6 +285,17 @@ function log(level, msg, meta = {}) {
|
|
|
285
285
|
writable: true,
|
|
286
286
|
configurable: true,
|
|
287
287
|
});
|
|
288
|
+
// Capture the SQL state.db path too — same write-time semantics so a
|
|
289
|
+
// deferred buffer flush after the test ends still routes the entry to
|
|
290
|
+
// the test's state.db (not the production one). Path resolution mirrors
|
|
291
|
+
// engine/db.js but inlined to avoid the require cycle that loading the
|
|
292
|
+
// db module here would create on module init.
|
|
293
|
+
Object.defineProperty(entry, '_dbPath', {
|
|
294
|
+
value: _currentLogDbPath(),
|
|
295
|
+
enumerable: false,
|
|
296
|
+
writable: true,
|
|
297
|
+
configurable: true,
|
|
298
|
+
});
|
|
288
299
|
|
|
289
300
|
_logBuffer.push(entry);
|
|
290
301
|
|
|
@@ -337,6 +348,19 @@ function _currentLogPath() {
|
|
|
337
348
|
return path.join(root, 'engine', 'log.json');
|
|
338
349
|
}
|
|
339
350
|
|
|
351
|
+
// Resolve the SQL state.db path at write time — same isolation contract
|
|
352
|
+
// as _currentLogPath. MINIONS_LOG_PATH only redirects the JSON sidecar;
|
|
353
|
+
// the SQL store stays attached to MINIONS_TEST_DIR / MINIONS_HOME because
|
|
354
|
+
// it shares the file with the rest of the engine state (dispatches,
|
|
355
|
+
// work_items, pull_requests).
|
|
356
|
+
function _currentLogDbPath() {
|
|
357
|
+
if (process.env.MINIONS_TEST_DIR) {
|
|
358
|
+
return path.join(path.resolve(process.env.MINIONS_TEST_DIR), 'engine', 'state.db');
|
|
359
|
+
}
|
|
360
|
+
const root = process.env.MINIONS_HOME ? path.resolve(process.env.MINIONS_HOME) : path.resolve(__dirname, '..');
|
|
361
|
+
return path.join(root, 'engine', 'state.db');
|
|
362
|
+
}
|
|
363
|
+
|
|
340
364
|
// MINIONS_TEST_DIR-aware engine dir for cache files (runtime caps, models,
|
|
341
365
|
// spawn-debug.log). Returns `<MINIONS_TEST_DIR>/engine` under tests; otherwise
|
|
342
366
|
// the caller's source-adjacent fallback (typically `__dirname`-derived). Never
|
|
@@ -366,23 +390,44 @@ function openUrlInBrowser(url) {
|
|
|
366
390
|
function _flushLogBuffer() {
|
|
367
391
|
if (_logBuffer.length === 0) return;
|
|
368
392
|
const drained = _logBuffer.splice(0);
|
|
369
|
-
// Group entries by their captured _logPath so test-originated
|
|
370
|
-
// land in the test dir's
|
|
371
|
-
// by the time we flush. Entries without
|
|
372
|
-
// (eg. direct _logBuffer.push()
|
|
373
|
-
|
|
374
|
-
const
|
|
393
|
+
// Group entries by their captured (_logPath, _dbPath) so test-originated
|
|
394
|
+
// entries always land in the test dir's storage even if MINIONS_TEST_DIR
|
|
395
|
+
// has been cleared by the time we flush. Entries without captured paths
|
|
396
|
+
// fall back to the current resolved values (eg. direct _logBuffer.push()
|
|
397
|
+
// from tests).
|
|
398
|
+
const fallbackJsonPath = _currentLogPath();
|
|
399
|
+
const fallbackDbPath = _currentLogDbPath();
|
|
400
|
+
const byJsonPath = new Map();
|
|
401
|
+
const byDbPath = new Map();
|
|
375
402
|
for (const raw of drained) {
|
|
376
|
-
const
|
|
403
|
+
const jsonTarget = raw._logPath || fallbackJsonPath;
|
|
404
|
+
const dbTarget = raw._dbPath || fallbackDbPath;
|
|
377
405
|
// SEC-09 defense-in-depth: redact again at flush time so any direct
|
|
378
406
|
// `_logBuffer.push(entry)` callers (tests, future paths) can't leak secrets.
|
|
379
407
|
const entry = redactSecrets(raw);
|
|
380
408
|
// Strip the routing-only metadata before persisting.
|
|
381
409
|
delete entry._logPath;
|
|
382
|
-
|
|
383
|
-
|
|
410
|
+
delete entry._dbPath;
|
|
411
|
+
if (!byJsonPath.has(jsonTarget)) byJsonPath.set(jsonTarget, []);
|
|
412
|
+
byJsonPath.get(jsonTarget).push(entry);
|
|
413
|
+
if (!byDbPath.has(dbTarget)) byDbPath.set(dbTarget, []);
|
|
414
|
+
byDbPath.get(dbTarget).push(entry);
|
|
384
415
|
}
|
|
385
|
-
|
|
416
|
+
|
|
417
|
+
// SQL append (Phase 4 source of truth). Per-path append so test
|
|
418
|
+
// state.db files stay isolated; logging must never crash the caller, so
|
|
419
|
+
// appendLogsToDbFile already swallows its own errors.
|
|
420
|
+
try {
|
|
421
|
+
const store = require('./logs-store');
|
|
422
|
+
for (const [dbPath, entries] of byDbPath) {
|
|
423
|
+
store.appendLogsToDbFile(dbPath, entries);
|
|
424
|
+
}
|
|
425
|
+
} catch { /* logs-store unavailable (node<22.5) — JSON mirror still works */ }
|
|
426
|
+
|
|
427
|
+
// JSON mirror — kept for backward compat with test fixtures that
|
|
428
|
+
// safeJson(log.json) directly, and the legacy getEngineLog fallback.
|
|
429
|
+
// Phase 4.5 will retire this once readers move to readRecentLogs().
|
|
430
|
+
for (const [target, entries] of byJsonPath) {
|
|
386
431
|
try {
|
|
387
432
|
mutateJsonFileLocked(target, (logData) => {
|
|
388
433
|
if (!Array.isArray(logData)) logData = logData?.entries || [];
|
|
@@ -1806,6 +1851,8 @@ const ENGINE_DEFAULTS = {
|
|
|
1806
1851
|
versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
|
|
1807
1852
|
logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
|
|
1808
1853
|
logBufferSize: 50, // flush immediately when buffer exceeds this many entries
|
|
1854
|
+
logCapEntries: 2500, // ring-buffer cap on `logs` SQL table; trims to logCapEntries-logCapTrimTo on overflow
|
|
1855
|
+
logCapTrimTo: 2000, // size to trim back to when cap is hit (matches legacy JSON splice 2500→2000)
|
|
1809
1856
|
lockRetries: 0, // no retries — single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
|
|
1810
1857
|
lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
|
|
1811
1858
|
buildFixGracePeriod: 600000, // 10min — wait for CI to run after a verified build-fix push before re-dispatching
|
|
@@ -2643,13 +2690,52 @@ const WATCH_ACTION_TYPE = {
|
|
|
2643
2690
|
RESUME_PLAN: 'resume-plan',
|
|
2644
2691
|
};
|
|
2645
2692
|
|
|
2693
|
+
/**
|
|
2694
|
+
* Route a metrics mutation through the SQL store with a JSON dual-write
|
|
2695
|
+
* mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
|
|
2696
|
+
* receives the full legacy-shape metrics object, mutates in place or
|
|
2697
|
+
* returns a replacement, and the store diffs by (kind, key) row.
|
|
2698
|
+
*
|
|
2699
|
+
* Falls back to the legacy mutateJsonFileLocked path on SQLite failure
|
|
2700
|
+
* so a node:sqlite-broken install keeps recording metrics.
|
|
2701
|
+
*/
|
|
2702
|
+
function mutateMetrics(mutator) {
|
|
2703
|
+
const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
|
|
2704
|
+
try {
|
|
2705
|
+
const store = require('./metrics-store');
|
|
2706
|
+
const { wrote, result } = store.applyMetricsMutation((m) => {
|
|
2707
|
+
if (!m || typeof m !== 'object') m = {};
|
|
2708
|
+
return mutator(m) || m;
|
|
2709
|
+
});
|
|
2710
|
+
if (wrote) {
|
|
2711
|
+
try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
|
|
2712
|
+
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
2713
|
+
}
|
|
2714
|
+
return result;
|
|
2715
|
+
} catch (e) {
|
|
2716
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
2717
|
+
throw e;
|
|
2718
|
+
}
|
|
2719
|
+
// SQLite unavailable — fall through to legacy JSON path.
|
|
2720
|
+
}
|
|
2721
|
+
return mutateJsonFileLocked(metricsPath, (metrics) => {
|
|
2722
|
+
if (!metrics || typeof metrics !== 'object') metrics = {};
|
|
2723
|
+
return mutator(metrics) || metrics;
|
|
2724
|
+
}, {
|
|
2725
|
+
defaultValue: {},
|
|
2726
|
+
onWrote: () => {
|
|
2727
|
+
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
2728
|
+
},
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2646
2732
|
/** Update per-agent review metrics (prsApproved/prsRejected). Only writes for configured agents. */
|
|
2647
2733
|
function trackReviewMetric(pr, newReviewStatus, config) {
|
|
2648
2734
|
if (newReviewStatus !== 'approved' && newReviewStatus !== 'changes-requested') return;
|
|
2649
2735
|
const authorId = (pr.agent || '').toLowerCase();
|
|
2650
2736
|
if (!authorId || !config?.agents?.[authorId]) return;
|
|
2651
2737
|
try {
|
|
2652
|
-
|
|
2738
|
+
mutateMetrics((metrics) => {
|
|
2653
2739
|
if (!metrics[authorId]) metrics[authorId] = { ...DEFAULT_AGENT_METRICS };
|
|
2654
2740
|
if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
|
|
2655
2741
|
else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
|
|
@@ -4643,6 +4729,35 @@ function reopenWorkItem(wi) {
|
|
|
4643
4729
|
* @param {Function} mutator - Receives the array, mutates in place or returns new value
|
|
4644
4730
|
*/
|
|
4645
4731
|
function mutatePullRequests(filePath, mutator) {
|
|
4732
|
+
// Phase 3 SQL path. Same shape as Phase 2's mutateWorkItems: route
|
|
4733
|
+
// through the pull-requests-store when filePath sits under MINIONS_DIR,
|
|
4734
|
+
// mirror back to JSON for legacy direct-readers, emit a pull_requests
|
|
4735
|
+
// event only on real writes. Ad-hoc tmp paths (legacy tests using
|
|
4736
|
+
// createTmpDir) and SQLite failures fall through to the JSON path.
|
|
4737
|
+
const fpNorm = String(filePath).replace(/\\/g, '/');
|
|
4738
|
+
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
4739
|
+
const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
|
|
4740
|
+
if (insideMinionsDir) {
|
|
4741
|
+
try {
|
|
4742
|
+
const store = require('./pull-requests-store');
|
|
4743
|
+
const scope = store.scopeForFilePath(filePath);
|
|
4744
|
+
const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
|
|
4745
|
+
if (!Array.isArray(prs)) prs = [];
|
|
4746
|
+
return mutator(prs) || prs;
|
|
4747
|
+
});
|
|
4748
|
+
if (wrote) {
|
|
4749
|
+
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror is best-effort */ }
|
|
4750
|
+
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
4751
|
+
}
|
|
4752
|
+
return result;
|
|
4753
|
+
} catch (e) {
|
|
4754
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
4755
|
+
throw e;
|
|
4756
|
+
}
|
|
4757
|
+
// Fall through to legacy JSON path on SQLite errors only.
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4646
4761
|
return mutateJsonFileLocked(filePath, (data) => {
|
|
4647
4762
|
if (!Array.isArray(data)) data = [];
|
|
4648
4763
|
return mutator(data) || data;
|
|
@@ -5051,7 +5166,7 @@ module.exports = {
|
|
|
5051
5166
|
runtimeConfigWarnings,
|
|
5052
5167
|
projectWorkSourceWarnings,
|
|
5053
5168
|
backfillProjectWorkSourceDefaults,
|
|
5054
|
-
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5169
|
+
WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, WORKTREE_REQUIRING_TYPES, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, PR_POLLABLE_STATUSES, PR_PENDING_REASON, DISPATCH_RESULT, mutateMetrics, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5055
5170
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
5056
5171
|
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
5057
5172
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
@@ -116,24 +116,28 @@ function readWorkItemsForScope(scope) {
|
|
|
116
116
|
// scope is bounded by the JSON's row count.
|
|
117
117
|
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
118
118
|
const jsonPath = _filePathForScope(scope);
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
119
|
+
const current = _statFingerprint(jsonPath);
|
|
120
|
+
const lastMirror = _lastMirrorByScope.get(scope);
|
|
121
|
+
if (current == null) return; // JSON absent → nothing to resync
|
|
122
|
+
// In-sync iff BOTH mtime AND size match. Either differing signals an
|
|
123
|
+
// external write — including the same-ms-tick safeWrite pattern that
|
|
124
|
+
// mtime-only checks miss on Windows (ms-rounded mtimeMs collides).
|
|
125
|
+
// Resync is idempotent (DELETE + bulk re-INSERT from JSON).
|
|
126
|
+
if (lastMirror != null
|
|
127
|
+
&& current.mtime === lastMirror.mtime
|
|
128
|
+
&& current.size === lastMirror.size) return;
|
|
123
129
|
// No mirror record but JSON exists: first-time hydrate iff SQL is empty
|
|
124
130
|
// for this scope (avoid trampling a freshly-backfilled migration state
|
|
125
131
|
// on the very first read).
|
|
126
|
-
if (
|
|
132
|
+
if (lastMirror == null) {
|
|
127
133
|
const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
|
|
128
134
|
if (sqlHas) {
|
|
129
|
-
|
|
130
|
-
// detected, but don't rebuild — migration already populated SQL.
|
|
131
|
-
_lastMirrorMtimeByScope.set(scope, currentMtime);
|
|
135
|
+
_lastMirrorByScope.set(scope, current);
|
|
132
136
|
return;
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
_hydrateScopeFromJson(db, scope);
|
|
136
|
-
|
|
140
|
+
_lastMirrorByScope.set(scope, current);
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
// Read all rows across all scopes — used by queries.getWorkItems which
|
|
@@ -239,17 +243,19 @@ function _applyWorkItemsDiff(db, diff) {
|
|
|
239
243
|
// wrote — true iff at least one INSERT/UPDATE/DELETE landed
|
|
240
244
|
// result — the post-mutation array (legacy return shape of
|
|
241
245
|
// mutateJsonFileLocked)
|
|
242
|
-
// In-process record of the JSON mirror
|
|
243
|
-
// scope.
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
const _lastMirrorMtimeByScope = new Map();
|
|
246
|
+
// In-process record of (mtime, size) of the JSON mirror we last wrote,
|
|
247
|
+
// keyed by scope. Two-field fingerprint catches the same-ms-tick write
|
|
248
|
+
// pattern that mtime-only checks miss on Windows: NTFS reports
|
|
249
|
+
// ms-rounded mtimeMs, so back-to-back writes inside the same tick look
|
|
250
|
+
// identical to "no change" by mtime alone. File size catches the rest
|
|
251
|
+
// — content swaps almost always change byte length.
|
|
252
|
+
const _lastMirrorByScope = new Map();
|
|
250
253
|
|
|
251
|
-
function
|
|
252
|
-
try {
|
|
254
|
+
function _statFingerprint(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
const st = fs.statSync(filePath);
|
|
257
|
+
return { mtime: st.mtimeMs, size: st.size };
|
|
258
|
+
}
|
|
253
259
|
catch { return null; }
|
|
254
260
|
}
|
|
255
261
|
|
|
@@ -325,10 +331,11 @@ function _mirrorJsonFromSql(scope, filePath) {
|
|
|
325
331
|
for (const wi of items) { if (wi && wi._source) delete wi._source; }
|
|
326
332
|
const target = filePath || _filePathForScope(scope);
|
|
327
333
|
shared.safeWrite(target, items);
|
|
328
|
-
// Record
|
|
329
|
-
// an external edit
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
// Record (mtime, size) we just wrote so the next mutation can detect
|
|
335
|
+
// an external edit. mtime alone misses same-ms-tick writes; size
|
|
336
|
+
// catches them.
|
|
337
|
+
const fp = _statFingerprint(target);
|
|
338
|
+
if (fp != null) _lastMirrorByScope.set(scope, fp);
|
|
332
339
|
} catch {
|
|
333
340
|
// Mirror failures are non-fatal: SQL has already committed.
|
|
334
341
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2070",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|