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
Exact-head independent local Codex review PASS with no P0/P1. CI run 34680860653 attempt 2 passed at 0707cd431c. Includes HTTP/schema failure handling, coalesced sampling cache and regression tests. Disclosed P2 follow-ups remain in the merge-queue receipt. Rollback: revert this squash commit. No deployment or publication claim.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# TCAS hook: pre-merge deconfliction (slice b, design)
|
||||
|
||||
Status: design only. Nothing in this document is implemented. Slice (a), the live view and the advisory feed it reads, shipped in `VIEW-CONTRACT.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Stop two agents from finishing overlapping edits and meeting at the merge. The scan already knows when two working sets converge; the hook is what turns that knowledge into a maneuver inside the harness, before either agent commits.
|
||||
|
||||
Push plan wording: "a PreToolUse/Edit hook that reads the advisory feed and returns steer, pause or wait for the lower-priority agent, logged to the capsule."
|
||||
|
||||
## Inputs
|
||||
|
||||
1. The event feed: `GET /api/control-plane/events` on the local control pane, or the same document written to a file by `scripts/proximity-tick.js --json` for sessions without a pane. Events of kind `proximity.advisory` with `action.type` `transmit` or `steer` and a deterministic `id`.
|
||||
2. The hook's own session id. Claude Code passes `session_id` on stdin; the ECC session adapter maps it to the ECC2 `sessions.id` the scan uses. Codex and Hermes use the instruction-backed equivalent (see below).
|
||||
3. The tool call: `tool_name` and `tool_input.file_path` for Edit, Write and MultiEdit. Bash is out of scope for v1.
|
||||
|
||||
## Decision
|
||||
|
||||
For each advisory event whose `subject` includes this session:
|
||||
|
||||
| Event | This session is | Maneuver | Hook result |
|
||||
|---|---|---|---|
|
||||
| `traffic`, action `transmit` | either side | **transmit**: inject the other agent's working set as a system message | exit 0, message on stderr (warn, never block) |
|
||||
| `resolution`, action `steer` | `hold` | **hold**: continue | exit 0, short note |
|
||||
| `resolution`, action `steer` | `steer`, and `file_path` is in the other agent's working set | **pause**: stop editing that file until the other agent's diff lands | exit 2 with the reason (blocks this one tool call) |
|
||||
| `resolution`, action `steer` | `steer`, and `file_path` is not in the other agent's working set | **wait**: allowed, but told to keep to non-overlapping files | exit 0, message on stderr |
|
||||
| `resolution`, action `steer` | `steer`, and a `steer` target exists | **steer**: suggest the disjoint files or subtree the agent should move to | exit 0, message; exit 2 only if the edit is on the shared file |
|
||||
|
||||
The maneuver is deterministic: both agents read the same event, `hold` and `steer` are named in it, so the two sides never pick the same move. This is the TCAS coordination property and it is why the view computes right-of-way once, centrally, rather than each hook deciding.
|
||||
|
||||
`pause` blocks a single tool call, not the session. The agent sees the reason and can pick another file. Blocking is bounded by the event's `at`: an event older than the pane's poll interval times three is stale and the hook does not block on it.
|
||||
|
||||
## Priority
|
||||
|
||||
Right-of-way comes from the event (`action.hold`, `action.steer`). The view computes it as more progress, then earlier start, then stable id (`rightOfWay` in `scripts/lib/agent-proximity/distance.js`). The hook never recomputes it.
|
||||
|
||||
## Logging to the capsule
|
||||
|
||||
Every decision is one entry in the session's capsule journal (`scripts/lib/eval-harness/capsule.js`, hash-linked NDJSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "tcas.decision",
|
||||
"event_id": "proximity.advisory:session-a|session-b:resolution",
|
||||
"session": "session-b",
|
||||
"tool": "Edit",
|
||||
"file": "src/api/users.js",
|
||||
"maneuver": "pause",
|
||||
"blocked": true,
|
||||
"risk": 1,
|
||||
"threshold": { "ta": 0.35, "ra": 0.7, "source": "static" },
|
||||
"at": "2026-09-11T20:01:03.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The capsule is the baseline counter for the 85 percent goal: rebase and merge-conflict triage incidents per week are counted from these entries plus `git rerere` and conflict markers, two weeks before and two weeks after the hook is on. No percentage is claimed before that.
|
||||
|
||||
## Where it plugs in
|
||||
|
||||
- **Claude Code**: a `PreToolUse` entry in `hooks/hooks.json` with matcher `Edit|Write|MultiEdit`, routed through `scripts/hooks/run-with-flags.js` so `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` gate it. Script under `scripts/hooks/tcas-pre-edit.js`, helpers in `scripts/lib/control-pane/tcas.js`. Budget: under 200 ms, no network beyond loopback, exit 0 on any parse or fetch error.
|
||||
- **Codex**: no PreToolUse. The instruction-backed equivalent is the `proximity_steer` / `proximity_hold` message the tick already writes into the ECC2 `messages` table, surfaced on the next turn. `pause` degrades to a strong instruction.
|
||||
- **Hermes**: gateway hook on the tool-call path, same decision table, same capsule entry.
|
||||
|
||||
## Off switch and safety
|
||||
|
||||
- Disabled by default. On with `ECC_TCAS_HOOK=1` or the hook profile.
|
||||
- Read-only against the pane. It never writes to the sessions or messages tables.
|
||||
- No lease is acquired. Durable leases are slice (c), the worktree lease table in ecc2 `session/store.rs` next to `messages`; until then a `pause` is a per-call block, not a lock, and two hooks racing on the same file is possible but harmless (both see the same event and the same `steer`).
|
||||
- Fails open. Any error is exit 0 with a `[TCAS]` line on stderr.
|
||||
|
||||
## Tests to write with it
|
||||
|
||||
- Decision table: one test per row above, driven by a fixture event feed and a stdin payload.
|
||||
- Staleness: an event older than the window does not block.
|
||||
- Fail-open: unreachable pane, malformed JSON, missing session id.
|
||||
- Capsule: one entry per decision, hash chain intact, replay reproduces the same bytes.
|
||||
- Integration: two fake sessions with overlapping working sets, the lower-priority one gets exit 2 on the shared file and exit 0 on a disjoint file.
|
||||
|
||||
## Out of scope for (b)
|
||||
|
||||
Learned thresholds, closure-rate escalation, mesh mode, cross-machine airspace, the `x_sem`, `x_vec`, `x_freq` channels (slice g), and the lease table (slice c).
|
||||
@@ -0,0 +1,141 @@
|
||||
# ECC control-plane live view: `ecc.control-plane.view.v1`
|
||||
|
||||
Status: shipped with the control pane (`scripts/lib/control-pane/control-plane-view.js`). Read-only. Advisory only.
|
||||
|
||||
The view joins three things the repo already computes separately and serves them as one JSON document shaped as tasks, lanes and events, so another control plane (the Ito ops board, a Hermes or Codex reader, a hook) can consume it without knowing ECC internals.
|
||||
|
||||
| Input | Where it comes from |
|
||||
|---|---|
|
||||
| Sessions | `scripts/lib/control-pane/state.js`, the ECC2 `sessions` table |
|
||||
| Pairwise proximity | `scripts/lib/agent-proximity/` (noisy-OR over `x_tree`, `x_overlap`, `x_dep`) via `scripts/lib/control-pane/proximity.js` |
|
||||
| 2D projection | `scripts/lib/agent-proximity/projection.js` (rolling z-score, tails clipped at 2.5 / 97.5, PCA) |
|
||||
| Coordination inventory | `scripts/lib/coordination-inventory.js` (PR #3028): declared tasks and sessions, heartbeat freshness, lease conflicts |
|
||||
|
||||
## Endpoints
|
||||
|
||||
Served by `node scripts/control-pane.js` (loopback only, same Host and Origin gate as the rest of the pane):
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /control-plane` | Self-contained HTML page: 2D projection canvas, lanes and tasks, event feed. No external scripts. |
|
||||
| `GET /api/control-plane` | The full view document below. |
|
||||
| `GET /api/control-plane/events` | `{ schemaVersion, generatedAt, thresholds, events, counts }` only, for hooks and pollers. |
|
||||
|
||||
The server keeps one projection window per process. Both API routes share a snapshot cached for five seconds, and concurrent refresh requests are coalesced. Reads within that interval do not add samples. After expiry, the next read refreshes the snapshot once; idle intervals do not generate synthetic samples. Failed refreshes return errors rather than healthy empty data. The page rejects failed HTTP responses and invalid view envelopes and shows `offline`. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`, `minWindowForZscore`), `proximityOptions` (passed to the scan).
|
||||
|
||||
## Document
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "ecc.control-plane.view.v1",
|
||||
"generatedAt": "2026-09-11T20:01:00.000Z",
|
||||
"source": { "snapshotSchema": "ecc.control-pane.snapshot.v1", "repoRoot": "...", "dbPath": "..." },
|
||||
"thresholds": { "ta": 0.35, "ra": 0.7, "source": "static" },
|
||||
"lanes": [ { "id": "harness:codex", "label": "codex", "kind": "harness", "taskIds": ["session-a"] } ],
|
||||
"tasks": [ { "...": "see Task" } ],
|
||||
"pairs": [ { "...": "see Pair" } ],
|
||||
"events": [ { "...": "see Event" } ],
|
||||
"projection": { "...": "see Projection" },
|
||||
"inventory": { "...": "see Inventory" },
|
||||
"counts": { "lanes": 1, "tasks": 1, "agents": 1, "pairs": 0, "events": 0, "advisories": 0, "resolutions": 0 },
|
||||
"limits": [ "..." ]
|
||||
}
|
||||
```
|
||||
|
||||
### Task
|
||||
|
||||
One task per session. A session with no changed files is still a task; it has no projection point and no pairs.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | Session id, unchanged. |
|
||||
| `lane` | Lane id this task belongs to. |
|
||||
| `label` | Session task text, or the id. |
|
||||
| `harness`, `agentType`, `state`, `pid` | From the session row. |
|
||||
| `worktree` | `{ path, branch, base }` or `null`. |
|
||||
| `heartbeatAt`, `updatedAt` | ISO timestamps or `null`. |
|
||||
| `workingSet` | `{ fileCount, files }`: the worktree diff against its base. |
|
||||
| `projection` | `{ point, pairs, maxRisk }` where `point` is `[x, y]` or `null`. `point` is the risk-weighted centroid of the task's pair points in PCA space. |
|
||||
| `inventory` | `{ id, heartbeat, process, authority: "declared-only" }`. `id` is the sanitized identifier used in the inventory manifest; `heartbeat` and `process` are the #3028 observations. |
|
||||
|
||||
### Lane
|
||||
|
||||
A grouping of tasks. Precedence: `task-group` (session `task_group`), then `project`, then `harness`. Ids are prefixed (`group:`, `project:`, `harness:`) so a consumer can tell the kinds apart without reading `kind`.
|
||||
|
||||
### Pair
|
||||
|
||||
One row per agent pair from the airspace scan (only sessions with edits participate).
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `a`, `b` | Session ids. |
|
||||
| `risk`, `level` | Noisy-OR risk and the scan's level (`clear`, `advisory`, `resolution`) at the scan's thresholds. |
|
||||
| `channels` | Raw `{ x_tree, x_overlap, x_dep }` in [0, 1]. |
|
||||
| `normalized` | The same after z-score, clip and map-back, or equal to `channels` while the window is cold. |
|
||||
| `point` | `[pc1, pc2]` PCA scores. |
|
||||
|
||||
### Event
|
||||
|
||||
Something an operator or a hook may act on. Ids are deterministic across polls so a consumer can dedupe.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "proximity.advisory:session-a|session-b:resolution",
|
||||
"kind": "proximity.advisory",
|
||||
"level": "resolution",
|
||||
"severity": "critical",
|
||||
"at": "2026-09-11T20:01:00.000Z",
|
||||
"subject": { "a": "session-a", "b": "session-b", "aLabel": "...", "bLabel": "..." },
|
||||
"risk": 1,
|
||||
"distance": 0,
|
||||
"channels": { "x_tree": 1, "x_overlap": 1, "x_dep": 0 },
|
||||
"threshold": { "ta": 0.35, "ra": 0.7, "crossed": "ra", "source": "static" },
|
||||
"action": { "type": "steer", "steer": "session-b", "hold": "session-a" },
|
||||
"message": "Resolution advisory: session-b steers, session-a holds (risk 100%, static threshold 0.7)."
|
||||
}
|
||||
```
|
||||
|
||||
| Kind | Levels | Action types | Source |
|
||||
|---|---|---|---|
|
||||
| `proximity.advisory` | `traffic` (risk at or above `ta`), `resolution` (at or above `ra`) | `transmit` (both agents share intent), `steer` (`steer` moves, `hold` keeps course) | Every pair link, evaluated against the view's thresholds. Right-of-way: more progress, then earlier start, then stable id. |
|
||||
| `inventory.lease-conflict` | `conflict` | `review` | #3028 `leaseConflicts`. Declared-only, never a lock. |
|
||||
|
||||
Thresholds are static per view (`source: "static"`). A learned threshold, closure-rate escalation, and the `pause` and `wait` maneuvers are slice (b), see `TCAS-HOOK.md`.
|
||||
|
||||
### Projection
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "pca",
|
||||
"channels": ["x_tree", "x_overlap", "x_dep"],
|
||||
"weights": { "x_tree": 0.25, "x_overlap": 1, "x_dep": 0.9 },
|
||||
"normalization": "zscore-clipped",
|
||||
"window": { "samples": 12, "percentiles": [2.5, 97.5], "channels": [ { "channel": "x_tree", "mean": 0.39, "stddev": 0.42, "clipLow": -0.92, "clipHigh": 1.45 } ] },
|
||||
"pca": { "loadings": [ { "x_tree": 0.12, "x_overlap": 0.87, "x_dep": -0.47 }, { "...": "..." } ], "explainedVariance": [0.6, 0.39] },
|
||||
"agents": [ { "agentId": "session-a", "point": [0.18, 0.41], "pairs": 3, "maxRisk": 1 } ]
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline per poll: every pair's channel vector is pushed into a rolling window (default 512 samples). Once the window holds at least 8 samples, each channel is z-scored against the window, clipped to the window's 2.5th and 97.5th percentile (in z units), mapped back to [0, 1], multiplied by the static channel weight, and the weighted matrix goes through PCA (Jacobi on the 3x3 covariance). Below 8 samples the raw channel values are used and `normalization` says `raw`. A channel with zero variance maps to 0.5. Degenerate inputs (fewer than two pairs, zero total variance) give zero scores, never NaN.
|
||||
|
||||
The projection is a display. It never changes `risk`, the advisory level, or right-of-way.
|
||||
|
||||
### Inventory
|
||||
|
||||
The #3028 report with the per-task rows folded into `tasks[].inventory`. Kept at the top level: `status` (`ok` or `unavailable` with `reason`), `truncated` (more than 64 sessions), `observedAt`, `mode: "read-only"`, `activity`, `leaseConflicts`, `warnings`, `coverage`, `limits`. The manifest is built from the live sessions (ids sanitized to the inventory alphabet, paths from the working set, heartbeat from the session row, declared session status `open` for running/pending/idle, `closed` for completed/failed/stopped). An external manifest (`viewOptions.manifest`) can add `goals`, `leases`, `repositories` and extra `tasks`; the inventory then reports lease conflicts and goal activity for them.
|
||||
|
||||
## Reuse in the Ito ops control plane
|
||||
|
||||
The shape to copy is `task`, `lane`, `event`:
|
||||
|
||||
- a **task** has an `id`, a `lane`, a `state`, an optional position, and an observation block whose `authority` says how much to trust it;
|
||||
- a **lane** is a named group with ordered `taskIds`;
|
||||
- an **event** has a stable `id`, a `kind`, a `level`, a `severity`, an `at`, a `subject`, an `action` with a `type`, and a human `message`.
|
||||
|
||||
Nothing in the shape is ECC-specific except the event kinds. An ops board that renders lanes of tasks and a feed of events can render this document as-is, and can emit its own kinds (`deal.stalled`, `bridge.down`) into the same feed.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- No leases are acquired, no agent is paused or steered. Consumers act; the view reports.
|
||||
- No conflict-reduction percentage is claimed. The 85 percent goal in the push plan is measured two weeks before and after slice (b), not here.
|
||||
- No semantic, call-graph or frequency channel yet (slice (g)). PCA picks new channels up automatically when they land in the scan.
|
||||
@@ -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 && options.sample !== false) 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,243 @@
|
||||
'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) {
|
||||
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
|
||||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
|
||||
!data.projection || !Array.isArray(data.projection.agents) || !data.thresholds ||
|
||||
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
|
||||
throw new Error('Invalid control-plane view');
|
||||
}
|
||||
view = Object.assign({}, data);
|
||||
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) {
|
||||
if (!r.ok) throw new Error('Control-plane request failed');
|
||||
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,358 @@
|
||||
'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,
|
||||
sample: options.sample,
|
||||
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 || {});
|
||||
const clock = deps.clock || Date.now;
|
||||
const interval = deps.sampleIntervalMs === undefined ? 5000 : deps.sampleIntervalMs;
|
||||
if (!Number.isFinite(interval) || interval <= 0) throw new Error('sampleIntervalMs must be positive and finite');
|
||||
let cached = null;
|
||||
let pending = null;
|
||||
let expiresAt = 0;
|
||||
async function refresh() {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
const view = buildControlPlaneView(snapshot, { ...deps.viewOptions, window });
|
||||
cached = { snapshot, view };
|
||||
expiresAt = clock() + interval;
|
||||
return cached;
|
||||
}
|
||||
return {
|
||||
window,
|
||||
async build(extra = {}) {
|
||||
if (!cached || clock() >= expiresAt) {
|
||||
if (!pending) pending = refresh().finally(() => { pending = null; });
|
||||
await pending;
|
||||
}
|
||||
if (Object.keys(extra).length === 0) return cached.view;
|
||||
return buildControlPlaneView(cached.snapshot, {
|
||||
...deps.viewOptions, ...extra, now: extra.now || cached.view.generatedAt, window, sample: false
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for scripts/lib/agent-proximity/projection.js: rolling z-score with
|
||||
* tail clipping, PCA and the 2D pair/agent projection.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { percentile, createProjectionWindow, normalizeSample, pca, projectPairs, PROJECTION_DEFAULTS, _internal } = require('../../scripts/lib/agent-proximity/projection');
|
||||
const { scanAirspace } = require('../../scripts/lib/agent-proximity');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (e) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function close(a, b, eps = 1e-6) {
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
console.log('\n=== Testing agent-proximity projection ===\n');
|
||||
|
||||
test('percentile: interpolates, clamps and survives empty input', () => {
|
||||
assert.strictEqual(percentile([], 50), 0);
|
||||
assert.strictEqual(percentile([4], 97.5), 4);
|
||||
assert.strictEqual(percentile([1, 2, 3, 4, 5], 50), 3);
|
||||
assert.ok(close(percentile([1, 2, 3, 4, 5], 25), 2));
|
||||
assert.strictEqual(percentile([1, 2, 3], 0), 1);
|
||||
assert.strictEqual(percentile([1, 2, 3], 100), 3);
|
||||
assert.strictEqual(percentile([1, 2, 3], 250), 3, 'p above 100 clamps to the max');
|
||||
assert.strictEqual(percentile([3, NaN, 1], 100), 3, 'non-finite values are ignored');
|
||||
});
|
||||
|
||||
test('window: rolls, keeps the newest samples and reports per-channel stats', () => {
|
||||
const w = createProjectionWindow({ windowSize: 4 });
|
||||
for (let i = 1; i <= 6; i += 1) w.push([i, 0, i * 2]);
|
||||
assert.strictEqual(w.length, 4);
|
||||
const stats = w.stats();
|
||||
assert.strictEqual(stats.samples, 4);
|
||||
assert.deepStrictEqual(stats.percentiles, PROJECTION_DEFAULTS.clipPercentiles);
|
||||
const tree = stats.channels[0];
|
||||
assert.strictEqual(tree.channel, 'tree');
|
||||
assert.ok(close(tree.mean, 4.5), 'mean of 3,4,5,6');
|
||||
assert.ok(tree.stddev > 0);
|
||||
assert.ok(tree.clipLow < 0 && tree.clipHigh > 0, 'clip bounds straddle zero in z units');
|
||||
assert.strictEqual(stats.channels[1].stddev, 0, 'constant channel has zero variance');
|
||||
w.reset();
|
||||
assert.strictEqual(w.length, 0);
|
||||
});
|
||||
|
||||
test('normalizeSample: z-scores, clips the tails and maps back to [0, 1]', () => {
|
||||
const w = createProjectionWindow({ windowSize: 100 });
|
||||
for (let i = 0; i < 100; i += 1) w.push([i / 100, 0.5, 0]);
|
||||
const stats = w.stats();
|
||||
const low = normalizeSample([-5, 0.5, 0], stats);
|
||||
const high = normalizeSample([5, 0.5, 0], stats);
|
||||
const mid = normalizeSample([0.495, 0.5, 0], stats);
|
||||
assert.strictEqual(low[0], 0, 'far below the 2.5th percentile clips to 0');
|
||||
assert.strictEqual(high[0], 1, 'far above the 97.5th percentile clips to 1');
|
||||
assert.ok(mid[0] > 0.4 && mid[0] < 0.6, `median lands near 0.5, got ${mid[0]}`);
|
||||
assert.strictEqual(low[1], 0.5, 'zero-variance channel maps to 0.5');
|
||||
assert.strictEqual(low[2], 0.5, 'all-zero channel maps to 0.5');
|
||||
for (const v of [...low, ...high, ...mid]) assert.ok(v >= 0 && v <= 1);
|
||||
});
|
||||
|
||||
test('pca: recovers the dominant axis and reports explained variance', () => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
const t = i / 39;
|
||||
rows.push([t, t * 0.5 + 0.001 * ((i % 3) - 1), 0.2]);
|
||||
}
|
||||
const out = pca(rows, 2);
|
||||
assert.strictEqual(out.scores.length, rows.length);
|
||||
assert.strictEqual(out.loadings.length, 2);
|
||||
const first = out.loadings[0];
|
||||
const norm = Math.sqrt(first.reduce((s, x) => s + x * x, 0));
|
||||
assert.ok(close(norm, 1, 1e-6), 'loadings are unit vectors');
|
||||
assert.ok(Math.abs(first[0]) > Math.abs(first[2]), 'first component follows the varying channels, not the constant one');
|
||||
assert.ok(out.explainedVariance[0] > 0.99, `first component explains almost everything, got ${out.explainedVariance[0]}`);
|
||||
assert.ok(out.explainedVariance[0] >= out.explainedVariance[1]);
|
||||
const total = out.explainedVariance.reduce((s, x) => s + x, 0);
|
||||
assert.ok(total <= 1 + 1e-9);
|
||||
});
|
||||
|
||||
test('pca: degenerate inputs give zero scores instead of NaN', () => {
|
||||
assert.deepStrictEqual(pca([], 2).scores, []);
|
||||
assert.deepStrictEqual(pca([[1, 2, 3]], 2).scores, [[0, 0]]);
|
||||
const flat = pca([[0.3, 0.3, 0.3], [0.3, 0.3, 0.3], [0.3, 0.3, 0.3]], 2);
|
||||
assert.deepStrictEqual(flat.scores, [[0, 0], [0, 0], [0, 0]]);
|
||||
assert.deepStrictEqual(flat.explainedVariance, [0, 0]);
|
||||
});
|
||||
|
||||
test('symmetricEigen: diagonalizes a known 3x3 matrix', () => {
|
||||
const eig = _internal.symmetricEigen([[2, 0, 0], [0, 3, 0], [0, 0, 1]]);
|
||||
assert.deepStrictEqual(eig.values.map(v => Math.round(v * 1e9) / 1e9), [3, 2, 1]);
|
||||
assert.ok(close(Math.abs(eig.vectors[0][1]), 1), 'top eigenvector points along the 3 axis');
|
||||
});
|
||||
|
||||
test('projectPairs: raw mode without a window, one point per pair and per agent', () => {
|
||||
const links = [
|
||||
{ a: 'a', b: 'b', risk: 1, level: 'resolution', channels: { tree: 1, overlap: 1, dependency: 0 } },
|
||||
{ a: 'a', b: 'c', risk: 0, level: 'clear', channels: { tree: 0, overlap: 0, dependency: 0 } },
|
||||
{ a: 'b', b: 'c', risk: 0.5, level: 'advisory', channels: { tree: 0.5, overlap: 0, dependency: 0.5 } }
|
||||
];
|
||||
const out = projectPairs(links);
|
||||
assert.strictEqual(out.method, 'pca');
|
||||
assert.strictEqual(out.normalization, 'raw');
|
||||
assert.deepStrictEqual(out.channels, ['x_tree', 'x_overlap', 'x_dep']);
|
||||
assert.deepStrictEqual(out.weights, { x_tree: 0.25, x_overlap: 1, x_dep: 0.9 });
|
||||
assert.strictEqual(out.pairs.length, 3);
|
||||
assert.strictEqual(out.pairs[0].point.length, 2);
|
||||
assert.deepStrictEqual(out.pairs[0].channels, { x_tree: 1, x_overlap: 1, x_dep: 0 });
|
||||
assert.deepStrictEqual(out.pairs[0].normalized, out.pairs[0].channels, 'raw mode passes channel values through');
|
||||
assert.strictEqual(out.agents.length, 3);
|
||||
const a = out.agents.find(x => x.agentId === 'a');
|
||||
assert.strictEqual(a.pairs, 2);
|
||||
assert.strictEqual(a.maxRisk, 1);
|
||||
for (const agent of out.agents) for (const v of agent.point) assert.ok(Number.isFinite(v));
|
||||
assert.strictEqual(out.pca.loadings.length, 2);
|
||||
assert.ok(out.pca.explainedVariance[0] > 0);
|
||||
});
|
||||
|
||||
test('projectPairs: switches to z-score mode once the window is warm and keeps values in [0, 1]', () => {
|
||||
const window = createProjectionWindow({ windowSize: 64 });
|
||||
const link = i => ({ a: `a${i}`, b: `b${i}`, risk: i / 10, level: 'clear', channels: { tree: i / 10, overlap: (10 - i) / 10, dependency: 0.3 } });
|
||||
const cold = projectPairs([link(1), link(2)], { window, minWindowForZscore: 8 });
|
||||
assert.strictEqual(cold.normalization, 'raw', 'two samples is below the warm-up size');
|
||||
assert.strictEqual(cold.window.samples, 2);
|
||||
const warm = projectPairs(Array.from({ length: 10 }, (_, i) => link(i)), { window, minWindowForZscore: 8 });
|
||||
assert.strictEqual(warm.normalization, 'zscore-clipped');
|
||||
assert.strictEqual(warm.window.samples, 12);
|
||||
assert.deepStrictEqual(warm.window.percentiles, [2.5, 97.5]);
|
||||
assert.strictEqual(warm.window.channels[0].channel, 'x_tree');
|
||||
for (const pair of warm.pairs) {
|
||||
for (const key of ['x_tree', 'x_overlap', 'x_dep']) {
|
||||
assert.ok(pair.normalized[key] >= 0 && pair.normalized[key] <= 1, `${key} normalized within [0, 1]`);
|
||||
}
|
||||
}
|
||||
const lowest = warm.pairs.find(p => p.a === 'a0');
|
||||
const highest = warm.pairs.find(p => p.a === 'a9');
|
||||
assert.ok(lowest.normalized.x_tree < highest.normalized.x_tree, 'ordering survives normalization');
|
||||
assert.strictEqual(warm.pairs[0].normalized.x_dep, 0.5, 'constant channel sits at 0.5');
|
||||
});
|
||||
|
||||
test('projectPairs: ignores malformed links and empty input', () => {
|
||||
const out = projectPairs([null, { risk: 1 }, { a: 'x' }]);
|
||||
assert.deepStrictEqual(out.pairs, []);
|
||||
assert.deepStrictEqual(out.agents, []);
|
||||
assert.deepStrictEqual(projectPairs(undefined).pairs, []);
|
||||
});
|
||||
|
||||
test('scanAirspace links carry the per-channel values the projection needs', () => {
|
||||
const agents = [
|
||||
{ agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] },
|
||||
{ agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] },
|
||||
{ agentId: 'c', files: [{ path: 'docs/guide.md' }] }
|
||||
];
|
||||
const scan = scanAirspace(agents, {});
|
||||
assert.strictEqual(scan.links.length, 3);
|
||||
for (const link of scan.links) {
|
||||
assert.ok(link.channels, 'link has channels');
|
||||
for (const key of ['tree', 'overlap', 'dependency']) assert.ok(Number.isFinite(link.channels[key]), `${key} is numeric`);
|
||||
}
|
||||
const ab = scan.links.find(l => (l.a === 'a' && l.b === 'b') || (l.a === 'b' && l.b === 'a'));
|
||||
assert.strictEqual(ab.channels.overlap, 1);
|
||||
const out = projectPairs(scan.links);
|
||||
assert.strictEqual(out.pairs.length, 3);
|
||||
assert.strictEqual(out.agents.length, 3);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const vm = require('vm');
|
||||
const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui');
|
||||
|
||||
async function renderResponse(ok, data) {
|
||||
const elements = new Map();
|
||||
const context = new Proxy({}, { get: () => () => {} });
|
||||
function element() {
|
||||
return { textContent: '', style: {}, appendChild() {}, getContext: () => context,
|
||||
clientWidth: 640, clientHeight: 480,
|
||||
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } };
|
||||
}
|
||||
const document = {
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
|
||||
createElement: element
|
||||
};
|
||||
const html = renderControlPlaneViewHtml();
|
||||
const start = html.indexOf('<script>');
|
||||
const end = html.indexOf('</script>', start);
|
||||
assert.ok(start >= 0 && end > start, 'fixed renderer template must contain its inline script');
|
||||
const code = html.slice(start + '<script>'.length, end);
|
||||
vm.runInNewContext(code, {
|
||||
document, window: { addEventListener() {}, devicePixelRatio: 1 }, setInterval() {},
|
||||
fetch: async () => ({ ok, json: async () => data })
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return elements;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
(async () => {
|
||||
const failed = await renderResponse(false, { ok: false, error: 'snapshot unavailable' });
|
||||
assert.strictEqual(failed.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
|
||||
passed += 1;
|
||||
const malformed = await renderResponse(true, { schemaVersion: 'wrong' });
|
||||
assert.strictEqual(malformed.get('status').textContent, 'offline', 'invalid schemas must be rejected');
|
||||
passed += 1;
|
||||
const valid = await renderResponse(true, {
|
||||
schemaVersion: 'ecc.control-plane.view.v1', tasks: [], lanes: [], pairs: [], events: [],
|
||||
projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 }, counts: {}
|
||||
});
|
||||
assert.ok(valid.get('status').textContent.includes('0 tasks'));
|
||||
passed += 1;
|
||||
console.log(`Results: Passed: ${passed}, Failed: 0`);
|
||||
})().catch(error => {
|
||||
console.error(error.message);
|
||||
console.log(`Results: Passed: ${passed}, Failed: 1`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for scripts/lib/control-pane/control-plane-view.js: the
|
||||
* ecc.control-plane.view.v1 contract (tasks, lanes, events, projection,
|
||||
* inventory) built from a control-pane snapshot with proximity.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { buildProximitySnapshot } = require('../../scripts/lib/control-pane/proximity');
|
||||
const { buildControlPlaneView, createControlPlaneViewSource, buildInventoryManifest, VIEW_SCHEMA_VERSION, EVENT_KINDS, _internal } = require('../../scripts/lib/control-pane/control-plane-view');
|
||||
const { createProjectionWindow } = require('../../scripts/lib/agent-proximity/projection');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (e) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = '2026-09-11T20:01:00.000Z';
|
||||
|
||||
function session(id, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
task: `Task ${id}`,
|
||||
project: '',
|
||||
taskGroup: '',
|
||||
agentType: 'worker',
|
||||
harness: 'codex',
|
||||
state: 'running',
|
||||
pid: null,
|
||||
worktree: { path: `/tmp/wt/${id}`, branch: `feat/${id}`, base: 'main' },
|
||||
lastHeartbeatAt: '2026-09-11T20:00:00Z',
|
||||
updatedAt: '2026-09-11T20:00:00Z',
|
||||
unreadMessages: 0,
|
||||
metrics: { tokensUsed: 0, costUsd: 0 },
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
const WORKING_SETS = {
|
||||
a: [{ path: 'src/api/users.js', lines: [[1, 50]] }, { path: 'src/db/schema.js' }],
|
||||
b: [{ path: 'src/api/users.js', lines: [[10, 30]] }],
|
||||
c: [{ path: 'docs/guide.md' }],
|
||||
d: [{ path: 'src/api/posts.js' }],
|
||||
idle: []
|
||||
};
|
||||
|
||||
function snapshotFor(sessions, extra = {}) {
|
||||
const proximity = buildProximitySnapshot(sessions, {
|
||||
workingSetFor: s => WORKING_SETS[s.id] || [],
|
||||
graph: { adjacency: { 'src/api/posts.js': ['src/db/schema.js'] } }
|
||||
});
|
||||
return { schemaVersion: 'ecc.control-pane.snapshot.v1', repoRoot: '/tmp/repo', dbPath: '/tmp/ecc2.db', sessions, proximity, ...extra };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\n=== Testing control-plane view ===\n');
|
||||
|
||||
await test('view: schema, counts, lanes and tasks from a four-session snapshot', () => {
|
||||
const sessions = [
|
||||
session('a', { taskGroup: 'ecc' }),
|
||||
session('b', { harness: 'claude', project: 'ecc-website' }),
|
||||
session('c', { harness: 'hermes', state: 'completed', lastHeartbeatAt: '' }),
|
||||
session('d', { pid: 4242 })
|
||||
];
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW });
|
||||
assert.strictEqual(view.schemaVersion, VIEW_SCHEMA_VERSION);
|
||||
assert.strictEqual(view.generatedAt, NOW);
|
||||
assert.deepStrictEqual(view.source, { snapshotSchema: 'ecc.control-pane.snapshot.v1', repoRoot: '/tmp/repo', dbPath: '/tmp/ecc2.db' });
|
||||
assert.deepStrictEqual(view.thresholds, { ta: 0.35, ra: 0.7, source: 'static' });
|
||||
assert.strictEqual(view.counts.tasks, 4);
|
||||
assert.strictEqual(view.counts.agents, 4);
|
||||
assert.strictEqual(view.counts.pairs, 6);
|
||||
assert.strictEqual(view.counts.lanes, 4);
|
||||
const laneIds = view.lanes.map(l => l.id).sort();
|
||||
assert.deepStrictEqual(laneIds, ['group:ecc', 'harness:codex', 'harness:hermes', 'project:ecc-website']);
|
||||
const group = view.lanes.find(l => l.id === 'group:ecc');
|
||||
assert.deepStrictEqual(group, { id: 'group:ecc', label: 'ecc', kind: 'task-group', taskIds: ['a'] });
|
||||
|
||||
const a = view.tasks.find(t => t.id === 'a');
|
||||
assert.strictEqual(a.lane, 'group:ecc');
|
||||
assert.strictEqual(a.label, 'Task a');
|
||||
assert.strictEqual(a.harness, 'codex');
|
||||
assert.strictEqual(a.state, 'running');
|
||||
assert.deepStrictEqual(a.worktree, { path: '/tmp/wt/a', branch: 'feat/a', base: 'main' });
|
||||
assert.strictEqual(a.heartbeatAt, '2026-09-11T20:00:00.000Z');
|
||||
assert.deepStrictEqual(a.workingSet, { fileCount: 2, files: ['src/api/users.js', 'src/db/schema.js'] });
|
||||
assert.strictEqual(a.projection.point.length, 2);
|
||||
assert.strictEqual(a.projection.pairs, 3);
|
||||
assert.strictEqual(a.projection.maxRisk, 1);
|
||||
assert.strictEqual(a.inventory.authority, 'declared-only');
|
||||
assert.strictEqual(a.inventory.heartbeat.state, 'fresh');
|
||||
|
||||
const c = view.tasks.find(t => t.id === 'c');
|
||||
assert.strictEqual(c.heartbeatAt, null);
|
||||
assert.strictEqual(c.inventory.heartbeat.state, 'unknown');
|
||||
assert.strictEqual(c.projection.maxRisk, 0);
|
||||
|
||||
const d = view.tasks.find(t => t.id === 'd');
|
||||
assert.strictEqual(d.pid, 4242);
|
||||
assert.ok(d.projection.maxRisk >= 0.7, 'd couples to a through the import graph');
|
||||
});
|
||||
|
||||
await test('view: static-threshold advisory events carry level, threshold, channels and action', () => {
|
||||
const sessions = [session('a'), session('b'), session('c'), session('d')];
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW });
|
||||
const advisories = view.events.filter(e => e.kind === EVENT_KINDS.advisory);
|
||||
assert.strictEqual(advisories.length, 2, 'a/b overlap and a/d dependency both cross a threshold');
|
||||
assert.strictEqual(view.counts.advisories, 2);
|
||||
const ab = advisories.find(e => e.subject.a === 'a' && e.subject.b === 'b');
|
||||
assert.ok(ab, 'a/b event present');
|
||||
assert.strictEqual(ab.id, 'proximity.advisory:a|b:resolution');
|
||||
assert.strictEqual(ab.level, 'resolution');
|
||||
assert.strictEqual(ab.severity, 'critical');
|
||||
assert.strictEqual(ab.at, NOW);
|
||||
assert.strictEqual(ab.risk, 1);
|
||||
assert.deepStrictEqual(ab.channels, { x_tree: 1, x_overlap: 1, x_dep: 0 });
|
||||
assert.deepStrictEqual(ab.threshold, { ta: 0.35, ra: 0.7, crossed: 'ra', source: 'static' });
|
||||
assert.strictEqual(ab.action.type, 'steer');
|
||||
assert.ok(['a', 'b'].includes(ab.action.steer) && ['a', 'b'].includes(ab.action.hold) && ab.action.steer !== ab.action.hold);
|
||||
assert.strictEqual(ab.action.hold, 'a', 'a has more committed work, so a holds');
|
||||
assert.ok(ab.message.includes('static threshold 0.7'));
|
||||
assert.strictEqual(view.counts.resolutions, 2);
|
||||
assert.ok(view.limits.some(l => l.includes('static thresholds')));
|
||||
});
|
||||
|
||||
await test('view: custom thresholds change the level and the event says which line was crossed', () => {
|
||||
const sessions = [session('a'), session('b')];
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW, thresholds: { ta: 0.2, ra: 1.5 } });
|
||||
assert.deepStrictEqual(view.thresholds, { ta: 0.2, ra: 1.5, source: 'static' });
|
||||
assert.strictEqual(view.events.length, 1);
|
||||
const ev = view.events[0];
|
||||
assert.strictEqual(ev.level, 'traffic');
|
||||
assert.strictEqual(ev.severity, 'warning');
|
||||
assert.strictEqual(ev.threshold.crossed, 'ta');
|
||||
assert.deepStrictEqual(ev.action, { type: 'transmit', steer: null, hold: null });
|
||||
assert.strictEqual(ev.id, 'proximity.advisory:a|b:traffic');
|
||||
});
|
||||
|
||||
await test('view: projection is PCA over the shipped channels with a rolling window', () => {
|
||||
const sessions = [session('a'), session('b'), session('c'), session('d')];
|
||||
const window = createProjectionWindow({ windowSize: 64 });
|
||||
const snapshot = snapshotFor(sessions);
|
||||
const first = buildControlPlaneView(snapshot, { now: NOW, window });
|
||||
assert.strictEqual(first.projection.method, 'pca');
|
||||
assert.deepStrictEqual(first.projection.channels, ['x_tree', 'x_overlap', 'x_dep']);
|
||||
assert.strictEqual(first.projection.normalization, 'raw', 'six samples is below the warm-up');
|
||||
const second = buildControlPlaneView(snapshot, { now: NOW, window });
|
||||
assert.strictEqual(second.projection.normalization, 'zscore-clipped');
|
||||
assert.strictEqual(second.projection.window.samples, 12);
|
||||
assert.deepStrictEqual(second.projection.window.percentiles, [2.5, 97.5]);
|
||||
assert.strictEqual(second.projection.pca.loadings.length, 2);
|
||||
assert.ok(second.projection.pca.explainedVariance[0] > 0);
|
||||
assert.strictEqual(second.pairs.length, 6);
|
||||
for (const pair of second.pairs) {
|
||||
assert.ok(Array.isArray(pair.point) && pair.point.length === 2);
|
||||
assert.ok(Object.keys(pair.normalized).every(k => pair.normalized[k] >= 0 && pair.normalized[k] <= 1));
|
||||
}
|
||||
assert.strictEqual(second.projection.agents.length, 4);
|
||||
assert.ok(!('pairs' in second.projection), 'pairs live at the top level, not under projection');
|
||||
});
|
||||
|
||||
await test('view: inventory is declared-only, maps sanitized ids and reports lease conflicts as events', () => {
|
||||
const sessions = [session('a'), session('weird id!/x'), session('c', { state: 'stopped' })];
|
||||
const manifest = {
|
||||
leases: [
|
||||
{ resource: 'browser:chrome', owner: 'a', expiresAt: '2026-09-11T21:00:00Z' },
|
||||
{ resource: 'browser:chrome', owner: 'c', expiresAt: '2026-09-11T21:00:00Z' }
|
||||
]
|
||||
};
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW, manifest });
|
||||
assert.strictEqual(view.inventory.status, 'ok');
|
||||
assert.strictEqual(view.inventory.mode, 'read-only');
|
||||
assert.strictEqual(view.inventory.observedAt, NOW);
|
||||
assert.deepStrictEqual(view.inventory.activity.declaredSessionsByStatus, { open: 2, closed: 1, unknown: 0 });
|
||||
assert.strictEqual(view.inventory.coverage.leases, 'declared-only');
|
||||
assert.ok(Array.isArray(view.inventory.limits) && view.inventory.limits.length > 0);
|
||||
assert.deepStrictEqual(view.inventory.leaseConflicts, [{ resource: 'browser:chrome', owners: ['a', 'c'] }]);
|
||||
const weird = view.tasks.find(t => t.id === 'weird id!/x');
|
||||
assert.strictEqual(weird.inventory.id, 'weird-id--x');
|
||||
assert.strictEqual(weird.inventory.heartbeat.state, 'fresh');
|
||||
const conflict = view.events.find(e => e.kind === EVENT_KINDS.leaseConflict);
|
||||
assert.ok(conflict, 'lease conflict surfaced as an event');
|
||||
assert.strictEqual(conflict.id, 'inventory.lease-conflict:browser:chrome');
|
||||
assert.strictEqual(conflict.level, 'conflict');
|
||||
assert.deepStrictEqual(conflict.subject, { resource: 'browser:chrome', owners: ['a', 'c'] });
|
||||
assert.strictEqual(conflict.action.type, 'review');
|
||||
assert.ok(conflict.message.includes('not a lock'));
|
||||
});
|
||||
|
||||
await test('view: inventory failure degrades to unavailable without breaking the view', () => {
|
||||
const sessions = [session('a'), session('b')];
|
||||
const inventoryModule = { buildInventory: () => { throw new Error('Invalid coordination input.'); } };
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW, inventoryModule });
|
||||
assert.deepStrictEqual(view.inventory, { status: 'unavailable', truncated: false, reason: 'Invalid coordination input.' });
|
||||
assert.strictEqual(view.tasks.length, 2);
|
||||
assert.strictEqual(view.tasks[0].inventory.heartbeat, null);
|
||||
assert.strictEqual(view.events.length, 1, 'advisory events still flow');
|
||||
});
|
||||
|
||||
await test('view: sessions without edits are tasks with no projection point and no pairs', () => {
|
||||
const sessions = [session('idle'), session('a')];
|
||||
const view = buildControlPlaneView(snapshotFor(sessions), { now: NOW });
|
||||
assert.strictEqual(view.counts.tasks, 2);
|
||||
assert.strictEqual(view.counts.agents, 1);
|
||||
assert.strictEqual(view.counts.pairs, 0);
|
||||
assert.strictEqual(view.events.length, 0);
|
||||
const idle = view.tasks.find(t => t.id === 'idle');
|
||||
assert.deepStrictEqual(idle.projection, { point: null, pairs: 0, maxRisk: 0 });
|
||||
assert.deepStrictEqual(idle.workingSet, { fileCount: 0, files: [] });
|
||||
assert.strictEqual(view.projection.normalization, 'raw');
|
||||
});
|
||||
|
||||
await test('view: empty and missing snapshots produce an empty, well-formed view', () => {
|
||||
const empty = buildControlPlaneView({ sessions: [], proximity: null }, { now: NOW });
|
||||
assert.strictEqual(empty.schemaVersion, VIEW_SCHEMA_VERSION);
|
||||
assert.deepStrictEqual(empty.tasks, []);
|
||||
assert.deepStrictEqual(empty.lanes, []);
|
||||
assert.deepStrictEqual(empty.events, []);
|
||||
assert.deepStrictEqual(empty.pairs, []);
|
||||
assert.strictEqual(empty.inventory.status, 'ok');
|
||||
const none = buildControlPlaneView(undefined, { now: NOW });
|
||||
assert.deepStrictEqual(none.counts, { lanes: 0, tasks: 0, agents: 0, pairs: 0, events: 0, advisories: 0, resolutions: 0 });
|
||||
assert.deepStrictEqual(none.source, { snapshotSchema: null, repoRoot: null, dbPath: null });
|
||||
});
|
||||
|
||||
await test('buildInventoryManifest: caps tasks at 64, filters unsafe paths and merges an external manifest', () => {
|
||||
const sessions = Array.from({ length: 70 }, (_, i) => session(`s${i}`));
|
||||
const agents = new Map([['s0', { files: ['ok/file.js', '/abs/file.js', '../up.js', 'C:/win.js', 'a//b.js'] }]]);
|
||||
const built = buildInventoryManifest(sessions, agents, { manifest: { goals: [{ id: 'g1', kind: 'native', status: 'active' }], tasks: [{ id: 'external', paths: [] }] } });
|
||||
assert.strictEqual(built.truncated, true);
|
||||
assert.strictEqual(built.manifest.tasks.length, 65);
|
||||
assert.deepStrictEqual(built.manifest.tasks[0].paths, ['ok/file.js']);
|
||||
assert.strictEqual(built.manifest.goals.length, 1);
|
||||
assert.strictEqual(built.manifest.sessions.length, 64);
|
||||
assert.strictEqual(built.manifest.sessions[0].status, 'open');
|
||||
assert.strictEqual(built.idMap.get('s0'), 's0');
|
||||
});
|
||||
|
||||
await test('internal: inventory ids are sanitized and deduplicated; states map to open/closed/unknown', () => {
|
||||
const taken = new Set();
|
||||
assert.strictEqual(_internal.inventoryIdFor('plain-id', 0, taken), 'plain-id');
|
||||
assert.strictEqual(_internal.inventoryIdFor('plain-id', 1, taken), 'plain-id-2');
|
||||
assert.strictEqual(_internal.inventoryIdFor('!!!', 2, taken), 'task-3');
|
||||
assert.strictEqual(_internal.inventoryIdFor('__proto__', 3, taken), 'proto__', 'leading underscores stripped, no longer reserved');
|
||||
assert.strictEqual(_internal.inventoryIdFor('constructor', 5, taken), 'task-6', 'reserved word falls back to a positional id');
|
||||
assert.strictEqual(_internal.inventoryIdFor('a b/c', 4, taken), 'a-b-c');
|
||||
assert.strictEqual(_internal.sessionDeclarationStatus('running'), 'open');
|
||||
assert.strictEqual(_internal.sessionDeclarationStatus('failed'), 'closed');
|
||||
assert.strictEqual(_internal.sessionDeclarationStatus('weird'), 'unknown');
|
||||
assert.deepStrictEqual(_internal.laneFor({ harness: 'codex' }), { id: 'harness:codex', label: 'codex', kind: 'harness' });
|
||||
});
|
||||
|
||||
await test('createControlPlaneViewSource: keeps one window across builds', async () => {
|
||||
const sessions = [session('a'), session('b'), session('c'), session('d')];
|
||||
const snapshot = snapshotFor(sessions);
|
||||
let builds = 0;
|
||||
let clock = 1000;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => {
|
||||
builds += 1;
|
||||
return snapshot;
|
||||
},
|
||||
projection: { windowSize: 32 },
|
||||
viewOptions: { now: NOW }
|
||||
});
|
||||
const first = await source.build();
|
||||
clock += 5000;
|
||||
const second = await source.build();
|
||||
assert.strictEqual(builds, 2);
|
||||
assert.strictEqual(first.projection.window.samples, 6);
|
||||
assert.strictEqual(second.projection.window.samples, 12);
|
||||
assert.strictEqual(source.window.size, 32);
|
||||
assert.strictEqual(second.generatedAt, NOW);
|
||||
});
|
||||
|
||||
await test('view source samples once per interval despite repeated and concurrent reads', async () => {
|
||||
let clock = 1000;
|
||||
let builds = 0;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => { builds += 1; return snapshotFor([session('a'), session('b')]); },
|
||||
viewOptions: { now: NOW }
|
||||
});
|
||||
const views = await Promise.all(Array.from({ length: 10 }, () => source.build()));
|
||||
assert.strictEqual(builds, 1);
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
assert.ok(views.every(view => view.generatedAt === views[0].generatedAt));
|
||||
await source.build();
|
||||
await source.build({ thresholds: { ta: 0.2, ra: 1.5 } });
|
||||
assert.strictEqual(source.window.length, 1, 'alternate read options must not resample');
|
||||
clock += 5000;
|
||||
await source.build();
|
||||
assert.strictEqual(builds, 2);
|
||||
assert.strictEqual(source.window.length, 2);
|
||||
});
|
||||
|
||||
await test('view source rejects failed refreshes and retries without false healthy data', async () => {
|
||||
let fail = true;
|
||||
let clock = 0;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => {
|
||||
if (fail) throw new Error('snapshot unavailable');
|
||||
return snapshotFor([session('a'), session('b')]);
|
||||
}
|
||||
});
|
||||
await assert.rejects(source.build(), /snapshot unavailable/);
|
||||
assert.strictEqual(source.window.length, 0);
|
||||
fail = false;
|
||||
await source.build();
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
clock += 5000;
|
||||
fail = true;
|
||||
await assert.rejects(source.build(), /snapshot unavailable/);
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
fail = false;
|
||||
await source.build();
|
||||
assert.strictEqual(source.window.length, 2);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
})();
|
||||
@@ -269,6 +269,65 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('serves the control-plane live view page, the view JSON and the event feed', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-plane-view-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeMinimalDatabase(dbPath);
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: false
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const page = await fetchLocal(`${app.url}/control-plane`);
|
||||
assert.strictEqual(page.status, 200);
|
||||
assert.ok((page.headers.get('content-type') || '').includes('text/html'));
|
||||
const html = await page.text();
|
||||
assert.ok(html.includes('ECC Control Plane'), 'page is titled ECC Control Plane');
|
||||
assert.ok(html.includes('<canvas'), 'page renders the 2D projection canvas');
|
||||
assert.ok(html.includes('/api/control-plane'), 'page polls the view feed');
|
||||
assert.ok(!html.includes('<script src='), 'page loads no external scripts');
|
||||
|
||||
const view = await fetchLocal(`${app.url}/api/control-plane`).then(r => r.json());
|
||||
assert.strictEqual(view.schemaVersion, 'ecc.control-plane.view.v1');
|
||||
assert.deepStrictEqual(view.thresholds, { ta: 0.35, ra: 0.7, source: 'static' });
|
||||
assert.ok(Array.isArray(view.tasks) && view.tasks.length === 1, 'one session becomes one task');
|
||||
assert.strictEqual(view.tasks[0].id, 'session-a');
|
||||
assert.strictEqual(view.tasks[0].lane, 'harness:codex');
|
||||
assert.ok(Array.isArray(view.lanes) && view.lanes.length === 1);
|
||||
assert.ok(Array.isArray(view.events) && Array.isArray(view.pairs));
|
||||
assert.strictEqual(view.projection.method, 'pca');
|
||||
assert.deepStrictEqual(view.projection.channels, ['x_tree', 'x_overlap', 'x_dep']);
|
||||
assert.strictEqual(view.inventory.status, 'ok');
|
||||
assert.strictEqual(view.inventory.mode, 'read-only');
|
||||
assert.strictEqual(view.counts.tasks, 1);
|
||||
|
||||
const events = await fetchLocal(`${app.url}/api/control-plane/events`).then(r => r.json());
|
||||
assert.strictEqual(events.schemaVersion, 'ecc.control-plane.view.v1');
|
||||
assert.deepStrictEqual(events.events, []);
|
||||
assert.deepStrictEqual(events.counts, { events: 0, advisories: 0, resolutions: 0 });
|
||||
|
||||
// The airspace page links to the new view.
|
||||
const airspace = await fetchLocal(`${app.url}/proximity`).then(r => r.text());
|
||||
assert.ok(airspace.includes('href="/control-plane"'));
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));
|
||||
|
||||
Reference in New Issue
Block a user