@yemi33/minions 0.1.2071 → 0.1.2073
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/dashboard/js/qa.js +358 -0
- package/dashboard/js/state.js +2 -1
- package/dashboard/pages/qa.html +72 -0
- package/dashboard/styles.css +102 -0
- package/dashboard.js +410 -6
- package/docs/README.md +1 -0
- package/docs/auto-discovery.md +2 -1
- package/docs/kb-sweep.md +8 -0
- package/docs/qa-runbook-lifecycle.md +232 -0
- package/engine/cleanup.js +4 -1
- package/engine/comment-classifier.js +8 -1
- package/engine/cooldown.js +6 -2
- package/engine/db/migrations/007-watches.js +95 -0
- package/engine/gh-comment.js +74 -3
- package/engine/lifecycle.js +100 -0
- package/engine/metrics-store.js +0 -0
- package/engine/pipeline.js +9 -1
- package/engine/playbook.js +39 -0
- package/engine/pull-requests-store.js +30 -22
- package/engine/qa-runners/maestro.js +152 -0
- package/engine/qa-runners/playwright.js +149 -0
- package/engine/qa-runners.js +323 -0
- package/engine/qa-sessions.js +1008 -0
- package/engine/shared.js +109 -13
- package/engine/watches-store.js +259 -0
- package/engine/watches.js +12 -16
- package/engine/work-items-store.js +33 -35
- package/engine.js +140 -0
- package/package.json +1 -1
- package/playbooks/qa-session-draft.md +158 -0
- package/playbooks/qa-session-execute.md +165 -0
- package/playbooks/qa-session-setup.md +154 -0
- package/prompts/cc-system.md +43 -0
- package/routing.md +3 -0
package/engine/shared.js
CHANGED
|
@@ -467,6 +467,22 @@ function safeReadDir(dir) {
|
|
|
467
467
|
try { return fs.readdirSync(dir); } catch { return []; }
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Read a JSON file with **automatic restore from `.backup` sidecar** on
|
|
472
|
+
* missing/corrupt primary. Intended for live, mutable state files
|
|
473
|
+
* (work-items.json, dispatch.json, pull-requests.json, etc.) that are paired
|
|
474
|
+
* with a `.backup` sidecar written by `safeWrite`. Returns the parsed JSON,
|
|
475
|
+
* or null when both primary and backup are missing/unparseable.
|
|
476
|
+
*
|
|
477
|
+
* **Restore semantics:** If the primary is missing or unparseable but a valid
|
|
478
|
+
* `.backup` exists, the backup is parsed, returned, AND atomically rewritten
|
|
479
|
+
* to the primary path (best-effort). This protects live state from torn
|
|
480
|
+
* writes / interrupted saves.
|
|
481
|
+
*
|
|
482
|
+
* Counterpart: `safeJsonNoRestore` for terminal artifacts and "missing == gone"
|
|
483
|
+
* reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
|
|
484
|
+
* stale `.backup` is actively harmful. See its JSDoc for selection guidance.
|
|
485
|
+
*/
|
|
470
486
|
function safeJson(p) {
|
|
471
487
|
// Split the read from the parse so we can distinguish "file missing" (normal
|
|
472
488
|
// pre-create state — silent) from "file present but corrupt JSON" (real
|
|
@@ -524,22 +540,42 @@ function safeJsonObj(p) { return safeJson(p) || {}; }
|
|
|
524
540
|
function safeJsonArr(p) { return safeJson(p) || []; }
|
|
525
541
|
|
|
526
542
|
/**
|
|
527
|
-
* Sibling of safeJson for terminal-artifact
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* the
|
|
543
|
+
* Sibling of safeJson for terminal-artifact and "missing == gone" reads
|
|
544
|
+
* (PRDs in `prd/`, archived plans, cooldowns, ephemeral session state —
|
|
545
|
+
* anything where a missing primary should NOT auto-restore from a stale
|
|
546
|
+
* `.backup` sidecar). Returns the parsed JSON on success, or `defaultValue`
|
|
547
|
+
* (default `null`) on **any** failure: missing file, unparseable JSON, or
|
|
548
|
+
* IO error. The `.backup` sidecar is never consulted.
|
|
531
549
|
*
|
|
532
550
|
* Why a separate primitive: safeJson's restore-on-miss is correct for live
|
|
533
551
|
* state files (work-items.json, dispatch.json, pull-requests.json, etc.) but
|
|
534
|
-
* actively harmful for terminal artifacts.
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
552
|
+
* actively harmful for terminal artifacts. Examples of misuse and the bugs
|
|
553
|
+
* they hide:
|
|
554
|
+
* - Archived PRDs leave a `.backup` sidecar in `prd/`; reading the active
|
|
555
|
+
* path with safeJson silently restores it and the dashboard sees a
|
|
556
|
+
* phantom "active" PRD (W-mouptdh1000h9f39). PRDs are end-state — no
|
|
557
|
+
* resurrection.
|
|
558
|
+
* - Cooldowns are time-bounded ephemeral state (24h TTL). Restoring a
|
|
559
|
+
* stale `cooldowns.json.backup` could resurrect expired entries that
|
|
560
|
+
* should already have been pruned, suppressing legitimate dispatches.
|
|
561
|
+
* - Restoring corrupt-primary scenarios from `.backup` masks the underlying
|
|
562
|
+
* write integrity failure and breaks State Integrity tests.
|
|
563
|
+
*
|
|
564
|
+
* **When to use which:**
|
|
565
|
+
* - `safeJson(p)` — live mutable state paired with safeWrite-managed `.backup`.
|
|
566
|
+
* Restore-on-miss is protective against torn writes.
|
|
567
|
+
* - `safeJsonNoRestore(p, defaultValue)` — terminal artifacts, time-bounded
|
|
568
|
+
* ephemeral state, or any read where "missing/corrupt" should mean "gone".
|
|
538
569
|
*
|
|
539
570
|
* Parse errors are logged so silent corruption still surfaces (mirrors
|
|
540
571
|
* safeJson's contract). Read errors other than ENOENT are also logged.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} p - Absolute path to the JSON file.
|
|
574
|
+
* @param {*} [defaultValue=null] - Value returned on any failure (missing,
|
|
575
|
+
* parse error, IO error). Pass `{}` / `[]` to mirror safeJsonObj/safeJsonArr.
|
|
576
|
+
* @returns {*} Parsed JSON on success, otherwise `defaultValue`.
|
|
541
577
|
*/
|
|
542
|
-
function safeJsonNoRestore(p) {
|
|
578
|
+
function safeJsonNoRestore(p, defaultValue = null) {
|
|
543
579
|
let raw;
|
|
544
580
|
try {
|
|
545
581
|
raw = fs.readFileSync(p, 'utf8');
|
|
@@ -547,13 +583,13 @@ function safeJsonNoRestore(p) {
|
|
|
547
583
|
if (e && e.code !== 'ENOENT') {
|
|
548
584
|
console.warn(`[safeJsonNoRestore] read failed for ${path.basename(p)}: ${e.message}`);
|
|
549
585
|
}
|
|
550
|
-
return
|
|
586
|
+
return defaultValue;
|
|
551
587
|
}
|
|
552
588
|
try {
|
|
553
589
|
return JSON.parse(raw);
|
|
554
590
|
} catch (parseErr) {
|
|
555
591
|
console.error(`[safeJsonNoRestore] parse failure for ${path.basename(p)}: ${parseErr.message}`);
|
|
556
|
-
return
|
|
592
|
+
return defaultValue;
|
|
557
593
|
}
|
|
558
594
|
}
|
|
559
595
|
|
|
@@ -1144,10 +1180,20 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
1144
1180
|
let data = safeJson(filePath);
|
|
1145
1181
|
const parsedInvalid = fileExists && data === null;
|
|
1146
1182
|
if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
|
|
1147
|
-
|
|
1183
|
+
// Normalize BEFORE taking the baseline snapshot so that both `beforeSerialized`
|
|
1184
|
+
// and the post-mutator snapshot reflect post-normalize state. Capturing the
|
|
1185
|
+
// baseline before normalize breaks the `skipWriteIfUnchanged` optimization for
|
|
1186
|
+
// pull-requests.json files: a no-op mutator on a denormalized file would
|
|
1187
|
+
// always trip the write path because normalization itself shifted serialized
|
|
1188
|
+
// bytes between the two snapshots (P-bfa1c-skipwrite-timing). The trade-off
|
|
1189
|
+
// is intentional: when normalization is the ONLY change, we deliberately
|
|
1190
|
+
// leave the on-disk file denormalized — readers re-run normalizePrRecords on
|
|
1191
|
+
// load (see getPrLinks, engine/queries.js:670-674), so the in-memory contract
|
|
1192
|
+
// is preserved without the per-poll mtime bump.
|
|
1148
1193
|
if (path.basename(filePath) === 'pull-requests.json' && Array.isArray(data)) {
|
|
1149
1194
|
normalizePrRecords(data, resolveProjectForPrPath(filePath));
|
|
1150
1195
|
}
|
|
1196
|
+
const beforeSerialized = skipWriteIfUnchanged ? JSON.stringify(data) : null;
|
|
1151
1197
|
const next = mutateFn(data);
|
|
1152
1198
|
const finalData = next === undefined ? data : next;
|
|
1153
1199
|
const shouldWrite = !skipWriteIfUnchanged || parsedInvalid || JSON.stringify(finalData) !== beforeSerialized;
|
|
@@ -1767,6 +1813,14 @@ function parseStreamJsonOutput(raw, runtimeName, opts) {
|
|
|
1767
1813
|
|
|
1768
1814
|
const KB_CATEGORIES = ['architecture', 'conventions', 'project-notes', 'build-reports', 'reviews'];
|
|
1769
1815
|
|
|
1816
|
+
// P-bfa2b-kb-path-traversal — read-side whitelist for /api/knowledge/:category/:file.
|
|
1817
|
+
// Superset of KB_CATEGORIES: adds 'agents' because per-agent personal memory is
|
|
1818
|
+
// served from knowledge/agents/<id>.md (see engine/consolidation.js +
|
|
1819
|
+
// engine/playbook.js) but is NOT a destination for inbox classification, so
|
|
1820
|
+
// KB_CATEGORIES intentionally excludes it. Frozen so handlers can rely on the
|
|
1821
|
+
// list being immutable across the process lifetime.
|
|
1822
|
+
const KB_READABLE_CATEGORIES = Object.freeze([...KB_CATEGORIES, 'agents']);
|
|
1823
|
+
|
|
1770
1824
|
/**
|
|
1771
1825
|
* Classify an inbox item into a knowledge base category.
|
|
1772
1826
|
* Single source of truth — used by consolidation.js (both LLM and regex paths).
|
|
@@ -2690,6 +2744,43 @@ const WATCH_ACTION_TYPE = {
|
|
|
2690
2744
|
RESUME_PLAN: 'resume-plan',
|
|
2691
2745
|
};
|
|
2692
2746
|
|
|
2747
|
+
/**
|
|
2748
|
+
* Route a watches mutation through the SQL store. Same shape as
|
|
2749
|
+
* mutateWorkItems / mutatePullRequests: mutator receives the watches
|
|
2750
|
+
* array, mutates in place or returns a replacement, and the store
|
|
2751
|
+
* diffs by id. Falls back to the legacy mutateJsonFileLocked path on
|
|
2752
|
+
* SQLite failure.
|
|
2753
|
+
*/
|
|
2754
|
+
function mutateWatches(mutator) {
|
|
2755
|
+
const watchesPath = path.join(MINIONS_DIR, 'engine', 'watches.json');
|
|
2756
|
+
try {
|
|
2757
|
+
const store = require('./watches-store');
|
|
2758
|
+
const { wrote, result } = store.applyWatchesMutation((arr) => {
|
|
2759
|
+
if (!Array.isArray(arr)) arr = [];
|
|
2760
|
+
return mutator(arr) || arr;
|
|
2761
|
+
});
|
|
2762
|
+
if (wrote) {
|
|
2763
|
+
try { store._mirrorJsonFromSql(watchesPath); } catch { /* mirror best-effort */ }
|
|
2764
|
+
try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
|
|
2765
|
+
}
|
|
2766
|
+
return result;
|
|
2767
|
+
} catch (e) {
|
|
2768
|
+
if (!e || !/SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
2769
|
+
throw e;
|
|
2770
|
+
}
|
|
2771
|
+
// SQLite unavailable — fall through to legacy JSON path.
|
|
2772
|
+
}
|
|
2773
|
+
return mutateJsonFileLocked(watchesPath, (data) => {
|
|
2774
|
+
if (!Array.isArray(data)) data = [];
|
|
2775
|
+
return mutator(data) || data;
|
|
2776
|
+
}, {
|
|
2777
|
+
defaultValue: [],
|
|
2778
|
+
onWrote: () => {
|
|
2779
|
+
try { require('./db-events').emitStateEvent('watches'); } catch { /* optional */ }
|
|
2780
|
+
},
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2693
2784
|
/**
|
|
2694
2785
|
* Route a metrics mutation through the SQL store with a JSON dual-write
|
|
2695
2786
|
* mirror. Same shape as mutateWorkItems / mutatePullRequests: mutator
|
|
@@ -4763,6 +4854,10 @@ function mutatePullRequests(filePath, mutator) {
|
|
|
4763
4854
|
return mutator(data) || data;
|
|
4764
4855
|
}, {
|
|
4765
4856
|
defaultValue: [],
|
|
4857
|
+
skipWriteIfUnchanged: true,
|
|
4858
|
+
// Emit only when an actual write happened. skipWriteIfUnchanged can
|
|
4859
|
+
// short-circuit no-op mutations; suppress the event in that case so the
|
|
4860
|
+
// dashboard cache-version doesn't bump for nothing.
|
|
4766
4861
|
onWrote: () => {
|
|
4767
4862
|
try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
|
|
4768
4863
|
},
|
|
@@ -5158,6 +5253,7 @@ module.exports = {
|
|
|
5158
5253
|
gitEnv,
|
|
5159
5254
|
parseStreamJsonOutput,
|
|
5160
5255
|
KB_CATEGORIES,
|
|
5256
|
+
KB_READABLE_CATEGORIES,
|
|
5161
5257
|
classifyInboxItem,
|
|
5162
5258
|
ENGINE_DEFAULTS,
|
|
5163
5259
|
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
@@ -5166,7 +5262,7 @@ module.exports = {
|
|
|
5166
5262
|
runtimeConfigWarnings,
|
|
5167
5263
|
projectWorkSourceWarnings,
|
|
5168
5264
|
backfillProjectWorkSourceDefaults,
|
|
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,
|
|
5265
|
+
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, mutateWatches, trackReviewMetric, queuePlanToPrd, extractPlanDeclaredProject,
|
|
5170
5266
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS, WATCH_ACTION_TYPE,
|
|
5171
5267
|
WATCH_STALLED_DEFAULT_TICKS, WATCH_STUCK_STAGE_DEFAULT_TICKS,
|
|
5172
5268
|
PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, AGENT_STATUS,
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// engine/watches-store.js — SQL-backed implementation of engine/watches.json.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the Phase 2/3 work_items / pull_requests stores: a single
|
|
4
|
+
// table (no per-scope split — watches.json is a flat array, not
|
|
5
|
+
// per-project), diff-then-apply mutator, JSON dual-write mirror with
|
|
6
|
+
// (mtime, size) fingerprint for external-edit detection.
|
|
7
|
+
//
|
|
8
|
+
// readWatches() -> [watch, watch, ...]
|
|
9
|
+
// applyWatchesMutation(fn) -> diff-then-apply, returns { wrote, result }
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
function _toMs(v) {
|
|
15
|
+
if (v == null) return null;
|
|
16
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
17
|
+
const parsed = Date.parse(v);
|
|
18
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _watchesFilePath() {
|
|
22
|
+
const shared = require('./shared');
|
|
23
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'watches.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _parseRow(row) {
|
|
27
|
+
if (!row || !row.data) return null;
|
|
28
|
+
try { return JSON.parse(row.data); }
|
|
29
|
+
catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _readJsonArrayFallback() {
|
|
33
|
+
const fp = _watchesFilePath();
|
|
34
|
+
let raw;
|
|
35
|
+
try { raw = fs.readFileSync(fp, 'utf8'); }
|
|
36
|
+
catch { return []; }
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
40
|
+
} catch (e) {
|
|
41
|
+
try {
|
|
42
|
+
// eslint-disable-next-line no-console
|
|
43
|
+
console.warn(`[watches-store] corrupt JSON in ${fp}: ${e.message}`);
|
|
44
|
+
} catch { /* console may be wrapped */ }
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Content-hash fingerprint — (mtime, size) is too coarse: same-size
|
|
50
|
+
// content swaps (e.g. last_checked timestamps that always have the same
|
|
51
|
+
// byte length) inside the same ms tick collide on both axes. Hashing the
|
|
52
|
+
// bytes is the unambiguous signal. SHA-1 on a few-KB file is sub-ms.
|
|
53
|
+
let _lastMirrorHash = null;
|
|
54
|
+
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
|
|
57
|
+
function _fileContentHash(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const buf = fs.readFileSync(filePath);
|
|
60
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _hydrateFromJson(db) {
|
|
66
|
+
const items = _readJsonArrayFallback();
|
|
67
|
+
db.prepare('DELETE FROM watches').run();
|
|
68
|
+
if (items.length === 0) return;
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const ins = db.prepare(`
|
|
71
|
+
INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(id) DO NOTHING
|
|
74
|
+
`);
|
|
75
|
+
for (const w of items) {
|
|
76
|
+
if (!w || !w.id) continue;
|
|
77
|
+
ins.run(
|
|
78
|
+
String(w.id),
|
|
79
|
+
typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
|
|
80
|
+
w.targetType || null,
|
|
81
|
+
w.condition || null,
|
|
82
|
+
String(w.status || 'active'),
|
|
83
|
+
w.owner || null,
|
|
84
|
+
_toMs(w.created_at),
|
|
85
|
+
_toMs(w.last_checked),
|
|
86
|
+
_toMs(w.last_triggered),
|
|
87
|
+
JSON.stringify(w),
|
|
88
|
+
now,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _resyncIfJsonDiverged(db) {
|
|
94
|
+
const jsonPath = _watchesFilePath();
|
|
95
|
+
const currentHash = _fileContentHash(jsonPath);
|
|
96
|
+
if (currentHash == null) return;
|
|
97
|
+
if (_lastMirrorHash != null && currentHash === _lastMirrorHash) return;
|
|
98
|
+
if (_lastMirrorHash == null) {
|
|
99
|
+
const sqlHas = db.prepare('SELECT 1 FROM watches LIMIT 1').get();
|
|
100
|
+
if (sqlHas) {
|
|
101
|
+
_lastMirrorHash = currentHash;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
_hydrateFromJson(db);
|
|
106
|
+
_lastMirrorHash = currentHash;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Read all watch rows from SQL only. No JSON fallback — used by the
|
|
110
|
+
// mirror writer to avoid resurrecting deleted rows: after the mutation
|
|
111
|
+
// deletes a watch from SQL but BEFORE the mirror lands, the JSON still
|
|
112
|
+
// holds the pre-mutation content, so a fallback read would round-trip
|
|
113
|
+
// the deleted row right back into JSON.
|
|
114
|
+
function _readWatchesFromSqlOnly(db) {
|
|
115
|
+
const rows = db.prepare('SELECT data FROM watches ORDER BY rowid').all();
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
const w = _parseRow(row);
|
|
119
|
+
if (w) out.push(w);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readWatches() {
|
|
125
|
+
const { getDb } = require('./db');
|
|
126
|
+
let db;
|
|
127
|
+
try { db = getDb(); }
|
|
128
|
+
catch { return _readJsonArrayFallback(); }
|
|
129
|
+
|
|
130
|
+
_resyncIfJsonDiverged(db);
|
|
131
|
+
|
|
132
|
+
const out = _readWatchesFromSqlOnly(db);
|
|
133
|
+
if (out.length === 0) {
|
|
134
|
+
// SQL is empty for first-time hydrate: trust the JSON if it has
|
|
135
|
+
// content. After hydrate ran, SQL is empty iff the user really has
|
|
136
|
+
// no watches.
|
|
137
|
+
const fallback = _readJsonArrayFallback();
|
|
138
|
+
if (fallback.length > 0) return fallback;
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _indexById(arr) {
|
|
145
|
+
const out = new Map();
|
|
146
|
+
for (const w of arr) {
|
|
147
|
+
if (!w || !w.id) continue;
|
|
148
|
+
out.set(String(w.id), w);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _computeWatchesDiff(before, after) {
|
|
154
|
+
const beforeMap = _indexById(before);
|
|
155
|
+
const afterMap = _indexById(after);
|
|
156
|
+
const toUpsert = [];
|
|
157
|
+
const toDelete = [];
|
|
158
|
+
for (const [id, w] of afterMap) {
|
|
159
|
+
const prev = beforeMap.get(id);
|
|
160
|
+
if (!prev) { toUpsert.push(w); continue; }
|
|
161
|
+
if (JSON.stringify(prev) !== JSON.stringify(w)) toUpsert.push(w);
|
|
162
|
+
}
|
|
163
|
+
for (const [id] of beforeMap) {
|
|
164
|
+
if (!afterMap.has(id)) toDelete.push(id);
|
|
165
|
+
}
|
|
166
|
+
return { toUpsert, toDelete };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _applyWatchesDiff(db, diff) {
|
|
170
|
+
if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const upsertStmt = db.prepare(`
|
|
173
|
+
INSERT INTO watches (id, target, target_type, condition, status, owner, created_at, last_checked, last_triggered, data, updated_at)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
175
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
176
|
+
target = excluded.target,
|
|
177
|
+
target_type = excluded.target_type,
|
|
178
|
+
condition = excluded.condition,
|
|
179
|
+
status = excluded.status,
|
|
180
|
+
owner = excluded.owner,
|
|
181
|
+
created_at = excluded.created_at,
|
|
182
|
+
last_checked = excluded.last_checked,
|
|
183
|
+
last_triggered = excluded.last_triggered,
|
|
184
|
+
data = excluded.data,
|
|
185
|
+
updated_at = excluded.updated_at
|
|
186
|
+
`);
|
|
187
|
+
const deleteStmt = db.prepare('DELETE FROM watches WHERE id = ?');
|
|
188
|
+
|
|
189
|
+
for (const w of diff.toUpsert) {
|
|
190
|
+
upsertStmt.run(
|
|
191
|
+
String(w.id),
|
|
192
|
+
typeof w.target === 'string' ? w.target : (w.target == null ? null : JSON.stringify(w.target)),
|
|
193
|
+
w.targetType || null,
|
|
194
|
+
w.condition || null,
|
|
195
|
+
String(w.status || 'active'),
|
|
196
|
+
w.owner || null,
|
|
197
|
+
_toMs(w.created_at),
|
|
198
|
+
_toMs(w.last_checked),
|
|
199
|
+
_toMs(w.last_triggered),
|
|
200
|
+
JSON.stringify(w),
|
|
201
|
+
now,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
for (const id of diff.toDelete) {
|
|
205
|
+
deleteStmt.run(id);
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyWatchesMutation(mutator) {
|
|
211
|
+
const { getDb, withTransaction } = require('./db');
|
|
212
|
+
let db;
|
|
213
|
+
try { db = getDb(); }
|
|
214
|
+
catch (e) {
|
|
215
|
+
throw new Error(`engine/watches-store: SQLite unavailable (${e.message}); cannot mutate watches`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return withTransaction(db, () => {
|
|
219
|
+
_resyncIfJsonDiverged(db);
|
|
220
|
+
const before = readWatches();
|
|
221
|
+
const beforeSnapshot = JSON.parse(JSON.stringify(before));
|
|
222
|
+
const next = mutator(before);
|
|
223
|
+
const after = (next === undefined || next === null)
|
|
224
|
+
? before
|
|
225
|
+
: (Array.isArray(next) ? next : before);
|
|
226
|
+
const diff = _computeWatchesDiff(beforeSnapshot, after);
|
|
227
|
+
const wrote = _applyWatchesDiff(db, diff);
|
|
228
|
+
return { wrote, result: after };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _mirrorJsonFromSql(filePath) {
|
|
233
|
+
try {
|
|
234
|
+
const shared = require('./shared');
|
|
235
|
+
const { getDb } = require('./db');
|
|
236
|
+
// Read SQL directly — bypass the JSON fallback so a deletion that
|
|
237
|
+
// leaves SQL empty doesn't resurrect from the stale JSON content.
|
|
238
|
+
const items = _readWatchesFromSqlOnly(getDb());
|
|
239
|
+
const target = filePath || _watchesFilePath();
|
|
240
|
+
shared.safeWrite(target, items);
|
|
241
|
+
const h = _fileContentHash(target);
|
|
242
|
+
if (h != null) _lastMirrorHash = h;
|
|
243
|
+
} catch {
|
|
244
|
+
// Mirror failures are non-fatal — SQL has already committed.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _resetWatchesTableForTest() {
|
|
249
|
+
const { getDb } = require('./db');
|
|
250
|
+
try { getDb().exec('DELETE FROM watches'); } catch { /* not initialized */ }
|
|
251
|
+
_lastMirrorHash = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
readWatches,
|
|
256
|
+
applyWatchesMutation,
|
|
257
|
+
_mirrorJsonFromSql,
|
|
258
|
+
_resetWatchesTableForTest,
|
|
259
|
+
};
|
package/engine/watches.js
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
const fs = require('fs');
|
|
68
68
|
const path = require('path');
|
|
69
69
|
const shared = require('./shared');
|
|
70
|
-
const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
|
|
70
|
+
const { safeJsonArr, mutateJsonFileLocked, mutateWatches, ts, uid, log, writeToInbox,
|
|
71
71
|
WATCH_STATUS, WATCH_TARGET_TYPE, WATCH_CONDITION, WATCH_ABSOLUTE_CONDITIONS } = shared;
|
|
72
72
|
const watchActions = require('./watch-actions');
|
|
73
73
|
|
|
@@ -230,11 +230,10 @@ function createWatch({ target, targetType, condition, interval, owner, descripti
|
|
|
230
230
|
_history: [],
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
if (!Array.isArray(watches)) watches = [];
|
|
233
|
+
mutateWatches((watches) => {
|
|
235
234
|
watches.push(watch);
|
|
236
235
|
return watches;
|
|
237
|
-
}
|
|
236
|
+
});
|
|
238
237
|
|
|
239
238
|
log('info', `Watch created: ${watch.id} → ${watch.target} (${watch.condition})`);
|
|
240
239
|
return watch;
|
|
@@ -264,8 +263,7 @@ function updateWatch(id, updates) {
|
|
|
264
263
|
if (reqErr) throw new Error(reqErr);
|
|
265
264
|
}
|
|
266
265
|
let found = null;
|
|
267
|
-
|
|
268
|
-
if (!Array.isArray(watches)) return watches;
|
|
266
|
+
mutateWatches((watches) => {
|
|
269
267
|
const watch = watches.find(w => w.id === id);
|
|
270
268
|
if (!watch) return watches;
|
|
271
269
|
// Only allow safe field updates
|
|
@@ -275,7 +273,7 @@ function updateWatch(id, updates) {
|
|
|
275
273
|
}
|
|
276
274
|
found = { ...watch };
|
|
277
275
|
return watches;
|
|
278
|
-
}
|
|
276
|
+
});
|
|
279
277
|
return found;
|
|
280
278
|
}
|
|
281
279
|
|
|
@@ -287,15 +285,14 @@ function updateWatch(id, updates) {
|
|
|
287
285
|
function deleteWatch(id) {
|
|
288
286
|
if (!id) return false;
|
|
289
287
|
let deleted = false;
|
|
290
|
-
|
|
291
|
-
if (!Array.isArray(watches)) return watches;
|
|
288
|
+
mutateWatches((watches) => {
|
|
292
289
|
const idx = watches.findIndex(w => w.id === id);
|
|
293
290
|
if (idx >= 0) {
|
|
294
291
|
watches.splice(idx, 1);
|
|
295
292
|
deleted = true;
|
|
296
293
|
}
|
|
297
294
|
return watches;
|
|
298
|
-
}
|
|
295
|
+
});
|
|
299
296
|
if (deleted) log('info', `Watch deleted: ${id}`);
|
|
300
297
|
return deleted;
|
|
301
298
|
}
|
|
@@ -413,8 +410,8 @@ function checkWatches(config, state) {
|
|
|
413
410
|
const notifications = [];
|
|
414
411
|
const actionsToRun = [];
|
|
415
412
|
|
|
416
|
-
|
|
417
|
-
if (
|
|
413
|
+
mutateWatches((watches) => {
|
|
414
|
+
if (watches.length === 0) return watches;
|
|
418
415
|
|
|
419
416
|
for (const watch of watches) {
|
|
420
417
|
try {
|
|
@@ -539,7 +536,7 @@ function checkWatches(config, state) {
|
|
|
539
536
|
}
|
|
540
537
|
|
|
541
538
|
return watches;
|
|
542
|
-
}
|
|
539
|
+
});
|
|
543
540
|
|
|
544
541
|
// Fire notifications outside the lock — writeToInbox does disk I/O
|
|
545
542
|
for (const n of notifications) {
|
|
@@ -591,8 +588,7 @@ async function _runActionTask(task) {
|
|
|
591
588
|
// can inspect each step's result. Single-action watches keep the legacy
|
|
592
589
|
// `dispatchedItemId` shortcut for back-compat.
|
|
593
590
|
try {
|
|
594
|
-
|
|
595
|
-
if (!Array.isArray(watches)) return watches;
|
|
591
|
+
mutateWatches((watches) => {
|
|
596
592
|
const w = watches.find(x => x.id === task.watchId);
|
|
597
593
|
if (w) {
|
|
598
594
|
if (isChain) {
|
|
@@ -639,7 +635,7 @@ async function _runActionTask(task) {
|
|
|
639
635
|
}
|
|
640
636
|
}
|
|
641
637
|
return watches;
|
|
642
|
-
}
|
|
638
|
+
});
|
|
643
639
|
} catch (err) {
|
|
644
640
|
log('warn', `Watch ${task.watchId} action result persist failed: ${err.message}`);
|
|
645
641
|
}
|
|
@@ -116,28 +116,19 @@ 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
|
-
|
|
123
|
-
|
|
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;
|
|
129
|
-
// No mirror record but JSON exists: first-time hydrate iff SQL is empty
|
|
130
|
-
// for this scope (avoid trampling a freshly-backfilled migration state
|
|
131
|
-
// on the very first read).
|
|
132
|
-
if (lastMirror == null) {
|
|
119
|
+
const currentHash = _fileContentHash(jsonPath);
|
|
120
|
+
const lastHash = _lastMirrorHashByScope.get(scope);
|
|
121
|
+
if (currentHash == null) return;
|
|
122
|
+
if (lastHash != null && currentHash === lastHash) return;
|
|
123
|
+
if (lastHash == null) {
|
|
133
124
|
const sqlHas = db.prepare('SELECT 1 FROM work_items WHERE scope = ? LIMIT 1').get(scope);
|
|
134
125
|
if (sqlHas) {
|
|
135
|
-
|
|
126
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
136
127
|
return;
|
|
137
128
|
}
|
|
138
129
|
}
|
|
139
130
|
_hydrateScopeFromJson(db, scope);
|
|
140
|
-
|
|
131
|
+
_lastMirrorHashByScope.set(scope, currentHash);
|
|
141
132
|
}
|
|
142
133
|
|
|
143
134
|
// Read all rows across all scopes — used by queries.getWorkItems which
|
|
@@ -243,18 +234,18 @@ function _applyWorkItemsDiff(db, diff) {
|
|
|
243
234
|
// wrote — true iff at least one INSERT/UPDATE/DELETE landed
|
|
244
235
|
// result — the post-mutation array (legacy return shape of
|
|
245
236
|
// mutateJsonFileLocked)
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
// — content swaps almost always change byte length.
|
|
252
|
-
const _lastMirrorByScope = new Map();
|
|
237
|
+
// Content-hash fingerprint of the JSON mirror per scope, so external
|
|
238
|
+
// edits are detected unambiguously. (mtime, size) is too coarse: same-
|
|
239
|
+
// length timestamp swaps (last_checked / last_triggered / completed_at)
|
|
240
|
+
// preserve both axes. SHA-1 is sub-ms on a few-hundred-KB file.
|
|
241
|
+
const _lastMirrorHashByScope = new Map();
|
|
253
242
|
|
|
254
|
-
|
|
243
|
+
const crypto = require('crypto');
|
|
244
|
+
|
|
245
|
+
function _fileContentHash(filePath) {
|
|
255
246
|
try {
|
|
256
|
-
const
|
|
257
|
-
return
|
|
247
|
+
const buf = fs.readFileSync(filePath);
|
|
248
|
+
return crypto.createHash('sha1').update(buf).digest('hex');
|
|
258
249
|
}
|
|
259
250
|
catch { return null; }
|
|
260
251
|
}
|
|
@@ -325,17 +316,24 @@ function applyWorkItemsMutation(scope, mutator) {
|
|
|
325
316
|
function _mirrorJsonFromSql(scope, filePath) {
|
|
326
317
|
try {
|
|
327
318
|
const shared = require('./shared');
|
|
328
|
-
const
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
319
|
+
const { getDb } = require('./db');
|
|
320
|
+
// Read SQL directly for this scope — bypass the JSON fallback so a
|
|
321
|
+
// mutation that empties SQL for the scope doesn't resurrect stale
|
|
322
|
+
// JSON content. (See pull-requests-store and watches-store for the
|
|
323
|
+
// same defense.)
|
|
324
|
+
const db = getDb();
|
|
325
|
+
const rows = db.prepare('SELECT data FROM work_items WHERE scope = ? ORDER BY rowid').all(scope);
|
|
326
|
+
const items = [];
|
|
327
|
+
for (const row of rows) {
|
|
328
|
+
const wi = _parseRow(row);
|
|
329
|
+
if (!wi) continue;
|
|
330
|
+
if (wi._source) delete wi._source;
|
|
331
|
+
items.push(wi);
|
|
332
|
+
}
|
|
332
333
|
const target = filePath || _filePathForScope(scope);
|
|
333
334
|
shared.safeWrite(target, items);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// catches them.
|
|
337
|
-
const fp = _statFingerprint(target);
|
|
338
|
-
if (fp != null) _lastMirrorByScope.set(scope, fp);
|
|
335
|
+
const h = _fileContentHash(target);
|
|
336
|
+
if (h != null) _lastMirrorHashByScope.set(scope, h);
|
|
339
337
|
} catch {
|
|
340
338
|
// Mirror failures are non-fatal: SQL has already committed.
|
|
341
339
|
}
|