@yemi33/minions 0.1.2067 → 0.1.2069
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/logs-store.js +190 -0
- package/engine/pull-requests-store.js +286 -0
- package/engine/queries.js +73 -35
- package/engine/shared.js +86 -10
- 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,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
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
const _lastMirrorMtimeByScope = new Map();
|
|
66
|
+
|
|
67
|
+
function _statMtimeMs(filePath) {
|
|
68
|
+
try { return fs.statSync(filePath).mtimeMs; }
|
|
69
|
+
catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _hydrateScopeFromJson(db, scope) {
|
|
73
|
+
const jsonItems = _readJsonArrayFallback(scope);
|
|
74
|
+
db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
|
|
75
|
+
if (jsonItems.length === 0) return;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const ins = db.prepare(`
|
|
78
|
+
INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
ON CONFLICT(scope, id) DO NOTHING
|
|
81
|
+
`);
|
|
82
|
+
for (const pr of jsonItems) {
|
|
83
|
+
if (!pr || !pr.id) continue;
|
|
84
|
+
ins.run(
|
|
85
|
+
String(pr.id),
|
|
86
|
+
scope,
|
|
87
|
+
String(pr.status || 'active'),
|
|
88
|
+
pr.reviewStatus || null,
|
|
89
|
+
pr.agent || null,
|
|
90
|
+
pr.branch || null,
|
|
91
|
+
Number.isFinite(pr.prNumber) ? pr.prNumber : null,
|
|
92
|
+
_toMs(pr.created),
|
|
93
|
+
_toMs(pr.lastPolledAt || pr.lastPolled),
|
|
94
|
+
JSON.stringify(pr),
|
|
95
|
+
now,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _resyncScopeIfJsonDiverged(db, scope) {
|
|
101
|
+
const jsonPath = _filePathForScope(scope);
|
|
102
|
+
const currentMtime = _statMtimeMs(jsonPath);
|
|
103
|
+
const lastMirrorMtime = _lastMirrorMtimeByScope.get(scope);
|
|
104
|
+
if (currentMtime == null) return;
|
|
105
|
+
if (lastMirrorMtime != null && Math.abs(currentMtime - lastMirrorMtime) <= 1) return;
|
|
106
|
+
if (lastMirrorMtime == null) {
|
|
107
|
+
const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
|
|
108
|
+
if (sqlHas) {
|
|
109
|
+
_lastMirrorMtimeByScope.set(scope, currentMtime);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
_hydrateScopeFromJson(db, scope);
|
|
114
|
+
_lastMirrorMtimeByScope.set(scope, currentMtime);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readPullRequestsForScope(scope) {
|
|
118
|
+
const { getDb } = require('./db');
|
|
119
|
+
let db;
|
|
120
|
+
try { db = getDb(); }
|
|
121
|
+
catch { return _readJsonArrayFallback(scope); }
|
|
122
|
+
|
|
123
|
+
_resyncScopeIfJsonDiverged(db, scope);
|
|
124
|
+
|
|
125
|
+
const rows = db.prepare(`
|
|
126
|
+
SELECT data FROM pull_requests
|
|
127
|
+
WHERE scope = ?
|
|
128
|
+
ORDER BY rowid
|
|
129
|
+
`).all(scope);
|
|
130
|
+
|
|
131
|
+
if (rows.length === 0) {
|
|
132
|
+
const fallback = _readJsonArrayFallback(scope);
|
|
133
|
+
if (fallback.length > 0) return fallback;
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const pr = _parseRow(row);
|
|
140
|
+
if (pr) out.push(pr);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readAllPullRequests() {
|
|
146
|
+
const { getDb } = require('./db');
|
|
147
|
+
let db;
|
|
148
|
+
try { db = getDb(); }
|
|
149
|
+
catch { return null; }
|
|
150
|
+
|
|
151
|
+
try { _resyncScopeIfJsonDiverged(db, 'central'); } catch {}
|
|
152
|
+
const knownScopes = db.prepare('SELECT DISTINCT scope FROM pull_requests').all().map(r => r.scope);
|
|
153
|
+
for (const s of knownScopes) {
|
|
154
|
+
if (s === 'central') continue;
|
|
155
|
+
try { _resyncScopeIfJsonDiverged(db, s); } catch {}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const rows = db.prepare('SELECT data, scope FROM pull_requests ORDER BY rowid').all();
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const pr = _parseRow(row);
|
|
162
|
+
if (!pr) continue;
|
|
163
|
+
pr._scope = row.scope;
|
|
164
|
+
out.push(pr);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _indexById(arr) {
|
|
170
|
+
const out = new Map();
|
|
171
|
+
for (const pr of arr) {
|
|
172
|
+
if (!pr || !pr.id) continue;
|
|
173
|
+
out.set(String(pr.id), pr);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _computePullRequestsDiff(scope, before, after) {
|
|
179
|
+
const beforeMap = _indexById(before);
|
|
180
|
+
const afterMap = _indexById(after);
|
|
181
|
+
const toUpsert = [];
|
|
182
|
+
const toDelete = [];
|
|
183
|
+
for (const [id, pr] of afterMap) {
|
|
184
|
+
const prev = beforeMap.get(id);
|
|
185
|
+
if (!prev) { toUpsert.push(pr); continue; }
|
|
186
|
+
if (JSON.stringify(prev) !== JSON.stringify(pr)) toUpsert.push(pr);
|
|
187
|
+
}
|
|
188
|
+
for (const [id] of beforeMap) {
|
|
189
|
+
if (!afterMap.has(id)) toDelete.push(id);
|
|
190
|
+
}
|
|
191
|
+
return { scope, toUpsert, toDelete };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _applyPullRequestsDiff(db, diff) {
|
|
195
|
+
if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
|
|
196
|
+
const now = Date.now();
|
|
197
|
+
const upsertStmt = db.prepare(`
|
|
198
|
+
INSERT INTO pull_requests (id, scope, status, review_status, agent, branch, pr_number, created_at, last_polled_at, data, updated_at)
|
|
199
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
200
|
+
ON CONFLICT(scope, id) DO UPDATE SET
|
|
201
|
+
status = excluded.status,
|
|
202
|
+
review_status = excluded.review_status,
|
|
203
|
+
agent = excluded.agent,
|
|
204
|
+
branch = excluded.branch,
|
|
205
|
+
pr_number = excluded.pr_number,
|
|
206
|
+
created_at = excluded.created_at,
|
|
207
|
+
last_polled_at = excluded.last_polled_at,
|
|
208
|
+
data = excluded.data,
|
|
209
|
+
updated_at = excluded.updated_at
|
|
210
|
+
`);
|
|
211
|
+
const deleteStmt = db.prepare('DELETE FROM pull_requests WHERE scope = ? AND id = ?');
|
|
212
|
+
|
|
213
|
+
for (const pr of diff.toUpsert) {
|
|
214
|
+
upsertStmt.run(
|
|
215
|
+
String(pr.id),
|
|
216
|
+
diff.scope,
|
|
217
|
+
String(pr.status || 'active'),
|
|
218
|
+
pr.reviewStatus || null,
|
|
219
|
+
pr.agent || null,
|
|
220
|
+
pr.branch || null,
|
|
221
|
+
Number.isFinite(pr.prNumber) ? pr.prNumber : null,
|
|
222
|
+
_toMs(pr.created),
|
|
223
|
+
_toMs(pr.lastPolledAt || pr.lastPolled),
|
|
224
|
+
JSON.stringify(pr),
|
|
225
|
+
now,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
for (const id of diff.toDelete) {
|
|
229
|
+
deleteStmt.run(diff.scope, id);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function applyPullRequestsMutation(scope, mutator) {
|
|
235
|
+
const { getDb, withTransaction } = require('./db');
|
|
236
|
+
let db;
|
|
237
|
+
try { db = getDb(); }
|
|
238
|
+
catch (e) {
|
|
239
|
+
throw new Error(`engine/pull-requests-store: SQLite unavailable (${e.message}); cannot mutate pull_requests`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return withTransaction(db, () => {
|
|
243
|
+
_resyncScopeIfJsonDiverged(db, scope);
|
|
244
|
+
const before = readPullRequestsForScope(scope);
|
|
245
|
+
// Strip the _scope decoration that readAllPullRequests adds, in case
|
|
246
|
+
// a caller round-trips records from there back through here.
|
|
247
|
+
for (const pr of before) { if (pr && pr._scope) delete pr._scope; }
|
|
248
|
+
const beforeSnapshot = JSON.parse(JSON.stringify(before));
|
|
249
|
+
const next = mutator(before);
|
|
250
|
+
const after = (next === undefined || next === null)
|
|
251
|
+
? before
|
|
252
|
+
: (Array.isArray(next) ? next : before);
|
|
253
|
+
const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
|
|
254
|
+
const wrote = _applyPullRequestsDiff(db, diff);
|
|
255
|
+
return { wrote, result: after };
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _mirrorJsonFromSql(scope, filePath) {
|
|
260
|
+
try {
|
|
261
|
+
const shared = require('./shared');
|
|
262
|
+
const items = readPullRequestsForScope(scope);
|
|
263
|
+
for (const pr of items) { if (pr && pr._scope) delete pr._scope; }
|
|
264
|
+
const target = filePath || _filePathForScope(scope);
|
|
265
|
+
shared.safeWrite(target, items);
|
|
266
|
+
const m = _statMtimeMs(target);
|
|
267
|
+
if (m != null) _lastMirrorMtimeByScope.set(scope, m);
|
|
268
|
+
} catch {
|
|
269
|
+
// Mirror failures are non-fatal — SQL has already committed.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _resetPullRequestsTableForTest() {
|
|
274
|
+
const { getDb } = require('./db');
|
|
275
|
+
try { getDb().exec('DELETE FROM pull_requests'); } catch { /* not initialized */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
scopeForFilePath,
|
|
280
|
+
readPullRequestsForScope,
|
|
281
|
+
readAllPullRequests,
|
|
282
|
+
applyPullRequestsMutation,
|
|
283
|
+
_filePathForScope,
|
|
284
|
+
_mirrorJsonFromSql,
|
|
285
|
+
_resetPullRequestsTableForTest,
|
|
286
|
+
};
|
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 {
|
|
@@ -651,45 +660,74 @@ function getPullRequests(config) {
|
|
|
651
660
|
const projectByName = new Map(projects.map(p => [p.name, p]));
|
|
652
661
|
const allPrs = [];
|
|
653
662
|
const seenIds = new Set();
|
|
654
|
-
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
let projectDirs = [];
|
|
663
|
+
|
|
664
|
+
// Phase 3: prefer the SQL store. Returns every scope's PRs tagged with
|
|
665
|
+
// `_scope`; we re-map to `_project` for backward compatibility with
|
|
666
|
+
// downstream consumers, intersecting with the configured project list
|
|
667
|
+
// to suppress orphan/removed projects (matches legacy behaviour).
|
|
668
|
+
let sqlPrs = null;
|
|
661
669
|
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) {
|
|
670
|
+
const store = require('./pull-requests-store');
|
|
671
|
+
sqlPrs = store.readAllPullRequests();
|
|
672
|
+
} catch { /* fall through to JSON */ }
|
|
673
|
+
|
|
674
|
+
if (Array.isArray(sqlPrs) && sqlPrs.length > 0) {
|
|
675
|
+
for (const pr of sqlPrs) {
|
|
674
676
|
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
677
|
+
const scope = pr._scope;
|
|
678
|
+
delete pr._scope;
|
|
679
|
+
if (scope === 'central') {
|
|
680
|
+
pr._project = 'central';
|
|
681
|
+
} else {
|
|
682
|
+
const project = projectByName.get(scope);
|
|
683
|
+
if (!project) continue; // orphan/removed project — don't surface
|
|
684
|
+
const base = project.prUrlBase || '';
|
|
685
|
+
if (!pr.url && base) {
|
|
686
|
+
const prNumber = shared.getPrNumber(pr);
|
|
687
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
688
|
+
}
|
|
689
|
+
shared.normalizePrRecords([pr], project);
|
|
690
|
+
pr._project = project.name || 'Project';
|
|
678
691
|
}
|
|
679
|
-
pr._project = project.name || 'Project';
|
|
680
692
|
allPrs.push(pr);
|
|
681
693
|
seenIds.add(pr.id);
|
|
682
694
|
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
695
|
+
} else {
|
|
696
|
+
// SQL empty or unavailable — fall back to per-file JSON read. Matches
|
|
697
|
+
// the original layout: per-project dirs first, central last.
|
|
698
|
+
let projectDirs = [];
|
|
699
|
+
try {
|
|
700
|
+
projectDirs = fs.readdirSync(path.join(MINIONS_DIR, 'projects'), { withFileTypes: true })
|
|
701
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
|
|
702
|
+
} catch { /* projects dir missing */ }
|
|
703
|
+
for (const dirName of projectDirs) {
|
|
704
|
+
const project = projectByName.get(dirName);
|
|
705
|
+
if (!project) continue;
|
|
706
|
+
const prPath = projectPrPath(project);
|
|
707
|
+
const prs = readJsonNoRestore(prPath);
|
|
708
|
+
if (!Array.isArray(prs)) continue;
|
|
709
|
+
shared.normalizePrRecords(prs, project);
|
|
710
|
+
const base = project.prUrlBase || '';
|
|
711
|
+
for (const pr of prs) {
|
|
712
|
+
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
713
|
+
if (!pr.url && base) {
|
|
714
|
+
const prNumber = shared.getPrNumber(pr);
|
|
715
|
+
if (prNumber != null) pr.url = base + prNumber;
|
|
716
|
+
}
|
|
717
|
+
pr._project = project.name || 'Project';
|
|
718
|
+
allPrs.push(pr);
|
|
719
|
+
seenIds.add(pr.id);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const centralPrs = readJsonNoRestore(path.join(MINIONS_DIR, 'pull-requests.json'));
|
|
723
|
+
if (centralPrs) {
|
|
724
|
+
shared.normalizePrRecords(centralPrs, null);
|
|
725
|
+
for (const pr of centralPrs) {
|
|
726
|
+
if (!pr?.id || seenIds.has(pr.id)) continue;
|
|
727
|
+
pr._project = 'central';
|
|
728
|
+
allPrs.push(pr);
|
|
729
|
+
seenIds.add(pr.id);
|
|
730
|
+
}
|
|
693
731
|
}
|
|
694
732
|
}
|
|
695
733
|
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
|
|
@@ -4643,6 +4690,35 @@ function reopenWorkItem(wi) {
|
|
|
4643
4690
|
* @param {Function} mutator - Receives the array, mutates in place or returns new value
|
|
4644
4691
|
*/
|
|
4645
4692
|
function mutatePullRequests(filePath, mutator) {
|
|
4693
|
+
// Phase 3 SQL path. Same shape as Phase 2's mutateWorkItems: route
|
|
4694
|
+
// through the pull-requests-store when filePath sits under MINIONS_DIR,
|
|
4695
|
+
// mirror back to JSON for legacy direct-readers, emit a pull_requests
|
|
4696
|
+
// event only on real writes. Ad-hoc tmp paths (legacy tests using
|
|
4697
|
+
// createTmpDir) and SQLite failures fall through to the JSON path.
|
|
4698
|
+
const fpNorm = String(filePath).replace(/\\/g, '/');
|
|
4699
|
+
const minionsNorm = String(MINIONS_DIR).replace(/\\/g, '/');
|
|
4700
|
+
const insideMinionsDir = fpNorm.startsWith(minionsNorm + '/') || fpNorm === minionsNorm + '/pull-requests.json';
|
|
4701
|
+
if (insideMinionsDir) {
|
|
4702
|
+
try {
|
|
4703
|
+
const store = require('./pull-requests-store');
|
|
4704
|
+
const scope = store.scopeForFilePath(filePath);
|
|
4705
|
+
const { wrote, result } = store.applyPullRequestsMutation(scope, (prs) => {
|
|
4706
|
+
if (!Array.isArray(prs)) prs = [];
|
|
4707
|
+
return mutator(prs) || prs;
|
|
4708
|
+
});
|
|
4709
|
+
if (wrote) {
|
|
4710
|
+
try { store._mirrorJsonFromSql(scope, filePath); } catch { /* mirror is best-effort */ }
|
|
4711
|
+
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
4712
|
+
}
|
|
4713
|
+
return result;
|
|
4714
|
+
} catch (e) {
|
|
4715
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
4716
|
+
throw e;
|
|
4717
|
+
}
|
|
4718
|
+
// Fall through to legacy JSON path on SQLite errors only.
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4646
4722
|
return mutateJsonFileLocked(filePath, (data) => {
|
|
4647
4723
|
if (!Array.isArray(data)) data = [];
|
|
4648
4724
|
return mutator(data) || data;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2069",
|
|
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"
|