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:
Affaan Mustafa
2026-09-11 21:03:07 -04:00
parent c9148d0bb2
commit ff03da1dc8
13 changed files with 1703 additions and 12 deletions
+17 -11
View File
@@ -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 }
+2 -1
View File
@@ -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 });
+305
View File
@@ -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">
+12
View File
@@ -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
+43
View File
@@ -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) {