@yemi33/minions 0.1.2086 → 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 +557 -351
- 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
package/dashboard.js
CHANGED
|
@@ -73,11 +73,6 @@ const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
|
|
|
73
73
|
|
|
74
74
|
const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
|
|
75
75
|
let CONFIG = queries.getConfig();
|
|
76
|
-
// Mirror cli.js: clear persisted statusWorkItemsRetentionDays=7 + the matching
|
|
77
|
-
// meetings field (prior default 7) so the new default (0, no trim) reaches the
|
|
78
|
-
// /api/status slimmers without needing an engine bounce first.
|
|
79
|
-
try { shared.applyStatusWorkItemsRetentionMigration(CONFIG); } catch { /* best-effort */ }
|
|
80
|
-
try { shared.applyStatusMeetingsRetentionMigration(CONFIG); } catch { /* best-effort */ }
|
|
81
76
|
let PROJECTS = _getProjects(CONFIG);
|
|
82
77
|
const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
|
|
83
78
|
const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
|
|
@@ -100,8 +95,6 @@ function ensureConfiguredProjectStateFiles() {
|
|
|
100
95
|
|
|
101
96
|
function reloadConfig() {
|
|
102
97
|
CONFIG = queries.getConfig();
|
|
103
|
-
try { shared.applyStatusWorkItemsRetentionMigration(CONFIG); } catch { /* best-effort */ }
|
|
104
|
-
try { shared.applyStatusMeetingsRetentionMigration(CONFIG); } catch { /* best-effort */ }
|
|
105
98
|
PROJECTS = _getProjects(CONFIG);
|
|
106
99
|
ensureConfiguredProjectStateFiles();
|
|
107
100
|
}
|
|
@@ -1744,6 +1737,14 @@ function invalidateStatusCache(_opts) {
|
|
|
1744
1737
|
function _buildStatusFastState() {
|
|
1745
1738
|
// reloadConfig is called by both sync and async callers before this — kept
|
|
1746
1739
|
// outside so the async path can yield between reload and rebuild.
|
|
1740
|
+
//
|
|
1741
|
+
// Issue #2949 — the heavy slices (agents/workItems/pullRequests/dispatch/
|
|
1742
|
+
// metrics/inbox/notes/engineLog/watches/meetings/qaRuns/qaSessions/
|
|
1743
|
+
// meetingsTotal) all moved to dedicated /api/<x> endpoints or /state/<path>
|
|
1744
|
+
// static-file passthrough. They're not in this snapshot anymore — every
|
|
1745
|
+
// page now fetches its own dedicated endpoint with input-mtime ETag, so
|
|
1746
|
+
// there's no outer cache that can hold a fresh disk write hostage.
|
|
1747
|
+
// /api/status is now just the small live engine/throttle envelope.
|
|
1747
1748
|
const engineState = getEngineState();
|
|
1748
1749
|
const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
|
|
1749
1750
|
// W-mpof1xxe000ac689 — Two-signal liveness check. heartbeat (15s cadence,
|
|
@@ -1752,309 +1753,38 @@ function _buildStatusFastState() {
|
|
|
1752
1753
|
// heartbeat writer silently dies but tickInner keeps running, the heartbeat
|
|
1753
1754
|
// ages past 120s while lastTickAt stays fresh — the dashboard used to lie
|
|
1754
1755
|
// ("STALE") on a provably-ticking engine. Engine is alive iff ANY signal is
|
|
1755
|
-
// fresh; only when BOTH age past threshold do we trip the badge.
|
|
1756
|
-
// threshold is floored by ENGINE_HEARTBEAT_STALE_MS so a misconfigured 5s
|
|
1757
|
-
// tickInterval cannot trip the boolean in 10s.
|
|
1756
|
+
// fresh; only when BOTH age past threshold do we trip the badge.
|
|
1758
1757
|
const tickInterval = Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval;
|
|
1759
1758
|
const tickStaleThresholdMs = Math.max(ENGINE_HEARTBEAT_STALE_MS, 2 * tickInterval);
|
|
1760
1759
|
const tickAge = engineState.lastTickAt ? Date.now() - engineState.lastTickAt : Infinity;
|
|
1761
1760
|
return {
|
|
1762
|
-
agents: getAgents(),
|
|
1763
|
-
inbox: getInbox(),
|
|
1764
|
-
notes: getNotesWithMeta(),
|
|
1765
|
-
pullRequests: getPullRequests(),
|
|
1766
1761
|
engine: {
|
|
1767
1762
|
...engineState,
|
|
1768
1763
|
worktreeCount: _countWorktrees(),
|
|
1769
|
-
// Snapshot-time staleness. Frozen with the snapshot; the client renders
|
|
1770
|
-
// off these instead of recomputing Date.now() - heartbeat against a
|
|
1771
|
-
// possibly-cached payload (false-positive banner regression #2754).
|
|
1772
1764
|
heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
|
|
1773
1765
|
heartbeatStale: !!(engineState.heartbeat
|
|
1774
1766
|
&& hbAge > ENGINE_HEARTBEAT_STALE_MS
|
|
1775
1767
|
&& tickAge > tickStaleThresholdMs),
|
|
1776
|
-
// W-mpnc4u8c001d9d6c — Surface the tick cadence so the client's
|
|
1777
|
-
// #engine-quick-stats "Next tick in Xs" countdown reads the SAME value
|
|
1778
|
-
// the engine actually uses (Settings parity rule). Paired with
|
|
1779
|
-
// control.lastTickAt (above) stamped at the start of every tickInner.
|
|
1780
1768
|
tickInterval: tickInterval,
|
|
1781
1769
|
},
|
|
1782
1770
|
adoThrottle: ado.getAdoThrottleState(),
|
|
1783
1771
|
ghThrottle: gh.getGhThrottleState(),
|
|
1784
|
-
dispatch: getDispatchQueue(),
|
|
1785
|
-
engineLog: getEngineLog(),
|
|
1786
|
-
metrics: getMetrics(),
|
|
1787
|
-
workItems: _slimWorkItemsForStatus(getWorkItems()),
|
|
1788
|
-
watches: watchesMod.getWatches(),
|
|
1789
|
-
meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
|
|
1790
|
-
// Top-level full meeting count (NOT slim slice length). Surfaced so the
|
|
1791
|
-
// sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
|
|
1792
|
-
// still fires when ANY meeting — including old/archived ones dropped from
|
|
1793
|
-
// the slim slice by statusMeetingsRetentionDays — gains a new round.
|
|
1794
|
-
// Without this, completing the third round of an archived meeting would
|
|
1795
|
-
// silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
|
|
1796
|
-
meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
|
|
1797
|
-
// QA runs — surfaced for the sidebar activity-dot counter and any future
|
|
1798
|
-
// CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
|
|
1799
|
-
// (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
|
|
1800
|
-
// so a new run lights the dot within one /api/status poll cycle (~4 s).
|
|
1801
|
-
// Uses the unsorted summary helper rather than listRuns({limit:50}) — the
|
|
1802
|
-
// latter reads + sorts the FULL qa-runs.json on every fast-state rebuild
|
|
1803
|
-
// and would charge O(N log N) to the /api/status hot path. The summary
|
|
1804
|
-
// helper returns { total, sig } without sorting; that's all the sidebar
|
|
1805
|
-
// counter needs to detect new runs and status flips.
|
|
1806
|
-
qaRuns: _safeStatusSlice('qaRuns', () => qaRunsMod.summarizeRunsForStatus(), { total: 0, sig: '' }),
|
|
1807
|
-
// QA sessions — same role as qaRuns above. The sidebar activity-dot
|
|
1808
|
-
// for QA Sessions polls this slice for the cheap { total, sig } summary
|
|
1809
|
-
// so a new session or a state transition lights the dot within one
|
|
1810
|
-
// /api/status cycle. summarizeSessionsForStatus is unsorted (mirrors
|
|
1811
|
-
// qaRunsMod.summarizeRunsForStatus) so this stays O(N) and doesn't
|
|
1812
|
-
// charge sort cost to the /api/status hot path.
|
|
1813
|
-
qaSessions: _safeStatusSlice('qaSessions', () => {
|
|
1814
|
-
const qaSessions = require('./engine/qa-sessions');
|
|
1815
|
-
return qaSessions.summarizeSessionsForStatus();
|
|
1816
|
-
}, { total: 0, sig: '' }),
|
|
1817
1772
|
};
|
|
1818
1773
|
}
|
|
1819
1774
|
|
|
1820
|
-
// Run a status-slice producer with rate-limited error logging. The lazy-
|
|
1821
|
-
// require IIFEs this replaces silently degraded to fallback values if the
|
|
1822
|
-
// underlying module had a syntax error or got renamed — sidebar dots went
|
|
1823
|
-
// dark with no diagnostic. We keep returning the fallback so the rest of
|
|
1824
|
-
// the status payload survives, but we log once per minute per slice so a
|
|
1825
|
-
// persistent error doesn't spam the engine log AND isn't invisible.
|
|
1826
|
-
const _statusSliceErrorLastLogged = new Map();
|
|
1827
|
-
function _safeStatusSlice(name, fn, fallback) {
|
|
1828
|
-
try {
|
|
1829
|
-
return fn();
|
|
1830
|
-
} catch (e) {
|
|
1831
|
-
const now = Date.now();
|
|
1832
|
-
const last = _statusSliceErrorLastLogged.get(name) || 0;
|
|
1833
|
-
if (now - last > 60000) {
|
|
1834
|
-
_statusSliceErrorLastLogged.set(name, now);
|
|
1835
|
-
console.error('[status] slice ' + name + ' failed: ' + (e && e.message || e));
|
|
1836
|
-
}
|
|
1837
|
-
return fallback;
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
// ── /api/status workItems slimming (W-mphejzmj000718bf) ─────────────────────
|
|
1842
|
-
// Project each work item onto a narrow shape that omits the large free-text
|
|
1843
|
-
// fields (description, full acceptanceCriteria, full references) before
|
|
1844
|
-
// shipping on /api/status. The dashboard never renders description/AC/
|
|
1845
|
-
// references-detail off the cached slice — `wiRow` only needs counts +
|
|
1846
|
-
// status/badge fields, and `openWorkItemDetail` fetches the full record on
|
|
1847
|
-
// demand via GET /api/work-items/<id>. This slim projection is the bulk of
|
|
1848
|
-
// the payload savings (~3MB → <500KB typical) and runs unconditionally.
|
|
1849
|
-
//
|
|
1850
|
-
// engine.statusWorkItemsRetentionDays is an optional second-tier filter that
|
|
1851
|
-
// drops terminal (done/failed/cancelled) items older than N days. Default 0
|
|
1852
|
-
// = no trim (legacy behavior, full list shipped). Set to a positive integer
|
|
1853
|
-
// to opt into the date-based trim. Active items (pending/dispatched/queued)
|
|
1854
|
-
// are ALWAYS kept regardless of age.
|
|
1855
|
-
const _ACTIVE_WI_STATUSES_FOR_STATUS = new Set(['pending', 'dispatched', 'queued', 'paused', 'decomposed']);
|
|
1856
|
-
const _TERMINAL_WI_STATUSES_FOR_STATUS = new Set(['done', 'failed', 'cancelled']);
|
|
1857
|
-
function _resolveStatusWorkItemsRetentionDays() {
|
|
1858
|
-
const raw = CONFIG?.engine?.statusWorkItemsRetentionDays;
|
|
1859
|
-
if (raw === 0 || raw === '0') return 0;
|
|
1860
|
-
const n = Number(raw);
|
|
1861
|
-
if (Number.isFinite(n) && n >= 0) return n;
|
|
1862
|
-
return shared.ENGINE_DEFAULTS.statusWorkItemsRetentionDays;
|
|
1863
|
-
}
|
|
1864
|
-
function _slimWorkItemForStatus(item) {
|
|
1865
|
-
const slim = {
|
|
1866
|
-
id: item.id,
|
|
1867
|
-
title: item.title,
|
|
1868
|
-
status: item.status,
|
|
1869
|
-
type: item.type,
|
|
1870
|
-
priority: item.priority,
|
|
1871
|
-
_source: item._source,
|
|
1872
|
-
created: item.created,
|
|
1873
|
-
};
|
|
1874
|
-
if (item.dispatched_to !== undefined) slim.dispatched_to = item.dispatched_to;
|
|
1875
|
-
if (item.agent !== undefined) slim.agent = item.agent;
|
|
1876
|
-
if (item._pr !== undefined) slim._pr = item._pr;
|
|
1877
|
-
if (item._prUrl !== undefined) slim._prUrl = item._prUrl;
|
|
1878
|
-
if (item._skipReason !== undefined) slim._skipReason = item._skipReason;
|
|
1879
|
-
if (item._blockedBy !== undefined) slim._blockedBy = item._blockedBy;
|
|
1880
|
-
if (item._humanFeedback !== undefined) slim._humanFeedback = item._humanFeedback;
|
|
1881
|
-
if (Array.isArray(item.completedAgents)) slim.completedAgents = item.completedAgents;
|
|
1882
|
-
if (item.failReason !== undefined) slim.failReason = item.failReason;
|
|
1883
|
-
if (item.branchStrategy !== undefined) slim.branchStrategy = item.branchStrategy;
|
|
1884
|
-
if (item.scope !== undefined) slim.scope = item.scope;
|
|
1885
|
-
if (item.completedAt !== undefined) slim.completedAt = item.completedAt;
|
|
1886
|
-
if (item.dispatched_at !== undefined) slim.dispatched_at = item.dispatched_at;
|
|
1887
|
-
if (item._reopened !== undefined) slim._reopened = item._reopened;
|
|
1888
|
-
if (item._pendingReason !== undefined) slim._pendingReason = item._pendingReason;
|
|
1889
|
-
if (item._managedSpawnPartial !== undefined) slim._managedSpawnPartial = item._managedSpawnPartial;
|
|
1890
|
-
if (item._securityFlag !== undefined) slim._securityFlag = item._securityFlag;
|
|
1891
|
-
if (item._artifacts !== undefined) slim._artifacts = item._artifacts;
|
|
1892
|
-
// Cross-slice fields read by derivePlanStatus / render-prd / refresh.js
|
|
1893
|
-
// (sourcePlan, planFile, itemType, project, parent_id). Without these the
|
|
1894
|
-
// plan-status derivation and decomposed-children rollup regress.
|
|
1895
|
-
if (item.sourcePlan !== undefined) slim.sourcePlan = item.sourcePlan;
|
|
1896
|
-
if (item.planFile !== undefined) slim.planFile = item.planFile;
|
|
1897
|
-
if (item.itemType !== undefined) slim.itemType = item.itemType;
|
|
1898
|
-
if (item.project !== undefined) slim.project = item.project;
|
|
1899
|
-
if (item.parent_id !== undefined) slim.parent_id = item.parent_id;
|
|
1900
|
-
// PR follow-up subobject is small and drives the +N chip + row badge.
|
|
1901
|
-
if (item.meta && item.meta.pr_followup) slim.meta = { pr_followup: item.meta.pr_followup };
|
|
1902
|
-
// Counts only — the modal fetches full arrays on demand via /api/work-items/<id>.
|
|
1903
|
-
slim.referencesCount = Array.isArray(item.references) ? item.references.length : 0;
|
|
1904
|
-
slim.acceptanceCriteriaCount = Array.isArray(item.acceptanceCriteria) ? item.acceptanceCriteria.length : 0;
|
|
1905
|
-
return slim;
|
|
1906
|
-
}
|
|
1907
|
-
function _slimWorkItemsForStatus(items) {
|
|
1908
|
-
if (!Array.isArray(items)) return items;
|
|
1909
|
-
const retentionDays = _resolveStatusWorkItemsRetentionDays();
|
|
1910
|
-
if (retentionDays <= 0) {
|
|
1911
|
-
// Trimming disabled — keep full list but still flatten via slim shape
|
|
1912
|
-
// so wire format is consistent (renderers never have to handle the old
|
|
1913
|
-
// heavy fields). Set retentionDays to a huge value if you want EVERY
|
|
1914
|
-
// terminal item, not just recent ones — 0 means "skip the date filter".
|
|
1915
|
-
return items.map(_slimWorkItemForStatus);
|
|
1916
|
-
}
|
|
1917
|
-
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
1918
|
-
const surviving = [];
|
|
1919
|
-
for (const item of items) {
|
|
1920
|
-
if (!item) continue;
|
|
1921
|
-
const status = item.status || 'pending';
|
|
1922
|
-
if (_TERMINAL_WI_STATUSES_FOR_STATUS.has(status)) {
|
|
1923
|
-
const ts = item.completedAt || item.dispatched_at || item.created || '';
|
|
1924
|
-
const tsMs = ts ? Date.parse(ts) : NaN;
|
|
1925
|
-
// Drop only when we have a parseable timestamp and it's beyond the
|
|
1926
|
-
// window. Items with missing/unparseable timestamps stay visible —
|
|
1927
|
-
// we'd rather over-include than silently hide them.
|
|
1928
|
-
if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
|
|
1929
|
-
continue;
|
|
1930
|
-
}
|
|
1931
|
-
} else if (!_ACTIVE_WI_STATUSES_FOR_STATUS.has(status)) {
|
|
1932
|
-
// Unknown status — keep, so a future status value isn't silently
|
|
1933
|
-
// hidden until the constant set is updated.
|
|
1934
|
-
}
|
|
1935
|
-
surviving.push(_slimWorkItemForStatus(item));
|
|
1936
|
-
}
|
|
1937
|
-
return surviving;
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
// ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
|
|
1941
|
-
// Mirrors the workItems slim above (PR #2816). Meetings were the second
|
|
1942
|
-
// largest /api/status slice — live measurement: 22 meetings / 4.3MB
|
|
1943
|
-
// (60% of the 7.2MB payload) before slimming. The list renderer in
|
|
1944
|
-
// dashboard/js/render-meetings.js:renderMeetings only needs:
|
|
1945
|
-
// - id, title, status, round, participants, agenda(short), createdAt,
|
|
1946
|
-
// completedAt
|
|
1947
|
-
// - per-participant booleans of findings/debate (used to pick the
|
|
1948
|
-
// ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
|
|
1949
|
-
// The detail modal calls `/api/meetings/:id` which serves the full record
|
|
1950
|
-
// (findings.content + debate.content + conclusion + transcript bodies), so
|
|
1951
|
-
// dropping those bodies from the slice is safe. The slim projection alone
|
|
1952
|
-
// delivers the bulk of the payload savings and always runs.
|
|
1953
|
-
//
|
|
1954
|
-
// engine.statusMeetingsRetentionDays is an optional second-tier filter that
|
|
1955
|
-
// drops terminal (completed/archived) meetings older than N days. Default 0
|
|
1956
|
-
// = no trim (legacy behavior, full list shipped — but still slim-shaped).
|
|
1957
|
-
// Active meetings (investigating/debating/concluding) are ALWAYS kept.
|
|
1958
|
-
const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
|
|
1959
|
-
const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
|
|
1960
|
-
function _resolveStatusMeetingsRetentionDays() {
|
|
1961
|
-
const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
|
|
1962
|
-
if (raw === 0 || raw === '0') return 0;
|
|
1963
|
-
const n = Number(raw);
|
|
1964
|
-
if (Number.isFinite(n) && n >= 0) return n;
|
|
1965
|
-
return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
|
|
1966
|
-
}
|
|
1967
|
-
function _slimMeetingForStatus(meeting) {
|
|
1968
|
-
// Reduce findings/debate objects to {agentId: true} sentinels — the list
|
|
1969
|
-
// renderer only checks `m.findings?.[p]` for truthiness when picking the
|
|
1970
|
-
// participant-badge icon. Keeping just the keys preserves that contract
|
|
1971
|
-
// while dropping the per-round agent transcript bodies (~95KB+ each).
|
|
1972
|
-
const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
|
|
1973
|
-
? Object.keys(meeting.findings) : [];
|
|
1974
|
-
const debateKeys = meeting.debate && typeof meeting.debate === 'object'
|
|
1975
|
-
? Object.keys(meeting.debate) : [];
|
|
1976
|
-
const findings = {};
|
|
1977
|
-
for (const k of findingsKeys) findings[k] = true;
|
|
1978
|
-
const debate = {};
|
|
1979
|
-
for (const k of debateKeys) debate[k] = true;
|
|
1980
|
-
const slim = {
|
|
1981
|
-
id: meeting.id,
|
|
1982
|
-
title: meeting.title,
|
|
1983
|
-
status: meeting.status,
|
|
1984
|
-
round: meeting.round,
|
|
1985
|
-
participants: Array.isArray(meeting.participants) ? meeting.participants : [],
|
|
1986
|
-
agenda: meeting.agenda,
|
|
1987
|
-
createdAt: meeting.createdAt,
|
|
1988
|
-
findings,
|
|
1989
|
-
debate,
|
|
1990
|
-
};
|
|
1991
|
-
if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
|
|
1992
|
-
if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
|
|
1993
|
-
if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
|
|
1994
|
-
return slim;
|
|
1995
|
-
}
|
|
1996
|
-
function _slimMeetingsForStatus(meetings) {
|
|
1997
|
-
if (!Array.isArray(meetings)) return meetings;
|
|
1998
|
-
const retentionDays = _resolveStatusMeetingsRetentionDays();
|
|
1999
|
-
if (retentionDays <= 0) {
|
|
2000
|
-
// Trimming disabled — keep full list but still flatten via slim shape
|
|
2001
|
-
// so wire format is consistent and the heavy bodies never ship via
|
|
2002
|
-
// /api/status regardless of operator config.
|
|
2003
|
-
return meetings.map(_slimMeetingForStatus);
|
|
2004
|
-
}
|
|
2005
|
-
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
2006
|
-
const surviving = [];
|
|
2007
|
-
for (const meeting of meetings) {
|
|
2008
|
-
if (!meeting) continue;
|
|
2009
|
-
const status = meeting.status || 'investigating';
|
|
2010
|
-
if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
|
|
2011
|
-
const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
|
|
2012
|
-
const tsMs = ts ? Date.parse(ts) : NaN;
|
|
2013
|
-
// Drop only when we have a parseable timestamp and it's beyond the
|
|
2014
|
-
// window. Meetings with missing/unparseable timestamps stay visible —
|
|
2015
|
-
// we'd rather over-include than silently hide them.
|
|
2016
|
-
if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
|
|
2017
|
-
continue;
|
|
2018
|
-
}
|
|
2019
|
-
} else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
|
|
2020
|
-
// Unknown status — keep, so a future round name isn't silently
|
|
2021
|
-
// hidden until the constant set is updated.
|
|
2022
|
-
}
|
|
2023
|
-
surviving.push(_slimMeetingForStatus(meeting));
|
|
2024
|
-
}
|
|
2025
|
-
return surviving;
|
|
2026
|
-
}
|
|
2027
|
-
// Count meetings on disk without rehydrating bodies — backs the sidebar
|
|
2028
|
-
// activity dot signature so new rounds in trimmed/archived meetings still
|
|
2029
|
-
// flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
|
|
2030
|
-
function _countMeetingsForStatus() {
|
|
2031
|
-
const meetings = meetingMod.getMeetings();
|
|
2032
|
-
return Array.isArray(meetings) ? meetings.length : 0;
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
1775
|
// Build the slow-state slice (rarely-changing data: ~60s TTL).
|
|
2036
1776
|
function _buildStatusSlowState() {
|
|
2037
|
-
|
|
1777
|
+
// Issue #2949 — heavy slices moved to dedicated endpoints (see header on
|
|
1778
|
+
// _buildStatusFastState). prdProgress + prd → /api/prd, verifyGuides →
|
|
1779
|
+
// /api/verify-guides, archivedPrds → /api/archived-prds, schedules →
|
|
1780
|
+
// /api/schedules, pipelines → /api/pipelines, pinned → /api/pinned.
|
|
1781
|
+
// What's left here is genuine "shape of the install" state that has no
|
|
1782
|
+
// staleness problem: projects + git status (server-only compute), runtime
|
|
1783
|
+
// capability roll-ups (skills + mcpServers), config-derived autoMode +
|
|
1784
|
+
// initialized + installId, and the version banner.
|
|
2038
1785
|
return {
|
|
2039
|
-
prdProgress: prdInfo.progress,
|
|
2040
|
-
prd: prdInfo.status,
|
|
2041
|
-
verifyGuides: getVerifyGuides(),
|
|
2042
|
-
archivedPrds: getArchivedPrds(),
|
|
2043
1786
|
skills: getSkills(),
|
|
2044
1787
|
mcpServers: getMcpServers(),
|
|
2045
|
-
schedules: (() => {
|
|
2046
|
-
const scheds = CONFIG.schedules || [];
|
|
2047
|
-
const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
|
|
2048
|
-
return scheds.map(s => {
|
|
2049
|
-
const runEntry = runs[s.id];
|
|
2050
|
-
// Backward compat: runEntry can be a string (old format) or object (new format with back-references)
|
|
2051
|
-
const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
|
|
2052
|
-
const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
|
|
2053
|
-
return { ...s, _lastRun, ...extra };
|
|
2054
|
-
});
|
|
2055
|
-
})(),
|
|
2056
|
-
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
2057
|
-
pinned: (() => { try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; } })(),
|
|
2058
1788
|
projects: PROJECTS.map(p => {
|
|
2059
1789
|
const status = getProjectGitStatus(p.localPath, p.mainBranch);
|
|
2060
1790
|
const mainBranch = p.mainBranch || null;
|
|
@@ -2357,6 +2087,331 @@ function getStatusJson() {
|
|
|
2357
2087
|
return _statusCacheJson;
|
|
2358
2088
|
}
|
|
2359
2089
|
|
|
2090
|
+
// ── /state/<path> — raw state-file passthrough ────────────────────────────
|
|
2091
|
+
// Static-file handler that serves files from under MINIONS_DIR directly,
|
|
2092
|
+
// bypassing the /api/status assembled-snapshot cache. Per-page polls hit
|
|
2093
|
+
// this instead of /api/status so a stale outer cache cannot hold a fresh
|
|
2094
|
+
// disk write hostage. ETag = mtimeMs + size → 304 when unchanged.
|
|
2095
|
+
//
|
|
2096
|
+
// Safety:
|
|
2097
|
+
// 1. shared.sanitizePath rejects null bytes / .. / absolute paths and
|
|
2098
|
+
// asserts the resolved target stays under MINIONS_DIR.
|
|
2099
|
+
// 2. Top-level dir must be in STATE_READ_ALLOWED_DIRS — no exposing
|
|
2100
|
+
// config.json, .install-id, source code, .gitignored secrets.
|
|
2101
|
+
// 3. STATE_READ_DENIED_PATTERNS deny-lists sensitive files INSIDE
|
|
2102
|
+
// allowed dirs (SQLite db, .backup sidecars, live-output.log,
|
|
2103
|
+
// session.json — any of which leak credentials or runtime state).
|
|
2104
|
+
// 4. Symlinks are rejected outright (no follow → realpath round-trip).
|
|
2105
|
+
// 5. Directory paths return a JSON listing [{name,size,mtimeMs,isDir}],
|
|
2106
|
+
// one level deep — needed for inbox/ and notes/inbox/ enumeration.
|
|
2107
|
+
const STATE_READ_ALLOWED_DIRS = new Set([
|
|
2108
|
+
'projects', 'engine', 'prd', 'notes', 'plans',
|
|
2109
|
+
'pipelines', 'knowledge', 'agents', 'watches.d',
|
|
2110
|
+
'meetings',
|
|
2111
|
+
]);
|
|
2112
|
+
// Single-file allowances for state files that live at MINIONS_DIR root
|
|
2113
|
+
// rather than inside an allowlisted directory. Currently: notes.md (consolidated
|
|
2114
|
+
// human + agent notes; renderer needs raw markdown). Keep this tight.
|
|
2115
|
+
const STATE_READ_ALLOWED_FILES = new Set([
|
|
2116
|
+
'notes.md',
|
|
2117
|
+
]);
|
|
2118
|
+
// Sensitive session-bearing JSON filenames that must NEVER be served via
|
|
2119
|
+
// /state/<path>. Exact basename match (not a broad regex) so we don't
|
|
2120
|
+
// silently swallow legitimate user filenames like plans/redis-sessions.md
|
|
2121
|
+
// or meetings/MTG-debugging-sessions-*.json. (Round-2 review finding #9.)
|
|
2122
|
+
const STATE_READ_DENIED_BASENAMES = new Set([
|
|
2123
|
+
'session.json',
|
|
2124
|
+
'sessions.json',
|
|
2125
|
+
'cc-session.json',
|
|
2126
|
+
'cc-sessions.json',
|
|
2127
|
+
'qa-sessions.json',
|
|
2128
|
+
'qa-sessions-history.json',
|
|
2129
|
+
'session-state.json',
|
|
2130
|
+
'session-token.json',
|
|
2131
|
+
'keep-pids.json',
|
|
2132
|
+
// doc-sessions.json holds user-authored doc-chat conversation state
|
|
2133
|
+
// (questions + document context + LLM responses). Was previously denied
|
|
2134
|
+
// via a broad *sessions?.json regex that round-2 #9 narrowed; restoring
|
|
2135
|
+
// explicit coverage here. (Round-3 review finding #2.)
|
|
2136
|
+
'doc-sessions.json',
|
|
2137
|
+
]);
|
|
2138
|
+
const STATE_READ_DENIED_PATTERNS = [
|
|
2139
|
+
/(^|[\\/])state\.db($|-wal$|-shm$)/i,
|
|
2140
|
+
/\.backup$/i,
|
|
2141
|
+
// Engine writes several stdout-bearing log shapes under agents/<id>/:
|
|
2142
|
+
// - live-output.log (tail-followed during the run)
|
|
2143
|
+
// - output.log (legacy post-run dump)
|
|
2144
|
+
// - output-<agent>-<type>-<id>.log (per-dispatch named log written by
|
|
2145
|
+
// lifecycle.js — typically the largest single byte source)
|
|
2146
|
+
// - live-output-prev.log (rotation backup of the previous run)
|
|
2147
|
+
// All contain agent stdout that can leak credentials echoed in error
|
|
2148
|
+
// stack traces and untrusted PR-comment content. (Round-3 review #1.)
|
|
2149
|
+
// `-[^\\/]*` (zero-or-more, NOT one-or-more) so degenerate `output-.log`
|
|
2150
|
+
// and `live-output-.log` variants are also caught — a malformed dispatch
|
|
2151
|
+
// template resolving the id to empty would otherwise slip past.
|
|
2152
|
+
// (Round-4 #4.)
|
|
2153
|
+
/(^|[\\/])(live-)?output(-prev|-[^\\/]*)?\.log$/i,
|
|
2154
|
+
/(^|[\\/])\.env($|\.)/i,
|
|
2155
|
+
];
|
|
2156
|
+
function _isStateReadPathDenied(rel) {
|
|
2157
|
+
if (STATE_READ_DENIED_PATTERNS.some(re => re.test(rel))) return true;
|
|
2158
|
+
// Basename comparison — extract the trailing segment and compare against
|
|
2159
|
+
// the known-sensitive set. Forward and backslash both supported so
|
|
2160
|
+
// Windows-shaped rel paths are caught.
|
|
2161
|
+
const base = rel.split(/[\\/]/).pop() || '';
|
|
2162
|
+
return STATE_READ_DENIED_BASENAMES.has(base.toLowerCase());
|
|
2163
|
+
}
|
|
2164
|
+
function _stateReadContentType(ext) {
|
|
2165
|
+
switch (ext.toLowerCase()) {
|
|
2166
|
+
case '.json': return 'application/json; charset=utf-8';
|
|
2167
|
+
case '.md': return 'text/markdown; charset=utf-8';
|
|
2168
|
+
case '.txt': case '.log': return 'text/plain; charset=utf-8';
|
|
2169
|
+
default: return 'application/octet-stream';
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
// ── Dedicated fresh-JSON endpoint pattern ─────────────────────────────────
|
|
2173
|
+
// Companion to /state/<path>. /state/ is "serve a single file verbatim";
|
|
2174
|
+
// serveFreshJson is "run server-side enrichment, ETag off input mtimes."
|
|
2175
|
+
//
|
|
2176
|
+
// Used for slices whose payload joins multiple files (agents derived from
|
|
2177
|
+
// config + dispatch + inbox + steering, metrics derived from metrics.json
|
|
2178
|
+
// + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
|
|
2179
|
+
// Replicating those joins in browser JS would be brittle, and shipping the
|
|
2180
|
+
// fully-enriched result through /api/status pays the staleness tax that
|
|
2181
|
+
// issue #2949 documented. This pattern bypasses both:
|
|
2182
|
+
//
|
|
2183
|
+
// 1. Caller supplies an array of input file paths.
|
|
2184
|
+
// 2. We statSync each one and take MAX(mtimeMs). That's the ETag.
|
|
2185
|
+
// 3. If the client's If-None-Match matches → 304 with no body.
|
|
2186
|
+
// 4. Otherwise call `builder()` to assemble the response from scratch
|
|
2187
|
+
// (no outer cache layer to go stale).
|
|
2188
|
+
// 5. Stamp ETag + Content-Type and send.
|
|
2189
|
+
//
|
|
2190
|
+
// Cost per request when ETag matches: N statSync calls (microseconds).
|
|
2191
|
+
// Cost on a miss: same N statSync + the builder, which is exactly the
|
|
2192
|
+
// existing queries.getAgents() / getMetrics() / etc. that /api/status
|
|
2193
|
+
// already pays. Net change: same compute, no caching, always-fresh.
|
|
2194
|
+
function _maxInputMtimeMs(inputs) {
|
|
2195
|
+
let max = 0;
|
|
2196
|
+
for (const fp of inputs) {
|
|
2197
|
+
try {
|
|
2198
|
+
// Use lstatSync so a broken Windows junction (common after DevBox
|
|
2199
|
+
// swap) or a circular symlink doesn't block the event loop on a
|
|
2200
|
+
// ~30 s OS timeout trying to follow the link. Engine never writes
|
|
2201
|
+
// symlinks into state dirs in normal operation, so an lstat that
|
|
2202
|
+
// detects a symlink is treated as "skip" — the input is effectively
|
|
2203
|
+
// not tracked. (Round-2 review finding #7.)
|
|
2204
|
+
const s = fs.lstatSync(fp);
|
|
2205
|
+
if (s.isSymbolicLink()) continue;
|
|
2206
|
+
if (s.mtimeMs > max) max = s.mtimeMs;
|
|
2207
|
+
// Directory inputs: dir mtime only advances on add/remove of
|
|
2208
|
+
// immediate entries, not on in-place file edits. Walk two levels
|
|
2209
|
+
// down so editing prd/<plan>.json in place (depth 1) and
|
|
2210
|
+
// agents/<id>/live-output.log (depth 2) both bust the ETag.
|
|
2211
|
+
// Steering edits at agents/<id>/inbox/steering-*.md (depth 3)
|
|
2212
|
+
// currently rely on add/remove cadence rather than per-file content
|
|
2213
|
+
// mtime — accept this lag rather than walking unbounded.
|
|
2214
|
+
// (Review finding #6.)
|
|
2215
|
+
if (s.isDirectory()) {
|
|
2216
|
+
let level1;
|
|
2217
|
+
try { level1 = fs.readdirSync(fp); } catch { level1 = []; }
|
|
2218
|
+
for (const name of level1) {
|
|
2219
|
+
const child = path.join(fp, name);
|
|
2220
|
+
let cs;
|
|
2221
|
+
try { cs = fs.lstatSync(child); } catch { continue; }
|
|
2222
|
+
if (cs.isSymbolicLink()) continue;
|
|
2223
|
+
if (cs.mtimeMs > max) max = cs.mtimeMs;
|
|
2224
|
+
if (cs.isDirectory()) {
|
|
2225
|
+
let level2;
|
|
2226
|
+
try { level2 = fs.readdirSync(child); } catch { level2 = []; }
|
|
2227
|
+
for (const inner of level2) {
|
|
2228
|
+
try {
|
|
2229
|
+
const is = fs.lstatSync(path.join(child, inner));
|
|
2230
|
+
if (is.isSymbolicLink()) continue;
|
|
2231
|
+
if (is.mtimeMs > max) max = is.mtimeMs;
|
|
2232
|
+
} catch { /* skip unreadable */ }
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
} catch {
|
|
2238
|
+
// Missing input = not yet on disk. Don't bust the ETag — a builder
|
|
2239
|
+
// that legitimately depends on the file will surface its own empty
|
|
2240
|
+
// state, and the ETag will move once the file appears.
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
return Math.floor(max);
|
|
2244
|
+
}
|
|
2245
|
+
function serveFreshJson(req, res, opts) {
|
|
2246
|
+
const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
|
|
2247
|
+
const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
|
|
2248
|
+
const tag = opts && opts.tag ? String(opts.tag) : 'v';
|
|
2249
|
+
if (!builder) {
|
|
2250
|
+
res.statusCode = 500;
|
|
2251
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2252
|
+
res.end(JSON.stringify({ error: 'serveFreshJson: builder is required' }));
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const mtime = _maxInputMtimeMs(inputs);
|
|
2256
|
+
const etag = '"' + tag + '-' + mtime + '"';
|
|
2257
|
+
res.setHeader('ETag', etag);
|
|
2258
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
2259
|
+
if (req && req.headers && req.headers['if-none-match'] === etag) {
|
|
2260
|
+
res.statusCode = 304;
|
|
2261
|
+
res.end();
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
let payload;
|
|
2265
|
+
try {
|
|
2266
|
+
payload = builder();
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
res.statusCode = 500;
|
|
2269
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2270
|
+
res.end(JSON.stringify({ error: e && e.message || String(e) }));
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
res.statusCode = 200;
|
|
2274
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
2275
|
+
res.end(JSON.stringify(payload));
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function handleStateRead(req, res) {
|
|
2279
|
+
const urlPath = req.url.split('?')[0];
|
|
2280
|
+
const rel = decodeURIComponent(urlPath.slice('/state/'.length));
|
|
2281
|
+
if (!rel) {
|
|
2282
|
+
res.statusCode = 400;
|
|
2283
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2284
|
+
res.end(JSON.stringify({ error: 'path required' }));
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
// Top-level allowlist — first path segment must match a permitted
|
|
2288
|
+
// directory OR (when there is no further path) a permitted root file.
|
|
2289
|
+
// STRICT-CASE comparison: case-folding the allowlist would create
|
|
2290
|
+
// Linux/Windows divergence (Windows NTFS resolves /state/PROJECTS/... to
|
|
2291
|
+
// the real lowercase dir and serves 200; Linux ext4 returns 404 since
|
|
2292
|
+
// sanitizePath preserves the literal-case path that lstat would then
|
|
2293
|
+
// miss). yemi33 PR Tests runs Windows-only — case-folding lets a
|
|
2294
|
+
// platform-divergent path land without CI signal. URLs in this codebase
|
|
2295
|
+
// are canonically lowercase; reject mixed-case to keep parity.
|
|
2296
|
+
// (Round-4 #5 — reverts the round-3 case-insensitive change.)
|
|
2297
|
+
const segments = rel.split(/[\\/]/);
|
|
2298
|
+
const top = segments[0] || '';
|
|
2299
|
+
const isSingleSegment = segments.length === 1;
|
|
2300
|
+
if (!STATE_READ_ALLOWED_DIRS.has(top) &&
|
|
2301
|
+
!(isSingleSegment && STATE_READ_ALLOWED_FILES.has(top))) {
|
|
2302
|
+
res.statusCode = 403;
|
|
2303
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2304
|
+
res.end(JSON.stringify({ error: 'forbidden' }));
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
if (_isStateReadPathDenied(rel)) {
|
|
2308
|
+
res.statusCode = 403;
|
|
2309
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2310
|
+
res.end(JSON.stringify({ error: 'forbidden file' }));
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
let resolved;
|
|
2314
|
+
try {
|
|
2315
|
+
resolved = shared.sanitizePath(rel, MINIONS_DIR);
|
|
2316
|
+
} catch (e) {
|
|
2317
|
+
res.statusCode = 403;
|
|
2318
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2319
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
let lstat;
|
|
2323
|
+
try { lstat = fs.lstatSync(resolved); }
|
|
2324
|
+
catch {
|
|
2325
|
+
res.statusCode = 404;
|
|
2326
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2327
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
// Reject symlinks outright — even if they point inside MINIONS_DIR, they
|
|
2331
|
+
// can be swapped after the allowlist check (TOCTOU) to escape. Engine
|
|
2332
|
+
// never writes symlinks into state dirs in normal operation.
|
|
2333
|
+
if (lstat.isSymbolicLink()) {
|
|
2334
|
+
res.statusCode = 403;
|
|
2335
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2336
|
+
res.end(JSON.stringify({ error: 'symlinks not served' }));
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
if (lstat.isDirectory()) {
|
|
2340
|
+
// One-level directory listing for enumeration (inbox, archive, etc.).
|
|
2341
|
+
// Per-entry stat lives inside the inner try-catch so a single bad entry
|
|
2342
|
+
// doesn't fail the whole listing — bad entry just gets dropped.
|
|
2343
|
+
const etag = '"dir-' + Math.floor(lstat.mtimeMs) + '"';
|
|
2344
|
+
res.setHeader('ETag', etag);
|
|
2345
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
2346
|
+
if (req.headers['if-none-match'] === etag) {
|
|
2347
|
+
res.statusCode = 304;
|
|
2348
|
+
res.end();
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
let entries;
|
|
2352
|
+
try {
|
|
2353
|
+
entries = fs.readdirSync(resolved).map(name => {
|
|
2354
|
+
// Re-apply the deny-list to entry names so the listing doesn't
|
|
2355
|
+
// leak the existence of state.db / *.backup / live-output.log /
|
|
2356
|
+
// session.json / .env* even though the per-file fetch for them
|
|
2357
|
+
// would 403. Deny patterns match against the relative path,
|
|
2358
|
+
// mirroring the request-time check above. (Review finding #8.)
|
|
2359
|
+
const childRel = (rel + '/' + name).replace(/\\/g, '/');
|
|
2360
|
+
if (_isStateReadPathDenied(childRel)) return null;
|
|
2361
|
+
try {
|
|
2362
|
+
const s = fs.lstatSync(path.join(resolved, name));
|
|
2363
|
+
if (s.isSymbolicLink()) return null;
|
|
2364
|
+
return {
|
|
2365
|
+
name,
|
|
2366
|
+
size: s.size,
|
|
2367
|
+
mtimeMs: Math.floor(s.mtimeMs),
|
|
2368
|
+
isDir: s.isDirectory(),
|
|
2369
|
+
};
|
|
2370
|
+
} catch { return null; }
|
|
2371
|
+
}).filter(Boolean);
|
|
2372
|
+
} catch (e) {
|
|
2373
|
+
res.statusCode = 500;
|
|
2374
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2375
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
res.statusCode = 200;
|
|
2379
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
2380
|
+
res.end(JSON.stringify({ path: rel.replace(/\\/g, '/'), entries }));
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
// File path: mtime+size ETag, stream the bytes.
|
|
2384
|
+
const etag = '"' + Math.floor(lstat.mtimeMs) + '-' + lstat.size + '"';
|
|
2385
|
+
res.setHeader('ETag', etag);
|
|
2386
|
+
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
2387
|
+
if (req.headers['if-none-match'] === etag) {
|
|
2388
|
+
res.statusCode = 304;
|
|
2389
|
+
res.end();
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
res.statusCode = 200;
|
|
2393
|
+
res.setHeader('Content-Type', _stateReadContentType(path.extname(resolved)));
|
|
2394
|
+
try {
|
|
2395
|
+
const stream = fs.createReadStream(resolved);
|
|
2396
|
+
stream.on('error', (err) => {
|
|
2397
|
+
if (!res.headersSent) {
|
|
2398
|
+
res.statusCode = 500;
|
|
2399
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2400
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2401
|
+
} else if (!res.writableEnded) {
|
|
2402
|
+
res.end();
|
|
2403
|
+
}
|
|
2404
|
+
});
|
|
2405
|
+
stream.pipe(res);
|
|
2406
|
+
} catch (e) {
|
|
2407
|
+
if (!res.headersSent) {
|
|
2408
|
+
res.statusCode = 500;
|
|
2409
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2410
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2360
2415
|
// Top-level /api/status request handler (W-mpehsyhv0017085a). Extracted from
|
|
2361
2416
|
// the inline route handler so unit tests can call it with mock req/res and so
|
|
2362
2417
|
// production routing has a single source of truth. The inline route delegates
|
|
@@ -2386,7 +2441,6 @@ async function _handleStatusRequest(req, res) {
|
|
|
2386
2441
|
if (_ifNoneMatchHasEtag(inm, currentEtag)) {
|
|
2387
2442
|
res.setHeader('ETag', currentEtag);
|
|
2388
2443
|
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
2389
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2390
2444
|
res.statusCode = 304;
|
|
2391
2445
|
res.end();
|
|
2392
2446
|
return;
|
|
@@ -2395,7 +2449,6 @@ async function _handleStatusRequest(req, res) {
|
|
|
2395
2449
|
// Use pre-serialized JSON and pre-computed gzip buffer — zero per-request compression
|
|
2396
2450
|
const json = getStatusJson();
|
|
2397
2451
|
res.setHeader('Content-Type', 'application/json');
|
|
2398
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2399
2452
|
res.setHeader('ETag', currentEtag);
|
|
2400
2453
|
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
2401
2454
|
res.statusCode = 200;
|
|
@@ -4715,9 +4768,12 @@ function checkRateLimit(key, maxPerMinute) {
|
|
|
4715
4768
|
function jsonReply(res, code, data, req) {
|
|
4716
4769
|
res.setHeader('Content-Type', 'application/json');
|
|
4717
4770
|
// Access-Control-Allow-Origin is set ONCE by the server dispatcher prelude:
|
|
4718
|
-
//
|
|
4719
|
-
//
|
|
4720
|
-
// cross-origin
|
|
4771
|
+
// echoed `Origin` for GET/HEAD ONLY when the origin is the dashboard's own
|
|
4772
|
+
// served origin or an entry in `config.engine.allowedDashboardOrigins`;
|
|
4773
|
+
// never on mutating responses (Origin gate already blocked cross-origin
|
|
4774
|
+
// POSTs). Handlers must NOT set ACAO themselves — doing so would either
|
|
4775
|
+
// override the prelude's narrowed value back to `*` (P-bfa2c-cors-wildcard
|
|
4776
|
+
// regression) or re-open the cross-origin write path.
|
|
4721
4777
|
res.statusCode = code;
|
|
4722
4778
|
const json = JSON.stringify(data);
|
|
4723
4779
|
const ae = req && req.headers && req.headers['accept-encoding'] || '';
|
|
@@ -4870,11 +4926,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
4870
4926
|
}
|
|
4871
4927
|
}
|
|
4872
4928
|
|
|
4873
|
-
// GET:
|
|
4874
|
-
//
|
|
4875
|
-
//
|
|
4929
|
+
// GET/HEAD: narrow CORS. Echo the request's `Origin` in
|
|
4930
|
+
// `Access-Control-Allow-Origin` ONLY if it matches the dashboard's own
|
|
4931
|
+
// served origin (`http://localhost:7331`) or an entry in
|
|
4932
|
+
// `config.engine.allowedDashboardOrigins` (default []). When no `Origin`
|
|
4933
|
+
// header is present (curl, uptime monitors, Node http.request without
|
|
4934
|
+
// Origin), do not emit ACAO at all — preserves the local-tooling path.
|
|
4935
|
+
// The previous wildcard (`*`) let any cross-origin browser page issue
|
|
4936
|
+
// `fetch` to http://localhost:7331/api/* and read the JSON response,
|
|
4937
|
+
// exposing operator-private state (config, work items, agent
|
|
4938
|
+
// transcripts). See P-bfa2c-cors-wildcard + docs/security.md.
|
|
4876
4939
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
4877
|
-
|
|
4940
|
+
if (_rawOrigin && shared.isAllowedDashboardOrigin(_rawOrigin, CONFIG)) {
|
|
4941
|
+
res.setHeader('Access-Control-Allow-Origin', _rawOrigin);
|
|
4942
|
+
res.setHeader('Vary', 'Origin');
|
|
4943
|
+
}
|
|
4878
4944
|
}
|
|
4879
4945
|
|
|
4880
4946
|
// ── Route Handler Functions ───────────────────────────────────────────────
|
|
@@ -6017,7 +6083,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6017
6083
|
const livePath = path.join(agentDir, 'live-output.log');
|
|
6018
6084
|
const prevPath = path.join(agentDir, 'live-output-prev.log');
|
|
6019
6085
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
6020
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6021
6086
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
6022
6087
|
const rawTail = parseInt(params.get('tail'));
|
|
6023
6088
|
if (params.has('tail') && isNaN(rawTail)) return jsonReply(res, 400, { error: 'tail must be a number' });
|
|
@@ -6080,7 +6145,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6080
6145
|
const outputPath = path.join(MINIONS_DIR, 'agents', agentId, 'output.log');
|
|
6081
6146
|
const content = safeRead(outputPath);
|
|
6082
6147
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
6083
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6084
6148
|
res.end(content || 'No output log found for this agent.');
|
|
6085
6149
|
return;
|
|
6086
6150
|
}
|
|
@@ -6088,7 +6152,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6088
6152
|
async function handleNotesFull(req, res) {
|
|
6089
6153
|
const content = safeRead(path.join(MINIONS_DIR, 'notes.md'));
|
|
6090
6154
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
6091
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6092
6155
|
res.end(content || 'No notes file found.');
|
|
6093
6156
|
return;
|
|
6094
6157
|
}
|
|
@@ -6155,7 +6218,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6155
6218
|
const content = safeRead(path.join(kbCatDir, file));
|
|
6156
6219
|
if (content === null) return jsonReply(res, 404, { error: 'not found' });
|
|
6157
6220
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
6158
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6159
6221
|
res.end(content);
|
|
6160
6222
|
return;
|
|
6161
6223
|
}
|
|
@@ -6361,7 +6423,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6361
6423
|
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
6362
6424
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
6363
6425
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
6364
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6365
6426
|
res.end(content);
|
|
6366
6427
|
return;
|
|
6367
6428
|
}
|
|
@@ -6383,7 +6444,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6383
6444
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
6384
6445
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
6385
6446
|
res.setHeader('Cache-Control', 'no-cache');
|
|
6386
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
6387
6447
|
res.end(content);
|
|
6388
6448
|
return;
|
|
6389
6449
|
}
|
|
@@ -7390,7 +7450,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7390
7450
|
const skillPath = _resolveSkillReadPath({ file, dir, source, config: CONFIG });
|
|
7391
7451
|
const content = skillPath ? (safeRead(skillPath) || '') : '';
|
|
7392
7452
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
7393
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
7394
7453
|
res.end(content || 'Skill not found.');
|
|
7395
7454
|
return;
|
|
7396
7455
|
}
|
|
@@ -8657,20 +8716,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8657
8716
|
}
|
|
8658
8717
|
}
|
|
8659
8718
|
|
|
8660
|
-
async function handleSchedulesList(req, res) {
|
|
8661
|
-
reloadConfig();
|
|
8662
|
-
const schedules = CONFIG.schedules || [];
|
|
8663
|
-
const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
|
|
8664
|
-
const result = schedules.map(s => {
|
|
8665
|
-
const runEntry = runs[s.id];
|
|
8666
|
-
// Backward compat: runEntry can be a string (old format) or object (new format with back-references)
|
|
8667
|
-
const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
|
|
8668
|
-
const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
|
|
8669
|
-
return { ...s, _lastRun, ...extra };
|
|
8670
|
-
});
|
|
8671
|
-
return jsonReply(res, 200, { schedules: result });
|
|
8672
|
-
}
|
|
8673
|
-
|
|
8674
8719
|
async function handleSchedulesCreate(req, res) {
|
|
8675
8720
|
const body = await readBody(req);
|
|
8676
8721
|
let { id, cron, title, type, project, agent, description, priority, enabled } = body;
|
|
@@ -9058,35 +9103,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9058
9103
|
}
|
|
9059
9104
|
// String fields
|
|
9060
9105
|
if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
|
|
9061
|
-
// W-mphejzmj000718bf: /api/status workItems retention window. Handled
|
|
9062
|
-
// outside the numericFields loop because `Number(0) || D[key]`
|
|
9063
|
-
// collapses the "disable trim" value (0) back to the default — we
|
|
9064
|
-
// want 0 to actually persist as "skip the date filter".
|
|
9065
|
-
if (e.statusWorkItemsRetentionDays !== undefined) {
|
|
9066
|
-
const raw = e.statusWorkItemsRetentionDays;
|
|
9067
|
-
if (raw === '' || raw === null) {
|
|
9068
|
-
_deleteEngineConfig('statusWorkItemsRetentionDays');
|
|
9069
|
-
} else {
|
|
9070
|
-
let val = Number(raw);
|
|
9071
|
-
if (!Number.isFinite(val) || val < 0) val = D.statusWorkItemsRetentionDays;
|
|
9072
|
-
if (val > 365) { _clamped.push(`statusWorkItemsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
|
|
9073
|
-
_setEngineConfig('statusWorkItemsRetentionDays', val);
|
|
9074
|
-
}
|
|
9075
|
-
}
|
|
9076
|
-
// W-mphlrxx6000a8760: /api/status meetings retention window. Same
|
|
9077
|
-
// shape as statusWorkItemsRetentionDays — 0 must persist literally
|
|
9078
|
-
// (disables trim), so handled outside the numericFields loop.
|
|
9079
|
-
if (e.statusMeetingsRetentionDays !== undefined) {
|
|
9080
|
-
const raw = e.statusMeetingsRetentionDays;
|
|
9081
|
-
if (raw === '' || raw === null) {
|
|
9082
|
-
_deleteEngineConfig('statusMeetingsRetentionDays');
|
|
9083
|
-
} else {
|
|
9084
|
-
let val = Number(raw);
|
|
9085
|
-
if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
|
|
9086
|
-
if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
|
|
9087
|
-
_setEngineConfig('statusMeetingsRetentionDays', val);
|
|
9088
|
-
}
|
|
9089
|
-
}
|
|
9090
9106
|
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
9091
9107
|
// the override (engine falls back to gh/git/os resolution); any other
|
|
9092
9108
|
// value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
|
|
@@ -9540,7 +9556,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9540
9556
|
|
|
9541
9557
|
async function handleAgentDetail(req, res, match) {
|
|
9542
9558
|
res.setHeader('Content-Type', 'application/json');
|
|
9543
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
9544
9559
|
try {
|
|
9545
9560
|
res.end(JSON.stringify(getAgentDetail(match[1])));
|
|
9546
9561
|
} catch (e) {
|
|
@@ -10165,7 +10180,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10165
10180
|
opts.status = statusRaw;
|
|
10166
10181
|
}
|
|
10167
10182
|
const runs = qa.listRuns(opts);
|
|
10168
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
10169
10183
|
return jsonReply(res, 200, { runs, generatedAt: new Date().toISOString() }, req);
|
|
10170
10184
|
} catch (e) {
|
|
10171
10185
|
return jsonReply(res, 500, { error: e.message }, req);
|
|
@@ -10181,7 +10195,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10181
10195
|
}
|
|
10182
10196
|
const run = qa.getRun(id);
|
|
10183
10197
|
if (!run) return jsonReply(res, 404, { error: 'qa run not found' }, req);
|
|
10184
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
10185
10198
|
return jsonReply(res, 200, { run }, req);
|
|
10186
10199
|
} catch (e) {
|
|
10187
10200
|
return jsonReply(res, 500, { error: e.message }, req);
|
|
@@ -10225,7 +10238,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10225
10238
|
if (!stat.isFile()) return jsonReply(res, 404, { error: 'artifact not found' }, req);
|
|
10226
10239
|
res.setHeader('Content-Type', _qaArtifactContentType(file));
|
|
10227
10240
|
res.setHeader('Content-Length', String(stat.size));
|
|
10228
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
10229
10241
|
res.statusCode = 200;
|
|
10230
10242
|
fs.createReadStream(real).pipe(res);
|
|
10231
10243
|
return;
|
|
@@ -10633,6 +10645,192 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10633
10645
|
|
|
10634
10646
|
// Status & health
|
|
10635
10647
|
{ method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
|
|
10648
|
+
// Raw state-file passthrough (no /api/ prefix — this serves files, not API
|
|
10649
|
+
// responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
|
|
10650
|
+
{ method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
|
|
10651
|
+
// ── Dedicated fresh-JSON endpoints (issue #2949) ───────────────────────
|
|
10652
|
+
// Each runs the existing server-side enrichment fresh on every request,
|
|
10653
|
+
// ETag computed from input file mtimes — no outer cache, no staleness.
|
|
10654
|
+
// Pages call these instead of relying on /api/status's assembled snapshot.
|
|
10655
|
+
{ method: 'GET', path: '/api/agents', desc: 'Live agent roster with status/lastAction/runtime/inbox/charter — derived from config + dispatch + agents dir', handler: (req, res) => {
|
|
10656
|
+
return serveFreshJson(req, res, {
|
|
10657
|
+
tag: 'agents',
|
|
10658
|
+
inputs: [
|
|
10659
|
+
CONFIG_PATH,
|
|
10660
|
+
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
10661
|
+
INBOX_DIR,
|
|
10662
|
+
AGENTS_DIR,
|
|
10663
|
+
],
|
|
10664
|
+
builder: () => getAgents(),
|
|
10665
|
+
});
|
|
10666
|
+
}},
|
|
10667
|
+
{ method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items (per-project files joined + dispatch/PR cross-reference) — fresh on every request', handler: (req, res) => {
|
|
10668
|
+
const config = queries.getConfig();
|
|
10669
|
+
const projects = config.projects || [];
|
|
10670
|
+
const inputs = [
|
|
10671
|
+
CONFIG_PATH,
|
|
10672
|
+
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
10673
|
+
];
|
|
10674
|
+
for (const p of projects) {
|
|
10675
|
+
if (p && p.name) {
|
|
10676
|
+
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
|
|
10677
|
+
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
10678
|
+
}
|
|
10679
|
+
}
|
|
10680
|
+
return serveFreshJson(req, res, {
|
|
10681
|
+
tag: 'work-items',
|
|
10682
|
+
inputs,
|
|
10683
|
+
builder: () => getWorkItems(),
|
|
10684
|
+
});
|
|
10685
|
+
}},
|
|
10686
|
+
{ method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests (per-project files joined + url backfill + _project stamp)', handler: (req, res) => {
|
|
10687
|
+
const config = queries.getConfig();
|
|
10688
|
+
const projects = config.projects || [];
|
|
10689
|
+
const inputs = [CONFIG_PATH];
|
|
10690
|
+
for (const p of projects) {
|
|
10691
|
+
if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
10692
|
+
}
|
|
10693
|
+
return serveFreshJson(req, res, {
|
|
10694
|
+
tag: 'pull-requests',
|
|
10695
|
+
inputs,
|
|
10696
|
+
builder: () => queries.getPullRequests(),
|
|
10697
|
+
});
|
|
10698
|
+
}},
|
|
10699
|
+
{ method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
|
|
10700
|
+
return serveFreshJson(req, res, {
|
|
10701
|
+
tag: 'dispatch',
|
|
10702
|
+
inputs: [
|
|
10703
|
+
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
10704
|
+
path.join(ENGINE_DIR, 'metrics.json'),
|
|
10705
|
+
],
|
|
10706
|
+
builder: () => getDispatchQueue(),
|
|
10707
|
+
});
|
|
10708
|
+
}},
|
|
10709
|
+
{ method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
|
|
10710
|
+
const config = queries.getConfig();
|
|
10711
|
+
const projects = config.projects || [];
|
|
10712
|
+
const inputs = [
|
|
10713
|
+
path.join(ENGINE_DIR, 'metrics.json'),
|
|
10714
|
+
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
10715
|
+
];
|
|
10716
|
+
for (const p of projects) {
|
|
10717
|
+
if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
10718
|
+
}
|
|
10719
|
+
return serveFreshJson(req, res, {
|
|
10720
|
+
tag: 'metrics',
|
|
10721
|
+
inputs,
|
|
10722
|
+
builder: () => getMetrics(),
|
|
10723
|
+
});
|
|
10724
|
+
}},
|
|
10725
|
+
{ method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from prd/*.json × per-project work-items × pull-requests', handler: (req, res) => {
|
|
10726
|
+
const config = queries.getConfig();
|
|
10727
|
+
const projects = config.projects || [];
|
|
10728
|
+
const inputs = [
|
|
10729
|
+
CONFIG_PATH,
|
|
10730
|
+
PRD_DIR,
|
|
10731
|
+
path.join(PRD_DIR, 'archive'),
|
|
10732
|
+
// Plans dir — getPrdInfo statSyncs plans/<plan.source_plan> to
|
|
10733
|
+
// compute the `planStale` flag; edits to plans/*.md need to bust
|
|
10734
|
+
// the ETag so the Regenerate prompt surfaces. (Review finding #7.)
|
|
10735
|
+
path.join(MINIONS_DIR, 'plans'),
|
|
10736
|
+
// Central work-items.json at MINIONS_DIR root — getPrdInfo
|
|
10737
|
+
// cross-references plan items lacking a project scope. (Review #7.)
|
|
10738
|
+
path.join(MINIONS_DIR, 'work-items.json'),
|
|
10739
|
+
];
|
|
10740
|
+
for (const p of projects) {
|
|
10741
|
+
if (p && p.name) {
|
|
10742
|
+
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
|
|
10743
|
+
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
10744
|
+
}
|
|
10745
|
+
}
|
|
10746
|
+
return serveFreshJson(req, res, {
|
|
10747
|
+
tag: 'prd',
|
|
10748
|
+
inputs,
|
|
10749
|
+
builder: () => getPrdInfo(),
|
|
10750
|
+
});
|
|
10751
|
+
}},
|
|
10752
|
+
{ method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
|
|
10753
|
+
return serveFreshJson(req, res, {
|
|
10754
|
+
tag: 'archived-prds',
|
|
10755
|
+
inputs: [path.join(PRD_DIR, 'archive')],
|
|
10756
|
+
builder: () => getArchivedPrds(),
|
|
10757
|
+
});
|
|
10758
|
+
}},
|
|
10759
|
+
{ method: 'GET', path: '/api/verify-guides', desc: 'List of generated verify-guide files under prd/guides/', handler: (req, res) => {
|
|
10760
|
+
return serveFreshJson(req, res, {
|
|
10761
|
+
tag: 'verify-guides',
|
|
10762
|
+
inputs: [path.join(PRD_DIR, 'guides')],
|
|
10763
|
+
builder: () => getVerifyGuides(),
|
|
10764
|
+
});
|
|
10765
|
+
}},
|
|
10766
|
+
{ method: 'GET', path: '/api/pinned', desc: 'Parsed pinned-notes entries from pinned.md (prepended to every agent prompt)', handler: (req, res) => {
|
|
10767
|
+
return serveFreshJson(req, res, {
|
|
10768
|
+
tag: 'pinned',
|
|
10769
|
+
inputs: [PINNED_PATH],
|
|
10770
|
+
builder: () => {
|
|
10771
|
+
try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; }
|
|
10772
|
+
},
|
|
10773
|
+
});
|
|
10774
|
+
}},
|
|
10775
|
+
{ method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with schedule-runs.json overlay (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
|
|
10776
|
+
return serveFreshJson(req, res, {
|
|
10777
|
+
tag: 'schedules',
|
|
10778
|
+
inputs: [CONFIG_PATH, path.join(ENGINE_DIR, 'schedule-runs.json')],
|
|
10779
|
+
builder: () => {
|
|
10780
|
+
// Read config.json directly via queries.getConfig() rather than
|
|
10781
|
+
// calling reloadConfig() — the latter cascades into
|
|
10782
|
+
// ensureConfiguredProjectStateFiles() which acquires
|
|
10783
|
+
// mutateJsonFileLocked on every project's work-items.json AND
|
|
10784
|
+
// pull-requests.json, putting the schedules sidebar poll on the
|
|
10785
|
+
// critical path of every engine PR/WI writer. The builder only
|
|
10786
|
+
// needs config.schedules; getConfig is a pure disk read.
|
|
10787
|
+
// (Round-2 review finding #3.)
|
|
10788
|
+
const cfg = queries.getConfig() || {};
|
|
10789
|
+
const scheds = cfg.schedules || [];
|
|
10790
|
+
const runs = shared.safeJson(path.join(ENGINE_DIR, 'schedule-runs.json')) || {};
|
|
10791
|
+
return scheds.map(s => {
|
|
10792
|
+
const runEntry = runs[s.id];
|
|
10793
|
+
const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
|
|
10794
|
+
const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
|
|
10795
|
+
return { ...s, _lastRun, ...extra };
|
|
10796
|
+
});
|
|
10797
|
+
},
|
|
10798
|
+
});
|
|
10799
|
+
}},
|
|
10800
|
+
{ method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 runs each from pipeline-runs.json', handler: (req, res) => {
|
|
10801
|
+
return serveFreshJson(req, res, {
|
|
10802
|
+
tag: 'pipelines',
|
|
10803
|
+
inputs: [
|
|
10804
|
+
path.join(MINIONS_DIR, 'pipelines'),
|
|
10805
|
+
path.join(ENGINE_DIR, 'pipeline-runs.json'),
|
|
10806
|
+
],
|
|
10807
|
+
builder: () => {
|
|
10808
|
+
try {
|
|
10809
|
+
const pl = require('./engine/pipeline');
|
|
10810
|
+
return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) }));
|
|
10811
|
+
} catch {
|
|
10812
|
+
return [];
|
|
10813
|
+
}
|
|
10814
|
+
},
|
|
10815
|
+
});
|
|
10816
|
+
}},
|
|
10817
|
+
{ method: 'GET', path: '/api/qa-runs-summary', desc: 'Sidebar-counter summary {total, sig} for QA runs (sidebar activity-dot signal)', handler: (req, res) => {
|
|
10818
|
+
return serveFreshJson(req, res, {
|
|
10819
|
+
tag: 'qa-runs-summary',
|
|
10820
|
+
inputs: [path.join(ENGINE_DIR, 'qa-runs.json')],
|
|
10821
|
+
builder: () => qaRunsMod.summarizeRunsForStatus(),
|
|
10822
|
+
});
|
|
10823
|
+
}},
|
|
10824
|
+
{ method: 'GET', path: '/api/meetings-total', desc: 'Total meeting count (sidebar activity-dot signal — cheaper than fetching /api/meetings)', handler: (req, res) => {
|
|
10825
|
+
return serveFreshJson(req, res, {
|
|
10826
|
+
tag: 'meetings-total',
|
|
10827
|
+
inputs: [path.join(MINIONS_DIR, 'meetings')],
|
|
10828
|
+
builder: () => {
|
|
10829
|
+
const meetings = meetingMod.getMeetings();
|
|
10830
|
+
return { total: Array.isArray(meetings) ? meetings.length : 0 };
|
|
10831
|
+
},
|
|
10832
|
+
});
|
|
10833
|
+
}},
|
|
10636
10834
|
{ method: 'GET', path: '/api/browser-presence', desc: 'Whether a dashboard browser tab was recently active', handler: (req, res) => {
|
|
10637
10835
|
return jsonReply(res, 200, _getDashboardBrowserPresence(), req);
|
|
10638
10836
|
}},
|
|
@@ -11107,7 +11305,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11107
11305
|
|
|
11108
11306
|
// Schedules
|
|
11109
11307
|
{ method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
|
|
11110
|
-
{ method: 'GET', path: '/api/schedules', desc: 'Return schedules from config + last-run times', handler: handleSchedulesList },
|
|
11111
11308
|
{ method: 'POST', path: '/api/schedules', desc: 'Create a new schedule', params: 'cron, title, id?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesCreate },
|
|
11112
11309
|
{ method: 'POST', path: '/api/schedules/update', desc: 'Update an existing schedule', params: 'id, cron?, title?, type?, project?, agent?, description?, priority?, enabled?', handler: handleSchedulesUpdate },
|
|
11113
11310
|
{ method: 'POST', path: '/api/schedules/delete', desc: 'Delete a schedule', params: 'id', handler: handleSchedulesDelete },
|
|
@@ -11579,6 +11776,15 @@ module.exports = {
|
|
|
11579
11776
|
refreshStatusAsync,
|
|
11580
11777
|
handleStatus: _handleStatusRequest,
|
|
11581
11778
|
invalidateStatusCache,
|
|
11779
|
+
// Raw state-file passthrough — exported for direct unit testing.
|
|
11780
|
+
handleStateRead,
|
|
11781
|
+
STATE_READ_ALLOWED_DIRS,
|
|
11782
|
+
STATE_READ_ALLOWED_FILES,
|
|
11783
|
+
STATE_READ_DENIED_PATTERNS,
|
|
11784
|
+
STATE_READ_DENIED_BASENAMES,
|
|
11785
|
+
// Dedicated fresh-JSON endpoint helper (issue #2949).
|
|
11786
|
+
serveFreshJson,
|
|
11787
|
+
_maxInputMtimeMs,
|
|
11582
11788
|
_getStatusCacheVersion,
|
|
11583
11789
|
_setStatusRefreshHook,
|
|
11584
11790
|
_resetStatusCacheForTesting,
|