@yemi33/minions 0.1.2087 → 0.1.2089
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/live-stream.js +6 -0
- package/dashboard/js/refresh.js +611 -164
- package/dashboard/js/render-agents.js +2 -0
- package/dashboard/js/render-dispatch.js +94 -6
- package/dashboard/js/render-inbox.js +82 -5
- package/dashboard/js/render-kb.js +9 -4
- package/dashboard/js/render-meetings.js +71 -11
- package/dashboard/js/render-pipelines.js +15 -5
- package/dashboard/js/render-plans.js +14 -9
- package/dashboard/js/render-prd.js +15 -6
- package/dashboard/js/render-prs.js +77 -9
- package/dashboard/js/render-schedules.js +8 -4
- package/dashboard/js/render-utils.js +33 -1
- package/dashboard/js/render-watches.js +23 -3
- package/dashboard/js/render-work-items.js +79 -9
- package/dashboard/js/settings.js +1 -5
- package/dashboard/js/state.js +9 -3
- package/dashboard/js/utils.js +4 -4
- package/dashboard-build.js +1 -1
- package/dashboard.js +400 -358
- package/docs/deprecated.json +0 -13
- 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
|
@@ -47,6 +47,7 @@ function _modelChipHtml(model) {
|
|
|
47
47
|
function renderAgents(agents) {
|
|
48
48
|
agentData = agents;
|
|
49
49
|
const grid = document.getElementById('agents-grid');
|
|
50
|
+
const scrollState = captureDashboardScrollState(grid, ['.agent-result']);
|
|
50
51
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml()/renderMd() (fields: agent id, emoji, name, status, role, lastAction, warning, resultSummary, blocking tool)
|
|
51
52
|
grid.innerHTML = agents.map(a => `
|
|
52
53
|
<div class="agent-card ${statusColor(a.status)}" data-agent-id="${escapeHtml(a.id)}" onclick="if(shouldIgnoreSelectionClick(event))return;openAgentDetail(this.dataset.agentId)">
|
|
@@ -67,6 +68,7 @@ function renderAgents(agents) {
|
|
|
67
68
|
${a.resultSummary ? `<div class="agent-result" title="${escapeHtml(a.resultSummary)}">${renderMd(a.resultSummary.slice(0, 200))}${a.resultSummary.length > 200 ? '...' : ''}</div>` : ''}
|
|
68
69
|
</div>
|
|
69
70
|
`).join('');
|
|
71
|
+
restoreDashboardScrollState(grid, scrollState);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
async function openAgentDetail(id) {
|
|
@@ -5,10 +5,69 @@ const LOG_PER_PAGE = 50;
|
|
|
5
5
|
let _completedPage = 0;
|
|
6
6
|
let _logPage = 0;
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
|
|
67
|
+
function _completedPrev() { if (_completedPage > 0) { _completedPage--; refresh({ preserveCompletedScroll: false }); } }
|
|
68
|
+
function _completedNext() { _completedPage++; refresh({ preserveCompletedScroll: false }); } // clamped in renderDispatch
|
|
69
|
+
function _logPrev() { if (_logPage > 0) { _logPage--; refresh({ preserveEngineLogScroll: false }); } }
|
|
70
|
+
function _logNext() { _logPage++; refresh({ preserveEngineLogScroll: false }); } // clamped in renderEngineLog
|
|
12
71
|
|
|
13
72
|
// Engine restart grace state (W-mpfw3hgm001gc594). After the operator clicks
|
|
14
73
|
// "Restart engine" we suppress the STALE indicators for ENGINE_RESTART_GRACE_MS
|
|
@@ -186,7 +245,8 @@ function renderGhThrottleAlert(ghThrottle) {
|
|
|
186
245
|
el.style.display = 'flex';
|
|
187
246
|
}
|
|
188
247
|
|
|
189
|
-
function renderDispatch(dispatch) {
|
|
248
|
+
function renderDispatch(dispatch, opts) {
|
|
249
|
+
opts = opts || {};
|
|
190
250
|
if (!dispatch) return;
|
|
191
251
|
|
|
192
252
|
// Stats
|
|
@@ -235,6 +295,9 @@ function renderDispatch(dispatch) {
|
|
|
235
295
|
// Completed
|
|
236
296
|
const completedEl = document.getElementById('completed-content');
|
|
237
297
|
const completedCount = document.getElementById('completed-count');
|
|
298
|
+
const completedScroll = opts.preserveCompletedScroll === false
|
|
299
|
+
? null
|
|
300
|
+
: captureDashboardScrollState(completedEl, [':self', '.pr-table-wrap']);
|
|
238
301
|
const completed = (dispatch.completed || []).slice().reverse();
|
|
239
302
|
completedCount.textContent = completed.length;
|
|
240
303
|
|
|
@@ -272,13 +335,37 @@ function renderDispatch(dispatch) {
|
|
|
272
335
|
} else {
|
|
273
336
|
completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
|
|
274
337
|
}
|
|
338
|
+
restoreDashboardScrollState(completedEl, completedScroll);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Pull the engine log straight off disk through /state/engine/log.json.
|
|
342
|
+
// On-disk file is the full append-only array; engine/queries.js getEngineLog
|
|
343
|
+
// slices to the last 50 for /api/status. We replicate that slice client-side
|
|
344
|
+
// after the disk fetch. ETag/304 keeps the steady state cheap; a new log
|
|
345
|
+
// line forces a fresh download (file is ~500 KB at high engine traffic, but
|
|
346
|
+
// it changes only on engine writes — not on every 4 s poll).
|
|
347
|
+
async function fetchEngineLogFromDisk() {
|
|
348
|
+
try {
|
|
349
|
+
const r = await fetch('/state/engine/log.json');
|
|
350
|
+
if (!r.ok) return null;
|
|
351
|
+
const raw = await r.json();
|
|
352
|
+
const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : []);
|
|
353
|
+
return arr.slice(-50);
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
275
357
|
}
|
|
276
358
|
|
|
277
|
-
function renderEngineLog(log) {
|
|
359
|
+
function renderEngineLog(log, opts) {
|
|
360
|
+
opts = opts || {};
|
|
278
361
|
const el = document.getElementById('engine-log');
|
|
279
362
|
if (!el) return;
|
|
363
|
+
const scrollState = opts.preserveEngineLogScroll === false
|
|
364
|
+
? null
|
|
365
|
+
: captureDashboardScrollState(el, [':self']);
|
|
280
366
|
if (!log || log.length === 0) {
|
|
281
367
|
el.innerHTML = '<div class="empty">No log entries yet.</div>';
|
|
368
|
+
restoreDashboardScrollState(el, scrollState);
|
|
282
369
|
return;
|
|
283
370
|
}
|
|
284
371
|
const reversed = log.slice().reverse();
|
|
@@ -303,6 +390,7 @@ function renderEngineLog(log) {
|
|
|
303
390
|
});
|
|
304
391
|
// eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
|
|
305
392
|
if (logPager) el.insertAdjacentHTML('beforeend', logPager);
|
|
393
|
+
restoreDashboardScrollState(el, scrollState);
|
|
306
394
|
}
|
|
307
395
|
|
|
308
396
|
function shortTime(t) {
|
|
@@ -3,10 +3,83 @@
|
|
|
3
3
|
const INBOX_PER_PAGE = 15;
|
|
4
4
|
let _inboxPage = 0;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
|
|
78
|
+
function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxData, { preserveScroll: false }); } }
|
|
79
|
+
function _inboxNext() { _inboxPage++; renderInbox(inboxData, { preserveScroll: false }); }
|
|
8
80
|
|
|
9
|
-
function renderInbox(inbox) {
|
|
81
|
+
function renderInbox(inbox, opts) {
|
|
82
|
+
opts = opts || {};
|
|
10
83
|
invalidatePinsCache();
|
|
11
84
|
inbox = inbox.filter(function(item) { return !isDeleted('inbox:' + item.name); });
|
|
12
85
|
// Stable sort — pinned items float to top
|
|
@@ -17,7 +90,8 @@ function renderInbox(inbox) {
|
|
|
17
90
|
const list = document.getElementById('inbox-list');
|
|
18
91
|
const count = document.getElementById('inbox-count');
|
|
19
92
|
count.textContent = inbox.length;
|
|
20
|
-
|
|
93
|
+
const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(list, [':self']);
|
|
94
|
+
if (!inbox.length) { list.innerHTML = '<p class="empty">No messages yet.</p>'; restoreDashboardScrollState(list, scrollState); return; }
|
|
21
95
|
|
|
22
96
|
const totalInboxPages = Math.ceil(inbox.length / INBOX_PER_PAGE);
|
|
23
97
|
if (_inboxPage >= totalInboxPages) _inboxPage = totalInboxPages - 1;
|
|
@@ -50,6 +124,7 @@ function renderInbox(inbox) {
|
|
|
50
124
|
});
|
|
51
125
|
// eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
|
|
52
126
|
if (inboxPager) list.insertAdjacentHTML('beforeend', inboxPager);
|
|
127
|
+
restoreDashboardScrollState(list, scrollState);
|
|
53
128
|
restoreNotifBadges();
|
|
54
129
|
}
|
|
55
130
|
|
|
@@ -78,6 +153,7 @@ function renderNotes(notes) {
|
|
|
78
153
|
const el = document.getElementById('notes-list');
|
|
79
154
|
const content = typeof notes === 'object' ? notes.content : notes;
|
|
80
155
|
const updatedAt = typeof notes === 'object' ? notes.updatedAt : null;
|
|
156
|
+
const scrollState = captureDashboardScrollState(el, ['.notes-preview']);
|
|
81
157
|
|
|
82
158
|
// Show last updated timestamp
|
|
83
159
|
const updatedEl = document.getElementById('notes-updated');
|
|
@@ -85,9 +161,10 @@ function renderNotes(notes) {
|
|
|
85
161
|
updatedEl.textContent = 'updated ' + formatLocalDateTime(updatedAt);
|
|
86
162
|
}
|
|
87
163
|
|
|
88
|
-
if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; return; }
|
|
164
|
+
if (!content || !content.trim()) { el.innerHTML = '<p class="empty">No team notes yet.</p>'; restoreDashboardScrollState(el, scrollState); return; }
|
|
89
165
|
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled fields before assembling HTML (see dashboard/js/utils.js)
|
|
90
166
|
el.innerHTML = '<div class="notes-preview" data-file="notes.md" onclick="openNotesModal()" title="Click to expand">' + renderMd(content) + '</div>';
|
|
167
|
+
restoreDashboardScrollState(el, scrollState);
|
|
91
168
|
el.querySelector('.notes-preview')._rawContent = content;
|
|
92
169
|
restoreNotifBadges();
|
|
93
170
|
}
|
|
@@ -17,8 +17,8 @@ let _kbActiveTab = 'all';
|
|
|
17
17
|
const KB_PER_PAGE = 30;
|
|
18
18
|
let _kbPage = 0;
|
|
19
19
|
|
|
20
|
-
function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase(); } }
|
|
21
|
-
function _kbNext() { _kbPage++; renderKnowledgeBase(); }
|
|
20
|
+
function _kbPrev() { if (_kbPage > 0) { _kbPage--; renderKnowledgeBase({ preserveScroll: false }); } }
|
|
21
|
+
function _kbNext() { _kbPage++; renderKnowledgeBase({ preserveScroll: false }); }
|
|
22
22
|
|
|
23
23
|
function _formatSweepElapsed(startedAt) {
|
|
24
24
|
if (!startedAt) return '';
|
|
@@ -58,11 +58,13 @@ function kbPinnedNewestFirst(a, b) {
|
|
|
58
58
|
return kbNewestFirst(a, b);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function renderKnowledgeBase() {
|
|
61
|
+
function renderKnowledgeBase(opts) {
|
|
62
|
+
opts = opts || {};
|
|
62
63
|
_syncPinsFromServer();
|
|
63
64
|
const tabsEl = document.getElementById('kb-tabs');
|
|
64
65
|
const listEl = document.getElementById('kb-list');
|
|
65
66
|
const countEl = document.getElementById('kb-count');
|
|
67
|
+
const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(listEl, [':self']);
|
|
66
68
|
|
|
67
69
|
// Single pass: flatten all KB items and count pinned
|
|
68
70
|
const allItems = [];
|
|
@@ -92,6 +94,7 @@ function renderKnowledgeBase() {
|
|
|
92
94
|
if (allItems.length === 0) {
|
|
93
95
|
tabsEl.innerHTML = '';
|
|
94
96
|
listEl.innerHTML = '<p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p>';
|
|
97
|
+
restoreDashboardScrollState(listEl, scrollState);
|
|
95
98
|
return;
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -126,6 +129,7 @@ function renderKnowledgeBase() {
|
|
|
126
129
|
|
|
127
130
|
if (items.length === 0) {
|
|
128
131
|
listEl.innerHTML = '<p class="empty">No entries in this category.</p>';
|
|
132
|
+
restoreDashboardScrollState(listEl, scrollState);
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -163,13 +167,14 @@ function renderKnowledgeBase() {
|
|
|
163
167
|
});
|
|
164
168
|
// eslint-disable-next-line no-unsanitized/method -- reason: composed from internal numeric page bounds and fixed renderPager() callback names (no user data flows in)
|
|
165
169
|
if (kbPager) listEl.insertAdjacentHTML('beforeend', kbPager);
|
|
170
|
+
restoreDashboardScrollState(listEl, scrollState);
|
|
166
171
|
restoreNotifBadges();
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
function kbSetTab(tab) {
|
|
170
175
|
_kbActiveTab = tab;
|
|
171
176
|
_kbPage = 0;
|
|
172
|
-
renderKnowledgeBase();
|
|
177
|
+
renderKnowledgeBase({ preserveScroll: false });
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
async function kbSweep() {
|
|
@@ -7,16 +7,74 @@ let _lastMeetingHash = '';
|
|
|
7
7
|
let _lastMeetingsForPaging = [];
|
|
8
8
|
let _mtgTotalPages = 1;
|
|
9
9
|
|
|
10
|
-
|
|
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
|
+
|
|
65
|
+
function renderMeetings(meetings, opts) {
|
|
66
|
+
opts = opts || {};
|
|
11
67
|
meetings = (meetings || []).filter(function(m) { return !isDeleted('mtg:' + m.id); });
|
|
12
68
|
meetings.sort((a, b) => (b.createdAt || b.completedAt || '').localeCompare(a.createdAt || a.completedAt || ''));
|
|
13
69
|
_lastMeetingsForPaging = meetings;
|
|
14
70
|
const el = document.getElementById('meetings-content');
|
|
15
71
|
const countEl = document.getElementById('meetings-count');
|
|
72
|
+
const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, [':self']);
|
|
16
73
|
if (!meetings || meetings.length === 0) {
|
|
17
74
|
countEl.textContent = '0';
|
|
18
75
|
el.innerHTML = '<p class="empty">No meetings yet. Start one to have agents investigate, debate, and conclude on a topic.</p>';
|
|
19
76
|
_mtgTotalPages = 1;
|
|
77
|
+
restoreDashboardScrollState(el, scrollState);
|
|
20
78
|
return;
|
|
21
79
|
}
|
|
22
80
|
|
|
@@ -33,6 +91,7 @@ function renderMeetings(meetings) {
|
|
|
33
91
|
// eslint-disable-next-line no-unsanitized/method -- reason: composed from internal archived count and fixed toggle markup (no user data flows in)
|
|
34
92
|
if (archived.length) el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">Show ' + archived.length + ' archived</button></div>');
|
|
35
93
|
_mtgTotalPages = 1;
|
|
94
|
+
restoreDashboardScrollState(el, scrollState);
|
|
36
95
|
return;
|
|
37
96
|
}
|
|
38
97
|
|
|
@@ -88,29 +147,30 @@ function renderMeetings(meetings) {
|
|
|
88
147
|
el.insertAdjacentHTML('beforeend', '<div style="text-align:center;margin-top:8px"><button class="pr-pager-btn" style="font-size:10px" onclick="_toggleArchivedMeetings()">' +
|
|
89
148
|
(_showArchived ? 'Hide' : 'Show') + ' ' + archived.length + ' archived</button></div>');
|
|
90
149
|
}
|
|
150
|
+
restoreDashboardScrollState(el, scrollState);
|
|
91
151
|
restoreNotifBadges();
|
|
92
152
|
}
|
|
93
153
|
|
|
94
|
-
function _rerenderMeetingPageFromCache() {
|
|
95
|
-
renderMeetings(_lastMeetingsForPaging || []);
|
|
154
|
+
function _rerenderMeetingPageFromCache(opts) {
|
|
155
|
+
renderMeetings(_lastMeetingsForPaging || [], opts);
|
|
96
156
|
}
|
|
97
157
|
function _mtgPrev() {
|
|
98
158
|
if (_mtgPage > 0) {
|
|
99
159
|
_mtgPage--;
|
|
100
|
-
_rerenderMeetingPageFromCache();
|
|
160
|
+
_rerenderMeetingPageFromCache({ preserveScroll: false });
|
|
101
161
|
}
|
|
102
162
|
}
|
|
103
163
|
function _mtgNext() {
|
|
104
164
|
if (_mtgPage < _mtgTotalPages - 1) {
|
|
105
165
|
_mtgPage++;
|
|
106
|
-
_rerenderMeetingPageFromCache();
|
|
166
|
+
_rerenderMeetingPageFromCache({ preserveScroll: false });
|
|
107
167
|
}
|
|
108
168
|
}
|
|
109
169
|
|
|
110
170
|
function _toggleArchivedMeetings() {
|
|
111
171
|
_showArchived = !_showArchived;
|
|
112
172
|
_mtgPage = 0;
|
|
113
|
-
_rerenderMeetingPageFromCache();
|
|
173
|
+
_rerenderMeetingPageFromCache({ preserveScroll: false });
|
|
114
174
|
}
|
|
115
175
|
|
|
116
176
|
let _meetingPollInterval = null;
|
|
@@ -149,14 +209,14 @@ function _renderMeetingDetail(m) {
|
|
|
149
209
|
if (m.findings?.[agent]) {
|
|
150
210
|
html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
|
|
151
211
|
'<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 1 — Findings</div>' +
|
|
152
|
-
'<div style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.findings[agent].content || '') + '</div></div>';
|
|
212
|
+
'<div class="meeting-scroll-panel" style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.findings[agent].content || '') + '</div></div>';
|
|
153
213
|
}
|
|
154
214
|
|
|
155
215
|
// Debate
|
|
156
216
|
if (m.debate?.[agent]) {
|
|
157
217
|
html += '<div style="padding:8px 12px;font-size:11px;border-bottom:1px solid var(--border)">' +
|
|
158
218
|
'<div style="color:var(--muted);font-size:10px;margin-bottom:4px">Round 2 — Debate</div>' +
|
|
159
|
-
'<div style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.debate[agent].content || '') + '</div></div>';
|
|
219
|
+
'<div class="meeting-scroll-panel" style="word-break:break-word;max-height:300px;overflow-y:auto">' + renderMd(m.debate[agent].content || '') + '</div></div>';
|
|
160
220
|
}
|
|
161
221
|
|
|
162
222
|
// Status
|
|
@@ -171,7 +231,7 @@ function _renderMeetingDetail(m) {
|
|
|
171
231
|
if (m.conclusion) {
|
|
172
232
|
html += '<div style="background:rgba(63,185,80,0.08);border:1px solid var(--green);border-radius:6px;padding:10px 14px">' +
|
|
173
233
|
'<div style="color:var(--green);font-weight:600;font-size:12px;margin-bottom:6px">Conclusion (by ' + escHtml(m.conclusion.agent || '?') + ')</div>' +
|
|
174
|
-
'<div style="font-size:12px;word-break:break-word;max-height:400px;overflow-y:auto">' + renderMd(m.conclusion.content || '') + '</div></div>';
|
|
234
|
+
'<div class="meeting-scroll-panel" style="font-size:12px;word-break:break-word;max-height:400px;overflow-y:auto">' + renderMd(m.conclusion.content || '') + '</div></div>';
|
|
175
235
|
}
|
|
176
236
|
|
|
177
237
|
// Human notes
|
|
@@ -216,12 +276,12 @@ function _renderMeetingDetail(m) {
|
|
|
216
276
|
|
|
217
277
|
document.getElementById('modal-title').textContent = 'Meeting: ' + m.title;
|
|
218
278
|
var body = document.getElementById('modal-body');
|
|
219
|
-
var
|
|
279
|
+
var scrollState = captureDashboardScrollState(body, [':self', '.meeting-scroll-panel']);
|
|
220
280
|
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes all user-controlled markdown fields before assembling HTML (see dashboard/js/utils.js); ids and labels use escHtml()
|
|
221
281
|
body.innerHTML = html;
|
|
222
282
|
body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
223
283
|
body.style.whiteSpace = 'normal';
|
|
224
|
-
body
|
|
284
|
+
restoreDashboardScrollState(body, scrollState);
|
|
225
285
|
|
|
226
286
|
// Wire up doc-chat Q&A panel for the meeting transcript
|
|
227
287
|
const transcript = (m.transcript || []).map(t =>
|
|
@@ -290,16 +290,19 @@ function _renderPipelineTriggerLabel(cron) {
|
|
|
290
290
|
return escHtml(human) + ' <span style="opacity:0.6">(' + escHtml(tz) + ')</span>';
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
function renderPipelines(pipelines) {
|
|
293
|
+
function renderPipelines(pipelines, opts) {
|
|
294
|
+
opts = opts || {};
|
|
294
295
|
pipelines = (pipelines || []).filter(function(p) { return !isDeleted('pipeline:' + p.id); });
|
|
295
296
|
_pipelinesData = pipelines;
|
|
296
297
|
const el = document.getElementById('pipelines-content');
|
|
297
298
|
const countEl = document.getElementById('pipelines-count');
|
|
298
299
|
if (!el) return;
|
|
300
|
+
const scrollState = opts.preserveScroll === false ? null : captureDashboardScrollState(el, ['.pl-node-chain']);
|
|
299
301
|
if (!pipelines || pipelines.length === 0) {
|
|
300
302
|
countEl.textContent = '0';
|
|
301
303
|
el.innerHTML = '<p class="empty">No pipelines yet. Create one to chain stages like audit \u2192 meeting \u2192 plan \u2192 merge.</p>';
|
|
302
304
|
_pipelineTotalPages = 1;
|
|
305
|
+
restoreDashboardScrollState(el, scrollState);
|
|
303
306
|
return;
|
|
304
307
|
}
|
|
305
308
|
countEl.textContent = pipelines.length;
|
|
@@ -360,23 +363,29 @@ function renderPipelines(pipelines) {
|
|
|
360
363
|
'<button class="pr-pager-btn ' + (_pipelinePage >= totalPages - 1 ? 'disabled' : '') + '" onclick="_pipelineNext()">Next</button>' +
|
|
361
364
|
'</div></div>');
|
|
362
365
|
}
|
|
366
|
+
restoreDashboardScrollState(el, scrollState);
|
|
363
367
|
}
|
|
364
368
|
|
|
365
369
|
function _pipelinePrev() {
|
|
366
370
|
if (_pipelinePage > 0) {
|
|
367
371
|
_pipelinePage--;
|
|
368
|
-
renderPipelines(_pipelinesData);
|
|
372
|
+
renderPipelines(_pipelinesData, { preserveScroll: false });
|
|
369
373
|
}
|
|
370
374
|
}
|
|
371
375
|
|
|
372
376
|
function _pipelineNext() {
|
|
373
377
|
if (_pipelinePage < _pipelineTotalPages - 1) {
|
|
374
378
|
_pipelinePage++;
|
|
375
|
-
renderPipelines(_pipelinesData);
|
|
379
|
+
renderPipelines(_pipelinesData, { preserveScroll: false });
|
|
376
380
|
}
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
function openPipelineDetail(id) {
|
|
384
|
+
var modalBody = document.getElementById('modal-body');
|
|
385
|
+
var preserveModalScroll = document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id;
|
|
386
|
+
var modalScrollState = preserveModalScroll
|
|
387
|
+
? captureDashboardScrollState(modalBody, [':self', '.pl-node-chain', '.pipeline-stage-output'])
|
|
388
|
+
: null;
|
|
380
389
|
_stopPipelinePoll();
|
|
381
390
|
var p = _pipelinesData.find(function(x) { return x.id === id; });
|
|
382
391
|
if (!p) { alert('Pipeline not found'); return; }
|
|
@@ -444,7 +453,7 @@ function openPipelineDetail(id) {
|
|
|
444
453
|
'</div>' +
|
|
445
454
|
_renderMonitoredResources(s.monitoredResources || []) +
|
|
446
455
|
_renderArtifactLinks(stageRun.artifacts, id) +
|
|
447
|
-
(stageRun.output ? '<div style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
|
|
456
|
+
(stageRun.output ? '<div class="pipeline-stage-output" style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
|
|
448
457
|
(stageStatus === 'waiting-human' ? '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green);margin-top:6px" onclick="_continuePipeline(\'' + escHtml(id) + '\',\'' + escHtml(s.id) + '\',this)">Continue</button>' : '') +
|
|
449
458
|
'</div>';
|
|
450
459
|
});
|
|
@@ -477,7 +486,8 @@ function openPipelineDetail(id) {
|
|
|
477
486
|
|
|
478
487
|
document.getElementById('modal-title').textContent = 'Pipeline: ' + displayTitle;
|
|
479
488
|
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes user-controlled stage output before assembling HTML; all other user data wrapped in escHtml() (see dashboard/js/utils.js)
|
|
480
|
-
|
|
489
|
+
modalBody.innerHTML = html;
|
|
490
|
+
restoreDashboardScrollState(modalBody, modalScrollState);
|
|
481
491
|
document.getElementById('modal').classList.add('open');
|
|
482
492
|
|
|
483
493
|
// Live-poll while modal is open — always poll (not just active runs)
|
|
@@ -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;
|