@yemi33/minions 0.1.2087 → 0.1.2088
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/refresh.js +598 -160
- package/dashboard/js/render-dispatch.js +77 -0
- package/dashboard/js/render-inbox.js +72 -0
- package/dashboard/js/render-meetings.js +55 -0
- package/dashboard/js/render-plans.js +14 -9
- package/dashboard/js/render-prd.js +13 -6
- package/dashboard/js/render-prs.js +55 -0
- package/dashboard/js/render-watches.js +16 -0
- package/dashboard/js/render-work-items.js +70 -0
- package/dashboard/js/settings.js +1 -5
- package/dashboard/js/state.js +9 -3
- package/dashboard.js +400 -358
- package/docs/security.md +23 -0
- package/engine/ado.js +54 -54
- package/engine/cli.js +3 -38
- package/engine/db/index.js +1 -1
- package/engine/db/migrations/002-dispatches.js +1 -1
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/dispatch.js +8 -2
- package/engine/github.js +38 -38
- package/engine/lifecycle.js +192 -18
- package/engine/projects.js +92 -0
- package/engine/queries.js +61 -129
- package/engine/shared.js +85 -89
- package/engine/watches.js +5 -5
- package/engine.js +23 -34
- package/package.json +2 -2
|
@@ -5,6 +5,65 @@ const LOG_PER_PAGE = 50;
|
|
|
5
5
|
let _completedPage = 0;
|
|
6
6
|
let _logPage = 0;
|
|
7
7
|
|
|
8
|
+
// Fetch engine/dispatch.json straight off disk through the /state/<path>
|
|
9
|
+
// static-file passthrough so dispatch state flips (queued→active, active→
|
|
10
|
+
// completed, skipReason changes on pending entries) surface within one 4 s
|
|
11
|
+
// poll regardless of /api/status outer-cache state (issue #2949).
|
|
12
|
+
//
|
|
13
|
+
// completedTotal isn't on disk — it's metrics-derived (sum across
|
|
14
|
+
// agents.tasksCompleted + agents.tasksErrored). The renderer already falls
|
|
15
|
+
// back to completed.length when completedTotal is missing, so we just
|
|
16
|
+
// overlay the cached value from the previous /api/status snapshot for
|
|
17
|
+
// continuity. Engine-side _withCompletionReportSummary enrichment (which
|
|
18
|
+
// reads per-dispatch sidecar files) is preserved on the cached snapshot
|
|
19
|
+
// too, so the modal "view report" links keep working — we only overlay the
|
|
20
|
+
// fresh status/timestamp fields onto the cached row when ids match.
|
|
21
|
+
async function fetchDispatchFromDisk(cachedDispatch) {
|
|
22
|
+
try {
|
|
23
|
+
const r = await fetch('/state/engine/dispatch.json');
|
|
24
|
+
if (!r.ok) return null;
|
|
25
|
+
const fresh = await r.json();
|
|
26
|
+
if (!fresh || typeof fresh !== 'object') return null;
|
|
27
|
+
// Cap completed to last 20 to match engine/queries.js getDispatchQueue
|
|
28
|
+
// — render-dispatch only paginates within this window, and dispatch.json
|
|
29
|
+
// on disk is already capped to 100 by the engine, so this is the same
|
|
30
|
+
// shape the renderer has always seen.
|
|
31
|
+
if (Array.isArray(fresh.completed) && fresh.completed.length > 20) {
|
|
32
|
+
fresh.completed = fresh.completed.slice(-20);
|
|
33
|
+
}
|
|
34
|
+
// Overlay completion-report summaries from the cached snapshot keyed by
|
|
35
|
+
// dispatch id. They live in sidecar files the client can't read, but
|
|
36
|
+
// they're slow to change so the cached overlay is fine.
|
|
37
|
+
if (cachedDispatch) {
|
|
38
|
+
const cachedById = new Map();
|
|
39
|
+
for (const list of ['pending', 'active', 'completed']) {
|
|
40
|
+
for (const d of (cachedDispatch[list] || [])) {
|
|
41
|
+
if (d && d.id) cachedById.set(d.id, d);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const list of ['pending', 'active', 'completed']) {
|
|
45
|
+
if (!Array.isArray(fresh[list])) continue;
|
|
46
|
+
fresh[list] = fresh[list].map((d) => {
|
|
47
|
+
if (!d || !d.id) return d;
|
|
48
|
+
const cached = cachedById.get(d.id);
|
|
49
|
+
if (!cached) return d;
|
|
50
|
+
const merged = Object.assign({}, d);
|
|
51
|
+
if (cached._completionReport !== undefined && merged._completionReport === undefined) {
|
|
52
|
+
merged._completionReport = cached._completionReport;
|
|
53
|
+
}
|
|
54
|
+
return merged;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (fresh.completedTotal == null && cachedDispatch.completedTotal != null) {
|
|
58
|
+
fresh.completedTotal = cachedDispatch.completedTotal;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return fresh;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
8
67
|
function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh(); } }
|
|
9
68
|
function _completedNext() { _completedPage++; refresh(); } // clamped in renderDispatch
|
|
10
69
|
function _logPrev() { if (_logPage > 0) { _logPage--; refresh(); } }
|
|
@@ -274,6 +333,24 @@ function renderDispatch(dispatch) {
|
|
|
274
333
|
}
|
|
275
334
|
}
|
|
276
335
|
|
|
336
|
+
// Pull the engine log straight off disk through /state/engine/log.json.
|
|
337
|
+
// On-disk file is the full append-only array; engine/queries.js getEngineLog
|
|
338
|
+
// slices to the last 50 for /api/status. We replicate that slice client-side
|
|
339
|
+
// after the disk fetch. ETag/304 keeps the steady state cheap; a new log
|
|
340
|
+
// line forces a fresh download (file is ~500 KB at high engine traffic, but
|
|
341
|
+
// it changes only on engine writes — not on every 4 s poll).
|
|
342
|
+
async function fetchEngineLogFromDisk() {
|
|
343
|
+
try {
|
|
344
|
+
const r = await fetch('/state/engine/log.json');
|
|
345
|
+
if (!r.ok) return null;
|
|
346
|
+
const raw = await r.json();
|
|
347
|
+
const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : []);
|
|
348
|
+
return arr.slice(-50);
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
277
354
|
function renderEngineLog(log) {
|
|
278
355
|
const el = document.getElementById('engine-log');
|
|
279
356
|
if (!el) return;
|
|
@@ -3,6 +3,78 @@
|
|
|
3
3
|
const INBOX_PER_PAGE = 15;
|
|
4
4
|
let _inboxPage = 0;
|
|
5
5
|
|
|
6
|
+
// Quick relative-time formatter — mirrors engine/queries.js timeSince so the
|
|
7
|
+
// inbox-item "age" chip reads the same as it did via /api/status.
|
|
8
|
+
function _timeSinceMs(mtimeMs) {
|
|
9
|
+
if (!mtimeMs) return '';
|
|
10
|
+
const ageMs = Date.now() - Number(mtimeMs);
|
|
11
|
+
if (ageMs < 0) return 'just now';
|
|
12
|
+
const sec = Math.floor(ageMs / 1000);
|
|
13
|
+
if (sec < 60) return sec + 's ago';
|
|
14
|
+
const min = Math.floor(sec / 60);
|
|
15
|
+
if (min < 60) return min + 'm ago';
|
|
16
|
+
const hr = Math.floor(min / 60);
|
|
17
|
+
if (hr < 24) return hr + 'h ago';
|
|
18
|
+
const days = Math.floor(hr / 24);
|
|
19
|
+
return days + 'd ago';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fetch the inbox via the /state/notes/inbox directory listing + per-file
|
|
23
|
+
// content fetch. The listing call returns {entries:[{name, mtimeMs, size,
|
|
24
|
+
// isDir}]} and is cheap (one statSync per entry). The per-file fetches run
|
|
25
|
+
// in parallel with their own mtime+size ETag so unchanged files 304 with
|
|
26
|
+
// no body. Issue #2949 — the staleness fix applies here too: the inbox
|
|
27
|
+
// directory mtime advances on every new note, and per-file ETags surface
|
|
28
|
+
// content edits within one 4 s poll.
|
|
29
|
+
async function fetchInboxFromDisk() {
|
|
30
|
+
try {
|
|
31
|
+
const listResp = await fetch('/state/notes/inbox');
|
|
32
|
+
if (!listResp.ok) return null;
|
|
33
|
+
const listing = await listResp.json();
|
|
34
|
+
const entries = Array.isArray(listing && listing.entries) ? listing.entries : [];
|
|
35
|
+
const mdFiles = entries
|
|
36
|
+
.filter((e) => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.md'))
|
|
37
|
+
.sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0));
|
|
38
|
+
const items = await Promise.all(mdFiles.map(async (e) => {
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetch('/state/notes/inbox/' + encodeURIComponent(e.name));
|
|
41
|
+
if (!r.ok) return null;
|
|
42
|
+
const content = await r.text();
|
|
43
|
+
return {
|
|
44
|
+
name: e.name,
|
|
45
|
+
mtime: e.mtimeMs,
|
|
46
|
+
age: _timeSinceMs(e.mtimeMs),
|
|
47
|
+
content,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
return items.filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fetch notes.md via /state/notes.md and return the renderer-shaped
|
|
60
|
+
// { content, updatedAt } pair. updatedAt comes from a HEAD-style poll of
|
|
61
|
+
// the ETag (mtime+size); we don't have direct mtime exposure, so a parallel
|
|
62
|
+
// listing of the parent dir would be needed. Cheaper path: just include the
|
|
63
|
+
// ETag value (which is "<mtimeMs>-<size>") and parse it out.
|
|
64
|
+
async function fetchNotesFromDisk() {
|
|
65
|
+
try {
|
|
66
|
+
const r = await fetch('/state/notes.md');
|
|
67
|
+
if (!r.ok) return null;
|
|
68
|
+
const content = await r.text();
|
|
69
|
+
const etag = r.headers.get('ETag') || '';
|
|
70
|
+
const m = etag.match(/^"(\d+)-/);
|
|
71
|
+
const updatedAt = m ? Number(m[1]) : null;
|
|
72
|
+
return { content, updatedAt };
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
6
78
|
function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData); } }
|
|
7
79
|
function _inboxNext() { _inboxPage++; renderInbox(inboxData); }
|
|
8
80
|
|
|
@@ -7,6 +7,61 @@ let _lastMeetingHash = '';
|
|
|
7
7
|
let _lastMeetingsForPaging = [];
|
|
8
8
|
let _mtgTotalPages = 1;
|
|
9
9
|
|
|
10
|
+
// Pull meetings straight off disk via /state/meetings (dir listing) +
|
|
11
|
+
// per-file fetch of /state/meetings/<id>.json. Each file is slimmed
|
|
12
|
+
// client-side to match dashboard.js _slimMeetingForStatus so the
|
|
13
|
+
// renderer sees the same shape it always has (findings/debate reduced
|
|
14
|
+
// to {agentId: true} sentinels, transcript/conclusion bodies dropped).
|
|
15
|
+
// Detail view still calls /api/meetings/<id> for the full bodies.
|
|
16
|
+
// Issue #2949 — meetings on /api/status used to inherit the same outer
|
|
17
|
+
// cache staleness as work-items; this disk-direct path sidesteps it.
|
|
18
|
+
function _slimMeetingForRender(m) {
|
|
19
|
+
if (!m || typeof m !== 'object') return null;
|
|
20
|
+
const findingsKeys = m.findings && typeof m.findings === 'object' ? Object.keys(m.findings) : [];
|
|
21
|
+
const debateKeys = m.debate && typeof m.debate === 'object' ? Object.keys(m.debate) : [];
|
|
22
|
+
const findings = {};
|
|
23
|
+
for (const k of findingsKeys) findings[k] = true;
|
|
24
|
+
const debate = {};
|
|
25
|
+
for (const k of debateKeys) debate[k] = true;
|
|
26
|
+
const slim = {
|
|
27
|
+
id: m.id,
|
|
28
|
+
title: m.title,
|
|
29
|
+
status: m.status,
|
|
30
|
+
round: m.round,
|
|
31
|
+
participants: Array.isArray(m.participants) ? m.participants : [],
|
|
32
|
+
agenda: m.agenda,
|
|
33
|
+
createdAt: m.createdAt,
|
|
34
|
+
findings,
|
|
35
|
+
debate,
|
|
36
|
+
};
|
|
37
|
+
if (m.completedAt !== undefined) slim.completedAt = m.completedAt;
|
|
38
|
+
if (m.roundStartedAt !== undefined) slim.roundStartedAt = m.roundStartedAt;
|
|
39
|
+
if (m.createdBy !== undefined) slim.createdBy = m.createdBy;
|
|
40
|
+
return slim;
|
|
41
|
+
}
|
|
42
|
+
async function fetchMeetingsFromDisk() {
|
|
43
|
+
try {
|
|
44
|
+
const listResp = await fetch('/state/meetings');
|
|
45
|
+
if (!listResp.ok) return null;
|
|
46
|
+
const listing = await listResp.json();
|
|
47
|
+
const entries = Array.isArray(listing && listing.entries) ? listing.entries : [];
|
|
48
|
+
const jsonFiles = entries.filter(e => e && !e.isDir && typeof e.name === 'string' && e.name.endsWith('.json'));
|
|
49
|
+
const meetings = await Promise.all(jsonFiles.map(async (e) => {
|
|
50
|
+
try {
|
|
51
|
+
const r = await fetch('/state/meetings/' + encodeURIComponent(e.name));
|
|
52
|
+
if (!r.ok) return null;
|
|
53
|
+
const raw = await r.json();
|
|
54
|
+
return _slimMeetingForRender(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
return meetings.filter(Boolean);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
10
65
|
function renderMeetings(meetings) {
|
|
11
66
|
meetings = (meetings || []).filter(function(m) { return !isDeleted('mtg:' + m.id); });
|
|
12
67
|
meetings.sort((a, b) => (b.createdAt || b.completedAt || '').localeCompare(a.createdAt || a.completedAt || ''));
|
|
@@ -128,12 +128,12 @@ function isFollowUpOutcomeSatisfied(wi, allWorkItems, allPrs) {
|
|
|
128
128
|
* but completion/progress is always derived from actual work item state.
|
|
129
129
|
*
|
|
130
130
|
* opts.allPrs — optional PR records used to reconcile stale failed follow-up
|
|
131
|
-
* tasks (issue #2366). Falls back to window.
|
|
132
|
-
*
|
|
131
|
+
* tasks (issue #2366). Falls back to window._lastPullRequests in the browser.
|
|
132
|
+
* Pass `[]` to disable reconciliation.
|
|
133
133
|
*/
|
|
134
134
|
function derivePlanStatus(prdFile, mdFile, prdJsonStatus, workItems, opts) {
|
|
135
135
|
const allPrs = (opts && opts.allPrs)
|
|
136
|
-
|| (typeof window !== 'undefined' && window.
|
|
136
|
+
|| (typeof window !== 'undefined' && window._lastPullRequests)
|
|
137
137
|
|| [];
|
|
138
138
|
const wi = workItems.filter(w =>
|
|
139
139
|
w.sourcePlan === prdFile || w.sourcePlan === mdFile ||
|
|
@@ -708,8 +708,11 @@ async function planApprove(file, btn) {
|
|
|
708
708
|
}
|
|
709
709
|
try { renderPlans(window._lastPlans); } catch { /* render is best-effort */ }
|
|
710
710
|
}
|
|
711
|
-
|
|
712
|
-
|
|
711
|
+
// Issue #2949 — prdProgress moved off /api/status to /api/prd; refresh.js
|
|
712
|
+
// stashes the fresh fetch into window._lastPrdProgress.
|
|
713
|
+
const _prdProgressForApprove = window._lastPrdProgress;
|
|
714
|
+
if (isPrd && _prdProgressForApprove?.items) {
|
|
715
|
+
for (const item of _prdProgressForApprove.items) {
|
|
713
716
|
if (item.source === file) {
|
|
714
717
|
item.planStatus = 'approved';
|
|
715
718
|
item.planStale = false;
|
|
@@ -919,14 +922,16 @@ function _renderVerifyBadge(verifyWi) {
|
|
|
919
922
|
const statusColors = { pending: 'var(--muted)', dispatched: 'var(--blue)', done: 'var(--green)', failed: 'var(--red)' };
|
|
920
923
|
const color = statusColors[verifyWi.status] || 'var(--muted)';
|
|
921
924
|
const label = verifyWi.status === 'dispatched' ? 'Verifying...' : verifyWi.status === 'done' ? '\u2714 Verified' : verifyWi.status === 'failed' ? 'Verify failed' : 'Verify pending';
|
|
922
|
-
// E2E PR — check by prdItems, branch, or title
|
|
923
|
-
|
|
925
|
+
// E2E PR — check by prdItems, branch, or title. Issue #2949 — pullRequests
|
|
926
|
+
// moved off /api/status to /api/pull-requests (window._lastPullRequests).
|
|
927
|
+
const allPrs = window._lastPullRequests || [];
|
|
924
928
|
const planFile = verifyWi.sourcePlan || '';
|
|
925
929
|
const planSlug = planFile.replace('.json', '');
|
|
926
930
|
const verifyPr = allPrs.find(pr => (pr.prdItems || []).includes(verifyWi.id) || (pr.branch && pr.branch.includes(planSlug) && (pr.title || '').includes('[E2E]')));
|
|
927
931
|
const prLink = verifyPr?.url ? ' <a href="' + escapeHtml(verifyPr.url) + '" target="_blank" onclick="event.stopPropagation()" style="color:var(--blue);text-decoration:underline;font-size:9px">' + escapeHtml(verifyPr.id || 'E2E PR') + '</a>' : '';
|
|
928
|
-
// Testing guide
|
|
929
|
-
|
|
932
|
+
// Testing guide. verifyGuides moved to /api/verify-guides
|
|
933
|
+
// (window._lastVerifyGuides set by refresh.js).
|
|
934
|
+
const guides = window._lastVerifyGuides || [];
|
|
930
935
|
const guide = guides.find(g => g.planFile === planFile);
|
|
931
936
|
const guideLink = guide ? ' <span onclick="event.stopPropagation();openVerifyGuide(\'' + escapeHtml(guide.file) + '\')" style="color:var(--green);cursor:pointer;text-decoration:underline;font-size:9px">Testing Guide</span>' : '';
|
|
932
937
|
return '<span style="font-size:9px;font-weight:600;color:' + color + ';padding:0 4px">' + label + '</span>' + prLink + guideLink;
|
|
@@ -564,8 +564,10 @@ function renderPrdProgress(prog) {
|
|
|
564
564
|
'</div>';
|
|
565
565
|
};
|
|
566
566
|
|
|
567
|
-
// E2E / verification PRs — group by sourcePlan
|
|
568
|
-
|
|
567
|
+
// E2E / verification PRs — group by sourcePlan. Issue #2949 — pullRequests
|
|
568
|
+
// moved off /api/status to /api/pull-requests; refresh.js stashes the fresh
|
|
569
|
+
// fetch into window._lastPullRequests.
|
|
570
|
+
const allPrs = window._lastPullRequests || [];
|
|
569
571
|
const e2eByPlan = {};
|
|
570
572
|
for (const pr of allPrs) {
|
|
571
573
|
// Match by explicit sourcePlan (robust), or fall back to title/branch heuristic (legacy)
|
|
@@ -576,8 +578,11 @@ function renderPrdProgress(prog) {
|
|
|
576
578
|
e2eByPlan[planKey].push(pr);
|
|
577
579
|
}
|
|
578
580
|
|
|
579
|
-
// Find testing guides in prd/ (verify-*.md files)
|
|
580
|
-
|
|
581
|
+
// Find testing guides in prd/ (verify-*.md files). Issue #2949 —
|
|
582
|
+
// verifyGuides moved off /api/status to /api/verify-guides; refresh.js
|
|
583
|
+
// fetches it alongside /api/prd in a Promise.all and stashes onto
|
|
584
|
+
// window._lastVerifyGuides.
|
|
585
|
+
const verifyGuides = window._lastVerifyGuides || [];
|
|
581
586
|
const guideByPlan = {};
|
|
582
587
|
for (const g of verifyGuides) {
|
|
583
588
|
guideByPlan[g.planFile] = g;
|
|
@@ -750,9 +755,11 @@ async function prdItemEdit(source, itemId) {
|
|
|
750
755
|
|
|
751
756
|
_prdDescRawCache = item.description || '';
|
|
752
757
|
|
|
753
|
-
// Look up work item and dispatch completion info
|
|
758
|
+
// Look up work item and dispatch completion info. Issue #2949 —
|
|
759
|
+
// dispatch moved off /api/status to /api/dispatch; refresh.js stashes
|
|
760
|
+
// the fresh queue into window._lastDispatch.
|
|
754
761
|
const wi = (window._lastWorkItems || []).find(w => w.id === itemId && w.sourcePlan === source);
|
|
755
|
-
const dispatch = window.
|
|
762
|
+
const dispatch = window._lastDispatch || {};
|
|
756
763
|
const completedEntry = (dispatch.completed || []).find(d =>
|
|
757
764
|
d.meta?.item?.id === itemId && d.meta?.item?.sourcePlan === source
|
|
758
765
|
);
|
|
@@ -4,6 +4,61 @@ let allPrs = [];
|
|
|
4
4
|
let prPage = 0;
|
|
5
5
|
const PR_PER_PAGE = 25;
|
|
6
6
|
|
|
7
|
+
// Fetch each project's pull-requests.json straight off disk through the
|
|
8
|
+
// /state/projects/<name>/pull-requests.json static-file passthrough. This
|
|
9
|
+
// is the issue #2949 escape hatch — the engine writes PR status / review
|
|
10
|
+
// state / build state directly into pull-requests.json, and the mtime-ETag
|
|
11
|
+
// on /state/ guarantees a fresh read on the next 4 s poll regardless of
|
|
12
|
+
// whether the /api/status outer cache has busted.
|
|
13
|
+
//
|
|
14
|
+
// Engine-side enrichment is minimal: stamping `_project` from the project
|
|
15
|
+
// name (the filename context) and back-filling `url` from `prUrlBase` when
|
|
16
|
+
// missing. Both are applied here to keep parity with the legacy slice.
|
|
17
|
+
// Other engine-derived fields (build status from CI poll, reviewStatus,
|
|
18
|
+
// _buildStatusStale) are PERSISTED to disk by the engine's PR pollers, so
|
|
19
|
+
// they survive the round-trip naturally.
|
|
20
|
+
async function fetchPullRequestsFromDisk(projects) {
|
|
21
|
+
if (!Array.isArray(projects) || !projects.length) return null;
|
|
22
|
+
const results = await Promise.all(projects.map(async (p) => {
|
|
23
|
+
const name = p && (p.name || p.path);
|
|
24
|
+
if (!name) return [];
|
|
25
|
+
const urlBase = (p && p.prUrlBase) || '';
|
|
26
|
+
try {
|
|
27
|
+
const r = await fetch('/state/projects/' + encodeURIComponent(name) + '/pull-requests.json');
|
|
28
|
+
if (!r.ok) return [];
|
|
29
|
+
const arr = await r.json();
|
|
30
|
+
if (!Array.isArray(arr)) return [];
|
|
31
|
+
return arr.map((pr) => {
|
|
32
|
+
if (!pr || typeof pr !== 'object' || !pr.id) return null;
|
|
33
|
+
const stamped = Object.assign({ _project: name }, pr);
|
|
34
|
+
if (!stamped.url && urlBase && typeof stamped.number === 'number') {
|
|
35
|
+
stamped.url = urlBase + stamped.number;
|
|
36
|
+
}
|
|
37
|
+
return stamped;
|
|
38
|
+
}).filter(Boolean);
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
const out = [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const arr of results) {
|
|
46
|
+
for (const pr of arr) {
|
|
47
|
+
if (seen.has(pr.id)) continue;
|
|
48
|
+
seen.add(pr.id);
|
|
49
|
+
out.push(pr);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Same sort the engine applies: most recent created first, fall back to id.
|
|
53
|
+
out.sort((a, b) => {
|
|
54
|
+
const ac = a.created || '';
|
|
55
|
+
const bc = b.created || '';
|
|
56
|
+
if (ac && bc) return bc.localeCompare(ac);
|
|
57
|
+
return (b.id || '').localeCompare(a.id || '');
|
|
58
|
+
});
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
7
62
|
function _countPrFollowups(pr) {
|
|
8
63
|
// PR follow-up chip (W-mpej3cox00099466) — counts WIs whose
|
|
9
64
|
// meta.pr_followup.parent_pr_url or parent_pr_id matches this PR.
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
// render-watches.js — Watch rendering and CRUD functions for the dashboard
|
|
2
2
|
|
|
3
|
+
// Pull watches.json straight off disk through /state/engine/watches.json
|
|
4
|
+
// instead of waiting on the /api/status outer cache (issue #2949). The
|
|
5
|
+
// engine writes watch status / _history transitions directly into
|
|
6
|
+
// watches.json, so mtime+size ETag surfaces them on the next 4 s poll.
|
|
7
|
+
// Falls back to data.watches when /state/ returns null.
|
|
8
|
+
async function fetchWatchesFromDisk() {
|
|
9
|
+
try {
|
|
10
|
+
const r = await fetch('/state/engine/watches.json');
|
|
11
|
+
if (!r.ok) return null;
|
|
12
|
+
const arr = await r.json();
|
|
13
|
+
return Array.isArray(arr) ? arr : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
4
20
|
|
|
5
21
|
const _WATCH_STATUS_BADGES = {
|
|
@@ -4,6 +4,76 @@ let allWorkItems = [];
|
|
|
4
4
|
let wiPage = 0;
|
|
5
5
|
const WI_PER_PAGE = 20;
|
|
6
6
|
|
|
7
|
+
// Engine-enrichment field names that live on the /api/status workItems slice
|
|
8
|
+
// but are NOT on disk in projects/<name>/work-items.json. The /state/ fetch
|
|
9
|
+
// recovers raw disk state — these get overlaid from the most recent
|
|
10
|
+
// /api/status snapshot (window._lastWorkItems) so the table still renders the
|
|
11
|
+
// PR chip, pending-reason badge, blockedBy hint, etc. The disk fields win
|
|
12
|
+
// for everything else, so a status flip from `pending` → `done` lands within
|
|
13
|
+
// one /state poll regardless of how stale /api/status got.
|
|
14
|
+
const _WI_ENRICHMENT_FIELDS = [
|
|
15
|
+
'_pr', '_prUrl', '_pendingReason', '_skipReason', '_blockedBy',
|
|
16
|
+
'_humanFeedback', '_reopened', '_managedSpawnPartial', '_securityFlag',
|
|
17
|
+
'_artifacts', 'referencesCount', 'acceptanceCriteriaCount',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Pull each project's work-items.json straight off disk through
|
|
21
|
+
// /state/projects/<name>/work-items.json. The endpoint is a static-file
|
|
22
|
+
// passthrough with mtime+size ETag — there is no server-side cache in the
|
|
23
|
+
// way, so an engine write to `status: 'done'` is visible to the next poll
|
|
24
|
+
// (issue #2949 root fix). Enrichment fields are overlaid from a cached
|
|
25
|
+
// id→item map sourced from window._lastWorkItems (the last /api/status
|
|
26
|
+
// snapshot). All fetches run in parallel; a single project's failure does
|
|
27
|
+
// not block the others.
|
|
28
|
+
async function fetchWorkItemsFromDisk(projects, cachedById) {
|
|
29
|
+
if (!Array.isArray(projects) || !projects.length) return null;
|
|
30
|
+
const results = await Promise.all(projects.map(async (p) => {
|
|
31
|
+
const name = p && (p.name || p.path);
|
|
32
|
+
if (!name) return [];
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch('/state/projects/' + encodeURIComponent(name) + '/work-items.json');
|
|
35
|
+
if (!r.ok) return [];
|
|
36
|
+
const arr = await r.json();
|
|
37
|
+
if (!Array.isArray(arr)) return [];
|
|
38
|
+
return arr.map((item) => {
|
|
39
|
+
if (!item || typeof item !== 'object') return null;
|
|
40
|
+
const stamped = Object.assign({}, item, { _source: name });
|
|
41
|
+
const cached = cachedById && cachedById.get(item.id);
|
|
42
|
+
if (cached) {
|
|
43
|
+
for (const k of _WI_ENRICHMENT_FIELDS) {
|
|
44
|
+
if (stamped[k] === undefined && cached[k] !== undefined) {
|
|
45
|
+
stamped[k] = cached[k];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// referencesCount / acceptanceCriteriaCount don't live on disk;
|
|
50
|
+
// derive from the raw arrays if present so the table icons render
|
|
51
|
+
// even before the cached snapshot catches up.
|
|
52
|
+
if (stamped.referencesCount == null && Array.isArray(stamped.references)) {
|
|
53
|
+
stamped.referencesCount = stamped.references.length;
|
|
54
|
+
}
|
|
55
|
+
if (stamped.acceptanceCriteriaCount == null && Array.isArray(stamped.acceptanceCriteria)) {
|
|
56
|
+
stamped.acceptanceCriteriaCount = stamped.acceptanceCriteria.length;
|
|
57
|
+
}
|
|
58
|
+
return stamped;
|
|
59
|
+
}).filter(Boolean);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
// Flatten + dedupe by id (a project rename mid-flight could double-list).
|
|
65
|
+
const out = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const arr of results) {
|
|
68
|
+
for (const item of arr) {
|
|
69
|
+
if (!item.id || seen.has(item.id)) continue;
|
|
70
|
+
seen.add(item.id);
|
|
71
|
+
out.push(item);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
7
77
|
// Track retry state per work item so loading/success/error survives re-renders
|
|
8
78
|
const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
|
|
9
79
|
function setWiRetryState(id, state) { _wiRetryState[id] = state; }
|
package/dashboard/js/settings.js
CHANGED
|
@@ -390,11 +390,9 @@ async function openSettings() {
|
|
|
390
390
|
'<div data-search="routing table playbook agent assignment" style="margin-bottom:16px">' +
|
|
391
391
|
'<textarea id="set-routing" rows="10" style="width:100%;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:monospace;font-size:var(--text-base);resize:vertical">' + escHtml(data.routing || '') + '</textarea>' +
|
|
392
392
|
'</div>' +
|
|
393
|
-
'<h4>Inbox
|
|
393
|
+
'<h4>Inbox</h4>' +
|
|
394
394
|
'<div class="settings-grid-2">' +
|
|
395
395
|
settingsField('Consolidation Threshold', 'set-inboxConsolidateThreshold', e.inboxConsolidateThreshold || 5, 'notes', 'Inbox notes before auto-consolidation') +
|
|
396
|
-
settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 0, 'days', 'Optional age-based trim for done/failed/cancelled work items in the /api/status workItems slice (active items are always shipped). Default 0 = no trim — the slim projection that strips description/AC/references already cuts the payload by ~80%. Set to a positive integer to also drop terminal items older than N days.') +
|
|
397
|
-
settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 0, 'days', 'Optional age-based trim for completed/archived meetings in the /api/status meetings slice (active meetings are always shipped). Default 0 = no trim — the slim projection that collapses findings/debate/transcript bodies to {agentId: true} sentinels already cuts the payload by ~80%. Set to a positive integer to also drop terminal meetings older than N days. Detail modal still fetches full bodies via /api/meetings/:id.') +
|
|
398
396
|
settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
|
|
399
397
|
'</div>' +
|
|
400
398
|
'<h4>Operator & Comments</h4>' +
|
|
@@ -841,8 +839,6 @@ async function saveSettings() {
|
|
|
841
839
|
restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
|
|
842
840
|
meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
|
|
843
841
|
operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
|
|
844
|
-
statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
|
|
845
|
-
statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
|
|
846
842
|
autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
|
|
847
843
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
848
844
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
package/dashboard/js/state.js
CHANGED
|
@@ -208,9 +208,15 @@ document.addEventListener('visibilitychange', function() {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
function rerenderPrdFromCache() {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
// Issue #2949 — prdProgress + prd moved off /api/status to /api/prd;
|
|
212
|
+
// refresh.js stashes the fresh fetch into window._lastPrdProgress +
|
|
213
|
+
// window._lastPrdStatus. `!== undefined` (not a truthy check) so a
|
|
214
|
+
// cache-was-null state (last PRD archived → /api/prd returned
|
|
215
|
+
// {progress: null}) still triggers a re-render — renderPrd handles
|
|
216
|
+
// null gracefully and paints the empty 'No PRD found' affordance.
|
|
217
|
+
if (window._lastPrdProgress === undefined) return;
|
|
218
|
+
renderPrdProgress(window._lastPrdProgress);
|
|
219
|
+
renderPrd(window._lastPrdStatus, window._lastPrdProgress);
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
// Global fetch wrapper with timeout — prevents connection exhaustion from hung requests.
|