mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
feat(control-pane): live control-plane view with 2D projection and static-threshold advisories
Wire scripts/lib/agent-proximity/ and the #3028 coordination inventory into the control pane as one read-only live view, ecc.control-plane.view.v1, shaped as tasks, lanes and events. - agent-proximity/projection.js: rolling z-score per channel, tails clipped at the 2.5th and 97.5th percentile, mapped back to [0, 1], static channel weights, PCA (Jacobi on the 3x3 covariance) over x_tree, x_overlap, x_dep. Raw mode until the window holds 8 samples. No runtime dependencies. - agent-proximity/index.js: airspace links now carry the per-channel values. - agent-proximity/distance.js: rightOfWay(a, b) extracted from advise(). - control-pane/proximity.js: snapshot includes agent summaries (files, progress, startedAt) so the view can join sessions to working sets. - control-pane/control-plane-view.js: builds the view from the snapshot; advisory events derived from every pair link against the view's static thresholds (ta 0.35, ra 0.7), deterministic ids, steer/hold from rightOfWay; lease conflicts from the inventory as events; inventory manifest built from live sessions with sanitized ids, declared-only. - control-pane/server.js: GET /control-plane (self-contained page with the 2D canvas), GET /api/control-plane, GET /api/control-plane/events; one projection window per server so z-scores roll across polls. - docs/control-plane/VIEW-CONTRACT.md: the task/lane/event contract for reuse by the Ito ops control plane. - docs/control-plane/TCAS-HOOK.md: design for slice (b), not implemented. The view does not acquire leases, steer or pause agents, and claims no conflict-reduction number.
This commit is contained in:
@@ -270,6 +270,21 @@ function agentPriority(agent) {
|
||||
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-of-way between two agents: more progress wins; tie goes to the earlier
|
||||
* start (greater age); final deterministic tiebreak on agentId so the maneuver
|
||||
* is coordinated. Returns { hold, steer } as agentIds.
|
||||
*/
|
||||
function rightOfWay(a, b) {
|
||||
const pa = agentPriority(a);
|
||||
const pb = agentPriority(b);
|
||||
let aHasPriority;
|
||||
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||
return { hold: aHasPriority ? a.agentId : b.agentId, steer: aHasPriority ? b.agentId : a.agentId };
|
||||
}
|
||||
|
||||
/**
|
||||
* TCAS-style advisory between two agents given their collision risk.
|
||||
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
|
||||
@@ -284,17 +299,7 @@ function advise(a, b, graph = {}, options = {}) {
|
||||
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
|
||||
}
|
||||
|
||||
const pa = agentPriority(a);
|
||||
const pb = agentPriority(b);
|
||||
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
|
||||
// final deterministic tiebreak on agentId so the maneuver is coordinated.
|
||||
let aHasPriority;
|
||||
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||
|
||||
const hold = aHasPriority ? a.agentId : b.agentId;
|
||||
const steer = aHasPriority ? b.agentId : a.agentId;
|
||||
const { hold, steer } = rightOfWay(a, b);
|
||||
|
||||
if (risk < thresholds.ra) {
|
||||
// Traffic advisory: exchange intent, no one has to move yet.
|
||||
@@ -324,6 +329,7 @@ module.exports = {
|
||||
treeRisk,
|
||||
collisionRisk,
|
||||
agentPriority,
|
||||
rightOfWay,
|
||||
advise,
|
||||
closureRate,
|
||||
_internal: { normalizePath, segments, jaccard }
|
||||
|
||||
@@ -135,7 +135,8 @@ function scanAirspace(agents, graph = {}, options = {}) {
|
||||
b: b.agentId,
|
||||
risk: verdict.risk,
|
||||
distance: verdict.distance,
|
||||
level: verdict.level
|
||||
level: verdict.level,
|
||||
channels: verdict.channels
|
||||
});
|
||||
if (verdict.level !== 'clear') {
|
||||
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 2D projection of the pairwise proximity channels for the control-plane view.
|
||||
*
|
||||
* Input: one row per agent pair, the shipped channel vector
|
||||
* x = [x_tree, x_overlap, x_dep] each in [0, 1]
|
||||
* (distance.js: treeRisk, overlapRisk, dependencyRisk).
|
||||
*
|
||||
* Pipeline (COMPETITION-AND-VISION section 4, "Normalization and projection"):
|
||||
* 1. z-score each channel against a rolling window of pair samples,
|
||||
* 2. clip the tails at the 2.5th and 97.5th percentile of that window,
|
||||
* 3. map back to [0, 1],
|
||||
* 4. apply the static channel weights (same omega as the noisy-OR),
|
||||
* 5. PCA over the weighted matrix, keep the first two components.
|
||||
*
|
||||
* Agent positions are the risk-weighted centroid of the projected points of
|
||||
* the pairs the agent belongs to. Nothing here changes the risk or the
|
||||
* advisory: the projection is a display, not a decision.
|
||||
*
|
||||
* No runtime dependencies. The eigen-decomposition is a Jacobi sweep over the
|
||||
* 3x3 covariance matrix, which is exact enough for a display.
|
||||
*/
|
||||
|
||||
const CHANNEL_ORDER = ['tree', 'overlap', 'dependency'];
|
||||
const CHANNEL_LABELS = { tree: 'x_tree', overlap: 'x_overlap', dependency: 'x_dep' };
|
||||
|
||||
const PROJECTION_DEFAULTS = {
|
||||
windowSize: 512,
|
||||
minWindowForZscore: 8,
|
||||
clipPercentiles: [2.5, 97.5],
|
||||
components: 2
|
||||
};
|
||||
|
||||
function finite(x) {
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
if (values.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const v of values) s += v;
|
||||
return s / values.length;
|
||||
}
|
||||
|
||||
function stddev(values, mu) {
|
||||
if (values.length < 2) return 0;
|
||||
let s = 0;
|
||||
for (const v of values) s += (v - mu) * (v - mu);
|
||||
return Math.sqrt(s / (values.length - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolated percentile (p in [0, 100]) of a numeric array.
|
||||
*/
|
||||
function percentile(values, p) {
|
||||
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return 0;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling window of pair channel samples. Each push records one sample vector;
|
||||
* the window keeps the newest `size` samples. `stats()` returns, per channel,
|
||||
* the mean, standard deviation and clip bounds (in z units) used to normalize.
|
||||
*/
|
||||
function createProjectionWindow(options = {}) {
|
||||
const size = Number.isFinite(options.windowSize) && options.windowSize > 0 ? Math.floor(options.windowSize) : PROJECTION_DEFAULTS.windowSize;
|
||||
const [pLo, pHi] = Array.isArray(options.clipPercentiles) && options.clipPercentiles.length === 2 ? options.clipPercentiles : PROJECTION_DEFAULTS.clipPercentiles;
|
||||
const samples = [];
|
||||
|
||||
return {
|
||||
size,
|
||||
push(vector) {
|
||||
const row = CHANNEL_ORDER.map((_, i) => finite(vector[i]));
|
||||
samples.push(row);
|
||||
if (samples.length > size) samples.splice(0, samples.length - size);
|
||||
return samples.length;
|
||||
},
|
||||
get length() {
|
||||
return samples.length;
|
||||
},
|
||||
stats() {
|
||||
const per = CHANNEL_ORDER.map((channel, i) => {
|
||||
const column = samples.map(row => row[i]);
|
||||
const mu = mean(column);
|
||||
const sigma = stddev(column, mu);
|
||||
const z = sigma > 0 ? column.map(v => (v - mu) / sigma) : column.map(() => 0);
|
||||
return {
|
||||
channel,
|
||||
mean: mu,
|
||||
stddev: sigma,
|
||||
clipLow: percentile(z, pLo),
|
||||
clipHigh: percentile(z, pHi)
|
||||
};
|
||||
});
|
||||
return { samples: samples.length, percentiles: [pLo, pHi], channels: per };
|
||||
},
|
||||
reset() {
|
||||
samples.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* z-score one sample against the window stats, clip to the percentile bounds,
|
||||
* map back to [0, 1]. A channel with zero variance maps to 0.5.
|
||||
*/
|
||||
function normalizeSample(vector, stats) {
|
||||
return CHANNEL_ORDER.map((_, i) => {
|
||||
const s = stats.channels[i];
|
||||
const v = finite(vector[i]);
|
||||
if (!(s.stddev > 0)) return 0.5;
|
||||
const z = (v - s.mean) / s.stddev;
|
||||
const lo = s.clipLow;
|
||||
const hi = s.clipHigh;
|
||||
if (!(hi > lo)) return 0.5;
|
||||
const clipped = Math.min(hi, Math.max(lo, z));
|
||||
return (clipped - lo) / (hi - lo);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Jacobi eigen-decomposition of a small symmetric matrix. Returns eigenvalues
|
||||
* (descending) and the matching unit eigenvectors (as columns).
|
||||
*/
|
||||
function symmetricEigen(matrix) {
|
||||
const n = matrix.length;
|
||||
const a = matrix.map(row => row.slice());
|
||||
const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
|
||||
for (let sweep = 0; sweep < 64; sweep += 1) {
|
||||
let off = 0;
|
||||
for (let p = 0; p < n; p += 1) for (let q = p + 1; q < n; q += 1) off += a[p][q] * a[p][q];
|
||||
if (off < 1e-18) break;
|
||||
for (let p = 0; p < n; p += 1) {
|
||||
for (let q = p + 1; q < n; q += 1) {
|
||||
if (Math.abs(a[p][q]) < 1e-14) continue;
|
||||
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
|
||||
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
||||
const c = 1 / Math.sqrt(t * t + 1);
|
||||
const s = t * c;
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const akp = a[k][p];
|
||||
const akq = a[k][q];
|
||||
a[k][p] = c * akp - s * akq;
|
||||
a[k][q] = s * akp + c * akq;
|
||||
}
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const apk = a[p][k];
|
||||
const aqk = a[q][k];
|
||||
a[p][k] = c * apk - s * aqk;
|
||||
a[q][k] = s * apk + c * aqk;
|
||||
}
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const vkp = v[k][p];
|
||||
const vkq = v[k][q];
|
||||
v[k][p] = c * vkp - s * vkq;
|
||||
v[k][q] = s * vkp + c * vkq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => a[j][j] - a[i][i]);
|
||||
return {
|
||||
values: order.map(i => a[i][i]),
|
||||
vectors: order.map(i => v.map(row => row[i]))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PCA over a row matrix. Returns the scores for the first `components`
|
||||
* components, the loadings (unit eigenvectors) and the explained variance.
|
||||
* Fewer than two rows, or zero total variance, yields all-zero scores.
|
||||
*/
|
||||
function pca(rows, components = PROJECTION_DEFAULTS.components) {
|
||||
const n = rows.length;
|
||||
const dims = n > 0 ? rows[0].length : CHANNEL_ORDER.length;
|
||||
const k = Math.max(1, Math.min(components, dims));
|
||||
const centre = Array.from({ length: dims }, (_, d) => mean(rows.map(r => r[d])));
|
||||
const zeroScores = rows.map(() => new Array(k).fill(0));
|
||||
if (n < 2) {
|
||||
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
|
||||
}
|
||||
const cov = Array.from({ length: dims }, () => new Array(dims).fill(0));
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < dims; i += 1) {
|
||||
for (let j = i; j < dims; j += 1) {
|
||||
cov[i][j] += (row[i] - centre[i]) * (row[j] - centre[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < dims; i += 1) for (let j = i; j < dims; j += 1) {
|
||||
cov[i][j] /= n - 1;
|
||||
cov[j][i] = cov[i][j];
|
||||
}
|
||||
const total = cov.reduce((s, row, i) => s + row[i], 0);
|
||||
if (!(total > 1e-12)) {
|
||||
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
|
||||
}
|
||||
const eig = symmetricEigen(cov);
|
||||
const loadings = eig.vectors.slice(0, k);
|
||||
const scores = rows.map(row => loadings.map(vec => vec.reduce((s, w, d) => s + w * (row[d] - centre[d]), 0)));
|
||||
const explainedVariance = eig.values.slice(0, k).map(val => Math.max(0, val) / total);
|
||||
return { scores, loadings, explainedVariance, centre };
|
||||
}
|
||||
|
||||
function channelVector(channels) {
|
||||
return CHANNEL_ORDER.map(key => finite(channels && channels[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a set of pair links ({ a, b, risk, channels }) to 2D.
|
||||
*
|
||||
* The window is optional; when given, each link's channel vector is pushed
|
||||
* into it and the normalization uses the window stats (rolling z-score plus
|
||||
* tail clip). Without a window, or while the window holds fewer than
|
||||
* `minWindowForZscore` samples, the raw [0, 1] channel values are used and the
|
||||
* result says so (`normalization: 'raw'`).
|
||||
*
|
||||
* @returns {{ pairs, agents, normalization, window, pca }}
|
||||
*/
|
||||
function projectPairs(links, options = {}) {
|
||||
const list = Array.isArray(links) ? links.filter(l => l && l.a !== undefined && l.b !== undefined) : [];
|
||||
const weights = { tree: 0.25, overlap: 1.0, dependency: 0.9, ...(options.channelWeights || {}) };
|
||||
const window = options.window || null;
|
||||
const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore;
|
||||
|
||||
const raw = list.map(l => channelVector(l.channels));
|
||||
if (window) for (const vec of raw) window.push(vec);
|
||||
|
||||
let stats = null;
|
||||
let normalization = 'raw';
|
||||
let normalized = raw;
|
||||
if (window && window.length >= minWindow) {
|
||||
stats = window.stats();
|
||||
normalized = raw.map(vec => normalizeSample(vec, stats));
|
||||
normalization = 'zscore-clipped';
|
||||
}
|
||||
const weighted = normalized.map(vec => vec.map((v, i) => v * finite(weights[CHANNEL_ORDER[i]])));
|
||||
const result = pca(weighted, options.components || PROJECTION_DEFAULTS.components);
|
||||
|
||||
const pairs = list.map((l, i) => ({
|
||||
a: l.a,
|
||||
b: l.b,
|
||||
risk: finite(l.risk),
|
||||
level: l.level || null,
|
||||
channels: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], raw[i][d]])),
|
||||
normalized: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], normalized[i][d]])),
|
||||
point: result.scores[i]
|
||||
}));
|
||||
|
||||
// Agent position: risk-weighted centroid of its pair points. A floor keeps
|
||||
// a clear pair from vanishing, so every agent with a pair gets a position.
|
||||
const byAgent = new Map();
|
||||
for (const pair of pairs) {
|
||||
const w = 0.05 + pair.risk;
|
||||
for (const id of [pair.a, pair.b]) {
|
||||
const acc = byAgent.get(id) || { sum: pair.point.map(() => 0), w: 0, pairs: 0, maxRisk: 0 };
|
||||
pair.point.forEach((x, d) => {
|
||||
acc.sum[d] += x * w;
|
||||
});
|
||||
acc.w += w;
|
||||
acc.pairs += 1;
|
||||
acc.maxRisk = Math.max(acc.maxRisk, pair.risk);
|
||||
byAgent.set(id, acc);
|
||||
}
|
||||
}
|
||||
const agents = [...byAgent.entries()].map(([agentId, acc]) => ({
|
||||
agentId,
|
||||
point: acc.sum.map(x => (acc.w > 0 ? x / acc.w : 0)),
|
||||
pairs: acc.pairs,
|
||||
maxRisk: acc.maxRisk
|
||||
}));
|
||||
|
||||
return {
|
||||
method: 'pca',
|
||||
channels: CHANNEL_ORDER.map(key => CHANNEL_LABELS[key]),
|
||||
weights: Object.fromEntries(CHANNEL_ORDER.map(key => [CHANNEL_LABELS[key], finite(weights[key])])),
|
||||
normalization,
|
||||
window: stats ? { samples: stats.samples, percentiles: stats.percentiles, channels: stats.channels.map(c => ({ ...c, channel: CHANNEL_LABELS[c.channel] })) } : { samples: window ? window.length : 0, percentiles: PROJECTION_DEFAULTS.clipPercentiles, channels: [] },
|
||||
pca: {
|
||||
loadings: result.loadings.map(vec => Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], vec[d]]))),
|
||||
explainedVariance: result.explainedVariance
|
||||
},
|
||||
pairs,
|
||||
agents
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROJECTION_DEFAULTS,
|
||||
CHANNEL_ORDER,
|
||||
CHANNEL_LABELS,
|
||||
percentile,
|
||||
createProjectionWindow,
|
||||
normalizeSample,
|
||||
pca,
|
||||
projectPairs,
|
||||
_internal: { symmetricEigen, mean, stddev }
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-contained control-plane live view page, served at /control-plane.
|
||||
*
|
||||
* Draws the 2D PCA projection of the agent pairs (projection.js) on a canvas,
|
||||
* the lanes and tasks beside it, and the advisory event feed. Polls
|
||||
* /api/control-plane. No external scripts, no framework: it has to work on a
|
||||
* loopback server with a strict CSP and offline.
|
||||
*/
|
||||
|
||||
function renderControlPlaneViewHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ECC Control Plane</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
|
||||
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
|
||||
header h1 { font-size: 15px; margin: 0; }
|
||||
header .sub { color: #8b949e; font-size: 12px; }
|
||||
header nav { margin-left: auto; font-size: 12px; }
|
||||
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
|
||||
header nav a:hover { color: #e6edf3; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
|
||||
#stage { position: relative; border-right: 1px solid #1f2630; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
#side { padding: 12px 14px; overflow-y: auto; }
|
||||
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
|
||||
#side h2:first-child { margin-top: 0; }
|
||||
.ev { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
|
||||
.ev.resolution { border-color: #b3402f; }
|
||||
.ev.traffic { border-color: #9a6700; }
|
||||
.ev.conflict { border-color: #58a6ff; }
|
||||
.ev .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.ev.resolution .lv { color: #ff7b72; }
|
||||
.ev.traffic .lv { color: #e3b341; }
|
||||
.ev.conflict .lv { color: #58a6ff; }
|
||||
.ev .msg { color: #c9d1d9; font-size: 12px; margin-top: 3px; }
|
||||
.lane { margin-bottom: 10px; }
|
||||
.lane .name { color: #c9d1d9; font-weight: 600; font-size: 12px; }
|
||||
.task { display: flex; gap: 8px; align-items: baseline; font-size: 12px; padding: 2px 0 2px 8px; color: #8b949e; }
|
||||
.task .id { color: #c9d1d9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.task .risk { margin-left: auto; }
|
||||
.empty { color: #6e7681; font-size: 12px; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ECC Control Plane</h1>
|
||||
<span class="sub" id="status">connecting...</span>
|
||||
<nav><a href="/">board</a><a href="/proximity">3D airspace</a><a href="/api/control-plane">json</a></nav>
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<div id="meta"></div>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Events</h2>
|
||||
<div id="events"><div class="empty">No events.</div></div>
|
||||
<h2>Lanes</h2>
|
||||
<div id="lanes"><div class="empty">No tasks.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
|
||||
|
||||
function resize() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.floor(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(r.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
draw();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= view.thresholds.ra) return '#ff7b72';
|
||||
if (risk >= view.thresholds.ta) return '#e3b341';
|
||||
return '#3fb950';
|
||||
}
|
||||
|
||||
// Fit the projected points into the canvas with a margin. The PCA scores
|
||||
// are centred already, so we only need a scale.
|
||||
function fit(points, w, h) {
|
||||
var maxAbs = 1e-6;
|
||||
points.forEach(function (p) {
|
||||
maxAbs = Math.max(maxAbs, Math.abs(p[0] || 0), Math.abs(p[1] || 0));
|
||||
});
|
||||
var scale = (Math.min(w, h) * 0.4) / maxAbs;
|
||||
return function (p) { return [w / 2 + (p[0] || 0) * scale, h / 2 - (p[1] || 0) * scale]; };
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var agents = view.projection.agents || [];
|
||||
var toScreen = fit(agents.map(function (a) { return a.point; }), w, h);
|
||||
var pos = {};
|
||||
agents.forEach(function (a) { pos[a.agentId] = toScreen(a.point); });
|
||||
|
||||
// axes
|
||||
ctx.strokeStyle = '#1f2630';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
|
||||
|
||||
// pair links under the points
|
||||
(view.pairs || []).forEach(function (pair) {
|
||||
if (pair.risk < 0.2) return;
|
||||
var pa = pos[pair.a], pb = pos[pair.b];
|
||||
if (!pa || !pb) return;
|
||||
ctx.strokeStyle = riskColor(pair.risk);
|
||||
ctx.globalAlpha = Math.min(1, 0.25 + pair.risk * 0.7);
|
||||
ctx.lineWidth = 1 + pair.risk * 3;
|
||||
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// agents
|
||||
var taskById = {};
|
||||
view.tasks.forEach(function (t) { taskById[t.id] = t; });
|
||||
agents.forEach(function (a) {
|
||||
var p = pos[a.agentId];
|
||||
var t = taskById[a.agentId] || {};
|
||||
var files = (t.workingSet && t.workingSet.fileCount) || 1;
|
||||
var radius = 6 + Math.sqrt(files) * 3;
|
||||
ctx.fillStyle = riskColor(a.maxRisk || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
});
|
||||
|
||||
// singleton tasks (no pair yet) sit at the origin, listed in the side panel
|
||||
var meta = document.getElementById('meta');
|
||||
var pj = view.projection || {};
|
||||
var ev = (pj.pca && pj.pca.explainedVariance) || [];
|
||||
meta.textContent = 'pca over x_tree, x_overlap, x_dep | ' + (pj.normalization || 'raw') +
|
||||
(pj.window && pj.window.samples ? ' | window ' + pj.window.samples : '') +
|
||||
(ev.length ? ' | var ' + ev.map(function (x) { return Math.round(x * 100) + '%'; }).join(' / ') : '');
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
var box = document.getElementById('events');
|
||||
box.textContent = '';
|
||||
if (!view.events.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty';
|
||||
e.textContent = 'No events. Airspace clear, no declared lease conflicts.'; box.appendChild(e); return;
|
||||
}
|
||||
view.events.forEach(function (ev) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'ev ' + ev.level;
|
||||
var lv = document.createElement('div'); lv.className = 'lv';
|
||||
lv.textContent = (ev.risk !== undefined ? Math.round(ev.risk * 100) + '% ' : '') + ev.kind + ' / ' + ev.level;
|
||||
el.appendChild(lv);
|
||||
var msg = document.createElement('div'); msg.className = 'msg';
|
||||
msg.textContent = ev.message; el.appendChild(msg);
|
||||
box.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLanes() {
|
||||
var box = document.getElementById('lanes');
|
||||
box.textContent = '';
|
||||
if (!view.tasks.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty';
|
||||
e.textContent = 'No tasks.'; box.appendChild(e); return;
|
||||
}
|
||||
var taskById = {};
|
||||
view.tasks.forEach(function (t) { taskById[t.id] = t; });
|
||||
view.lanes.forEach(function (lane) {
|
||||
var el = document.createElement('div'); el.className = 'lane';
|
||||
var name = document.createElement('div'); name.className = 'name';
|
||||
name.textContent = lane.label + ' (' + lane.kind + ', ' + lane.taskIds.length + ')';
|
||||
el.appendChild(name);
|
||||
lane.taskIds.forEach(function (id) {
|
||||
var t = taskById[id]; if (!t) return;
|
||||
var row = document.createElement('div'); row.className = 'task';
|
||||
var idEl = document.createElement('span'); idEl.className = 'id'; idEl.textContent = t.id.slice(0, 20);
|
||||
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
|
||||
var risk = document.createElement('span'); risk.className = 'risk';
|
||||
risk.style.color = riskColor(t.projection.maxRisk || 0);
|
||||
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
|
||||
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
|
||||
el.appendChild(row);
|
||||
});
|
||||
box.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
view = data || view;
|
||||
view.tasks = view.tasks || []; view.lanes = view.lanes || []; view.pairs = view.pairs || [];
|
||||
view.events = view.events || []; view.projection = view.projection || { agents: [] };
|
||||
view.thresholds = view.thresholds || { ta: 0.35, ra: 0.7 };
|
||||
renderEvents(); renderLanes(); draw();
|
||||
var c = view.counts || {};
|
||||
document.getElementById('status').textContent =
|
||||
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
|
||||
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
|
||||
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/control-plane').then(function (r) { return r.json(); }).then(apply).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}
|
||||
|
||||
resize();
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { renderControlPlaneViewHtml };
|
||||
@@ -0,0 +1,338 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC control-plane live view.
|
||||
*
|
||||
* One JSON document, `ecc.control-plane.view.v1`, that joins three things the
|
||||
* repo already computes separately:
|
||||
*
|
||||
* 1. the control-pane session snapshot (state.js): who is running where,
|
||||
* 2. the agent-proximity airspace scan (agent-proximity + proximity.js):
|
||||
* pairwise collision risk over the shipped channels x_tree, x_overlap,
|
||||
* x_dep, with the 2D PCA projection from agent-proximity/projection.js,
|
||||
* 3. the coordination inventory (coordination-inventory.js, PR #3028):
|
||||
* declared tasks and sessions, heartbeat freshness, lease conflicts.
|
||||
*
|
||||
* The output is shaped as tasks, lanes and events so another control plane
|
||||
* (the Ito ops board) can consume it without knowing ECC internals:
|
||||
*
|
||||
* task = one agent session (id, lane, harness, state, worktree, working
|
||||
* set size, projected point, inventory observation)
|
||||
* lane = a grouping of tasks (task group, project, or harness)
|
||||
* event = something an operator or a hook may act on. Today: a proximity
|
||||
* advisory at a static threshold, or a lease conflict.
|
||||
*
|
||||
* Everything here is read-only and advisory. The view does not acquire
|
||||
* leases, does not steer agents and does not claim a conflict-reduction
|
||||
* number. See docs/control-plane/VIEW-CONTRACT.md.
|
||||
*/
|
||||
|
||||
const { DEFAULTS, rightOfWay } = require('../agent-proximity/distance');
|
||||
const { projectPairs, createProjectionWindow } = require('../agent-proximity/projection');
|
||||
|
||||
const VIEW_SCHEMA_VERSION = 'ecc.control-plane.view.v1';
|
||||
const EVENT_KINDS = {
|
||||
advisory: 'proximity.advisory',
|
||||
leaseConflict: 'inventory.lease-conflict'
|
||||
};
|
||||
|
||||
const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/;
|
||||
const OPEN_STATES = new Set(['running', 'pending', 'idle']);
|
||||
const CLOSED_STATES = new Set(['completed', 'failed', 'stopped']);
|
||||
|
||||
function isoOrNull(value) {
|
||||
if (!value) return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a session id to an identifier the inventory accepts. Replaces anything
|
||||
* outside the allowed alphabet, strips a leading non-alphanumeric run, and
|
||||
* falls back to a positional id. Callers get the mapping back so a consumer
|
||||
* can join inventory rows to tasks.
|
||||
*/
|
||||
function inventoryIdFor(id, index, taken) {
|
||||
let candidate = String(id || '')
|
||||
.replace(/[^a-zA-Z0-9_.:-]/g, '-')
|
||||
.replace(/^[^a-zA-Z0-9]+/, '')
|
||||
.slice(0, 200);
|
||||
if (!candidate || ['__proto__', 'constructor', 'prototype'].includes(candidate)) candidate = `task-${index + 1}`;
|
||||
let unique = candidate;
|
||||
let n = 2;
|
||||
while (taken.has(unique)) {
|
||||
unique = `${candidate.slice(0, 190)}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
taken.add(unique);
|
||||
return IDENTIFIER.test(unique) ? unique : `task-${index + 1}`;
|
||||
}
|
||||
|
||||
function laneFor(session) {
|
||||
if (session.taskGroup) return { id: `group:${session.taskGroup}`, label: session.taskGroup, kind: 'task-group' };
|
||||
if (session.project) return { id: `project:${session.project}`, label: session.project, kind: 'project' };
|
||||
const harness = session.harness || 'unknown';
|
||||
return { id: `harness:${harness}`, label: harness, kind: 'harness' };
|
||||
}
|
||||
|
||||
function sessionDeclarationStatus(state) {
|
||||
if (OPEN_STATES.has(state)) return 'open';
|
||||
if (CLOSED_STATES.has(state)) return 'closed';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the #3028 manifest from live sessions plus the working sets the
|
||||
* proximity scan already extracted. Declared-only by construction: the
|
||||
* inventory library labels every row `declared-only` and this view keeps
|
||||
* that label.
|
||||
*/
|
||||
function buildInventoryManifest(sessions, agentsById, options = {}) {
|
||||
const taken = new Set();
|
||||
const idMap = new Map();
|
||||
const tasks = [];
|
||||
const declaredSessions = [];
|
||||
const limited = (sessions || []).slice(0, 64);
|
||||
limited.forEach((session, index) => {
|
||||
const invId = inventoryIdFor(session.id, index, taken);
|
||||
idMap.set(session.id, invId);
|
||||
const agent = agentsById.get(session.id);
|
||||
const paths = (agent ? agent.files : []).filter(p => typeof p === 'string' && !p.startsWith('/') && !/^[A-Za-z]:/.test(p) && !p.split('/').some(x => !x || x === '.' || x === '..')).slice(0, 128);
|
||||
tasks.push({
|
||||
id: invId,
|
||||
repoId: null,
|
||||
paths,
|
||||
pid: Number.isSafeInteger(session.pid) && session.pid > 0 ? session.pid : null,
|
||||
status: String(session.state || 'unknown').slice(0, 200) || 'unknown',
|
||||
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
|
||||
statusFileModifiedAt: isoOrNull(session.updatedAt)
|
||||
});
|
||||
declaredSessions.push({
|
||||
id: invId,
|
||||
taskId: invId,
|
||||
goalId: null,
|
||||
status: sessionDeclarationStatus(session.state),
|
||||
updatedAt: isoOrNull(session.lastHeartbeatAt || session.updatedAt)
|
||||
});
|
||||
});
|
||||
const extra = options.manifest && typeof options.manifest === 'object' ? options.manifest : {};
|
||||
return {
|
||||
manifest: {
|
||||
version: 1,
|
||||
repositories: Array.isArray(extra.repositories) ? extra.repositories : [],
|
||||
tasks: [...tasks, ...(Array.isArray(extra.tasks) ? extra.tasks : [])],
|
||||
sessions: [...declaredSessions, ...(Array.isArray(extra.sessions) ? extra.sessions : [])],
|
||||
goals: Array.isArray(extra.goals) ? extra.goals : [],
|
||||
leases: Array.isArray(extra.leases) ? extra.leases : []
|
||||
},
|
||||
idMap,
|
||||
truncated: (sessions || []).length > limited.length
|
||||
};
|
||||
}
|
||||
|
||||
function runInventory(sessions, agentsById, options = {}) {
|
||||
const built = buildInventoryManifest(sessions, agentsById, options);
|
||||
try {
|
||||
const { buildInventory } = options.inventoryModule || require('../coordination-inventory');
|
||||
const report = buildInventory(built.manifest, { now: options.now, resources: options.resources });
|
||||
return { status: 'ok', idMap: built.idMap, truncated: built.truncated, report };
|
||||
} catch (error) {
|
||||
return { status: 'unavailable', idMap: built.idMap, truncated: built.truncated, reason: error.message, report: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent shape the right-of-way rule needs, rebuilt from the proximity
|
||||
* snapshot's agent summaries (progress = recency-weighted file count).
|
||||
*/
|
||||
function priorityAgent(summary, agentId) {
|
||||
if (!summary) return { agentId, files: [], startedAt: null };
|
||||
const progress = Number.isFinite(summary.progress) ? summary.progress : summary.fileCount || 0;
|
||||
return { agentId, startedAt: summary.startedAt || null, files: [{ path: '', weight: progress }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-threshold advisory events, derived from every pair link against the
|
||||
* view's own thresholds so an override changes the events, not only labels.
|
||||
* The risk itself comes from the scan (noisy-OR, unchanged).
|
||||
*/
|
||||
function advisoryEvents(links, agentsById, thresholds, at) {
|
||||
const events = [];
|
||||
for (const link of links || []) {
|
||||
if (!link || !Number.isFinite(link.risk) || link.risk < thresholds.ta) continue;
|
||||
const resolution = link.risk >= thresholds.ra;
|
||||
const level = resolution ? 'resolution' : 'traffic';
|
||||
const a = agentsById.get(link.a);
|
||||
const b = agentsById.get(link.b);
|
||||
const aLabel = (a && a.label) || link.a;
|
||||
const bLabel = (b && b.label) || link.b;
|
||||
const way = resolution ? rightOfWay(priorityAgent(a, link.a), priorityAgent(b, link.b)) : { steer: null, hold: null };
|
||||
const channels = link.channels || {};
|
||||
events.push({
|
||||
id: `${EVENT_KINDS.advisory}:${link.a}|${link.b}:${level}`,
|
||||
kind: EVENT_KINDS.advisory,
|
||||
level,
|
||||
severity: resolution ? 'critical' : 'warning',
|
||||
at,
|
||||
subject: { a: link.a, b: link.b, aLabel, bLabel },
|
||||
risk: link.risk,
|
||||
distance: Number.isFinite(link.distance) ? link.distance : 1 - link.risk,
|
||||
channels: {
|
||||
x_tree: Number.isFinite(channels.tree) ? channels.tree : null,
|
||||
x_overlap: Number.isFinite(channels.overlap) ? channels.overlap : null,
|
||||
x_dep: Number.isFinite(channels.dependency) ? channels.dependency : null
|
||||
},
|
||||
threshold: { ta: thresholds.ta, ra: thresholds.ra, crossed: resolution ? 'ra' : 'ta', source: 'static' },
|
||||
action: resolution ? { type: 'steer', steer: way.steer, hold: way.hold } : { type: 'transmit', steer: null, hold: null },
|
||||
message: resolution
|
||||
? `Resolution advisory: ${way.steer} steers, ${way.hold} holds (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ra}).`
|
||||
: `Traffic advisory: ${link.a} and ${link.b} transmit intent (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ta}).`
|
||||
});
|
||||
}
|
||||
events.sort((x, y) => y.risk - x.risk);
|
||||
return events;
|
||||
}
|
||||
|
||||
function leaseConflictEvents(report, at) {
|
||||
if (!report || !Array.isArray(report.leaseConflicts)) return [];
|
||||
return report.leaseConflicts.map(conflict => ({
|
||||
id: `${EVENT_KINDS.leaseConflict}:${conflict.resource}`,
|
||||
kind: EVENT_KINDS.leaseConflict,
|
||||
level: 'conflict',
|
||||
severity: 'warning',
|
||||
at,
|
||||
subject: { resource: conflict.resource, owners: conflict.owners },
|
||||
action: { type: 'review', steer: null, hold: null },
|
||||
message: `Declared lease conflict on ${conflict.resource}: ${conflict.owners.join(', ')}. Declared-only, not a lock.`
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the live view from a control-pane snapshot that already carries a
|
||||
* `proximity` field (buildControlPaneSnapshot with includeProximity: true).
|
||||
*
|
||||
* @param {object} snapshot control-pane snapshot
|
||||
* @param {object} [options] { window, thresholds, now, manifest, resources, channelWeights }
|
||||
*/
|
||||
function buildControlPlaneView(snapshot, options = {}) {
|
||||
const at = options.now || new Date().toISOString();
|
||||
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
|
||||
const sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
|
||||
const prox = (snapshot && snapshot.proximity) || {};
|
||||
const agents = Array.isArray(prox.agents) ? prox.agents : [];
|
||||
const agentsById = new Map(agents.map(a => [a.agentId, a]));
|
||||
|
||||
const projection = projectPairs(prox.links || [], {
|
||||
window: options.window,
|
||||
channelWeights: options.channelWeights,
|
||||
minWindowForZscore: options.minWindowForZscore
|
||||
});
|
||||
const pointByAgent = new Map(projection.agents.map(a => [a.agentId, a]));
|
||||
|
||||
const inventory = runInventory(sessions, agentsById, {
|
||||
now: at,
|
||||
manifest: options.manifest,
|
||||
resources: options.resources,
|
||||
inventoryModule: options.inventoryModule
|
||||
});
|
||||
const inventoryTaskById = new Map();
|
||||
if (inventory.report) for (const task of inventory.report.tasks || []) inventoryTaskById.set(task.id, task);
|
||||
|
||||
const lanes = new Map();
|
||||
const tasks = sessions.map(session => {
|
||||
const lane = laneFor(session);
|
||||
if (!lanes.has(lane.id)) lanes.set(lane.id, { ...lane, taskIds: [] });
|
||||
lanes.get(lane.id).taskIds.push(session.id);
|
||||
const agent = agentsById.get(session.id);
|
||||
const projected = pointByAgent.get(session.id);
|
||||
const invId = inventory.idMap.get(session.id) || null;
|
||||
const invTask = invId ? inventoryTaskById.get(invId) : null;
|
||||
return {
|
||||
id: session.id,
|
||||
lane: lane.id,
|
||||
label: session.task || session.id,
|
||||
harness: session.harness || 'unknown',
|
||||
agentType: session.agentType || '',
|
||||
state: session.state || 'unknown',
|
||||
pid: session.pid === undefined ? null : session.pid,
|
||||
worktree: session.worktree || null,
|
||||
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
|
||||
updatedAt: isoOrNull(session.updatedAt),
|
||||
workingSet: { fileCount: agent ? agent.fileCount : 0, files: agent ? agent.files : [] },
|
||||
projection: projected ? { point: projected.point, pairs: projected.pairs, maxRisk: projected.maxRisk } : { point: null, pairs: 0, maxRisk: 0 },
|
||||
inventory: invTask ? { id: invId, heartbeat: invTask.heartbeat, process: invTask.process, authority: 'declared-only' } : { id: invId, heartbeat: null, process: null, authority: 'declared-only' }
|
||||
};
|
||||
});
|
||||
|
||||
const events = [...advisoryEvents(prox.links, agentsById, thresholds, at), ...leaseConflictEvents(inventory.report, at)];
|
||||
|
||||
const { pairs, agents: projectedAgents, ...projectionMeta } = projection;
|
||||
return {
|
||||
schemaVersion: VIEW_SCHEMA_VERSION,
|
||||
generatedAt: at,
|
||||
source: {
|
||||
snapshotSchema: snapshot ? snapshot.schemaVersion || null : null,
|
||||
repoRoot: snapshot ? snapshot.repoRoot || null : null,
|
||||
dbPath: snapshot ? snapshot.dbPath || null : null
|
||||
},
|
||||
thresholds: { ta: thresholds.ta, ra: thresholds.ra, source: 'static' },
|
||||
lanes: [...lanes.values()],
|
||||
tasks,
|
||||
pairs,
|
||||
events,
|
||||
projection: { ...projectionMeta, agents: projectedAgents },
|
||||
inventory: inventory.report
|
||||
? {
|
||||
status: 'ok',
|
||||
truncated: inventory.truncated,
|
||||
observedAt: inventory.report.observedAt,
|
||||
mode: inventory.report.mode,
|
||||
activity: inventory.report.activity,
|
||||
leaseConflicts: inventory.report.leaseConflicts,
|
||||
warnings: inventory.report.warnings,
|
||||
coverage: inventory.report.coverage,
|
||||
limits: inventory.report.limits
|
||||
}
|
||||
: { status: inventory.status, truncated: inventory.truncated, reason: inventory.reason || null },
|
||||
counts: {
|
||||
lanes: lanes.size,
|
||||
tasks: tasks.length,
|
||||
agents: agents.length,
|
||||
pairs: pairs.length,
|
||||
events: events.length,
|
||||
advisories: events.filter(e => e.kind === EVENT_KINDS.advisory).length,
|
||||
resolutions: events.filter(e => e.kind === EVENT_KINDS.advisory && e.level === 'resolution').length
|
||||
},
|
||||
limits: [
|
||||
'Advisories use static thresholds; no learned threshold and no conflict-reduction claim.',
|
||||
'Projection is a display over the shipped channels x_tree, x_overlap, x_dep; it does not change risk.',
|
||||
'Inventory rows are declared-only observations; leases are not locks.',
|
||||
'The view does not steer, pause or lock any agent.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful view builder for a long-lived server: keeps one projection window
|
||||
* so z-scores roll over ticks. `buildSnapshot()` is injected (it is the
|
||||
* control-pane snapshot with includeProximity: true).
|
||||
*/
|
||||
function createControlPlaneViewSource(deps = {}) {
|
||||
const window = deps.window || createProjectionWindow(deps.projection || {});
|
||||
return {
|
||||
window,
|
||||
async build(extra = {}) {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
return buildControlPlaneView(snapshot, { ...deps.viewOptions, ...extra, window });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VIEW_SCHEMA_VERSION,
|
||||
EVENT_KINDS,
|
||||
buildControlPlaneView,
|
||||
createControlPlaneViewSource,
|
||||
buildInventoryManifest,
|
||||
_internal: { inventoryIdFor, laneFor, sessionDeclarationStatus, advisoryEvents, leaseConflictEvents }
|
||||
};
|
||||
@@ -49,6 +49,7 @@ function renderProximityVizHtml() {
|
||||
<header>
|
||||
<h1>ECC - Agent Airspace</h1>
|
||||
<span class="sub" id="status">connecting...</span>
|
||||
<a class="sub" href="/control-plane" style="margin-left:auto;text-decoration:none">2D control plane</a>
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
|
||||
@@ -122,10 +122,21 @@ function buildProximitySnapshot(sessions, options = {}) {
|
||||
const agents = sessionsToAgents(sessions, options);
|
||||
|
||||
// Need at least two participating agents for a collision to be possible.
|
||||
const agentSummaries = agents.map(a => ({
|
||||
agentId: a.agentId,
|
||||
label: a.label,
|
||||
startedAt: a.startedAt,
|
||||
fileCount: a.files.length,
|
||||
progress: a.files.reduce((s, f) => s + (f.weight ?? 1), 0),
|
||||
files: a.files.map(f => f.path)
|
||||
}));
|
||||
|
||||
if (agents.length < 2) {
|
||||
return {
|
||||
enabled: true,
|
||||
advisories: [],
|
||||
triggers: [],
|
||||
agents: agentSummaries,
|
||||
positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })),
|
||||
links: [],
|
||||
counts: { agents: agents.length, advisories: 0, resolutions: 0 }
|
||||
@@ -151,6 +162,7 @@ function buildProximitySnapshot(sessions, options = {}) {
|
||||
enabled: true,
|
||||
advisories,
|
||||
triggers: buildProximityTriggers(scan.advisories),
|
||||
agents: agentSummaries,
|
||||
positions: scan.positions,
|
||||
links: scan.links,
|
||||
counts: scan.counts
|
||||
|
||||
@@ -9,6 +9,8 @@ const { buildControlPaneAction } = require('./actions');
|
||||
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
|
||||
const { renderControlPaneHtml } = require('./ui');
|
||||
const { renderProximityVizHtml } = require('./proximity-viz');
|
||||
const { renderControlPlaneViewHtml } = require('./control-plane-view-ui');
|
||||
const { createControlPlaneViewSource } = require('./control-plane-view');
|
||||
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
|
||||
|
||||
// Run a single write against the local work-item store, then close it. Kept
|
||||
@@ -185,6 +187,24 @@ function createControlPaneServer(options = {}) {
|
||||
const baseQuery = options.query || '';
|
||||
const allowedHostnames = buildAllowedHostnames(host);
|
||||
|
||||
// Live control-plane view: sessions + proximity scan + coordination
|
||||
// inventory, joined as tasks/lanes/events with a 2D projection. The view
|
||||
// source owns the rolling projection window so z-scores span ticks.
|
||||
const viewSource = createControlPlaneViewSource({
|
||||
projection: options.projection || {},
|
||||
viewOptions: options.viewOptions || {},
|
||||
buildSnapshot: () =>
|
||||
buildControlPaneSnapshot({
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
config: resolvedConfig,
|
||||
allowActions,
|
||||
includeProximity: true,
|
||||
proximityOptions: options.proximityOptions
|
||||
})
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
|
||||
@@ -257,6 +277,29 @@ function createControlPaneServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Control-plane live view: 2D projection + advisory events + inventory.
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/control-plane') {
|
||||
sendText(res, 200, renderControlPlaneViewHtml(), 'text/html; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane') {
|
||||
sendJson(res, 200, await viewSource.build());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane/events') {
|
||||
const view = await viewSource.build();
|
||||
sendJson(res, 200, {
|
||||
schemaVersion: view.schemaVersion,
|
||||
generatedAt: view.generatedAt,
|
||||
thresholds: view.thresholds,
|
||||
events: view.events,
|
||||
counts: { events: view.counts.events, advisories: view.counts.advisories, resolutions: view.counts.resolutions }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
|
||||
if (req.method === 'POST' && actionMatch) {
|
||||
if (!allowActions) {
|
||||
|
||||
Reference in New Issue
Block a user