feat: Integration Phase 2 — config.js, live WS client, auth, agent hot-add (#7, #11, #12)

- js/config.js: connection config with URL param + env var override
  - WS URL, auth token, mock mode toggle
  - Computed isLive and wsUrlWithAuth getters
  - Resolves #7 (config.js)
  - Resolves #11 (Phase 1 shared-secret auth via query param)

- js/websocket.js: refactored to use Config for live/mock switching
  - Live mode: real WS with reconnection + exponential backoff
  - Auth token appended as ?token= on WS connect
  - agent_joined handler dispatches to addAgent() for hot-add
  - sendMessage() public API for UI → backend communication

- js/agents.js: dynamic agent hot-add and removal
  - addAgent(def): spawns 3D avatar at runtime without reload
  - autoPlace(): finds unoccupied circular slot (radius 8+)
  - removeAgent(id): clean dispose + connection line rebuild
  - Connection distance threshold 8→14 for larger agent rings
  - Resolves #12 (dynamic agent hot-add)
This commit is contained in:
2026-03-19 01:09:59 +00:00
parent 70f590ab9a
commit 745208f3c8
3 changed files with 262 additions and 35 deletions

View File

@@ -150,7 +150,7 @@ function buildConnectionLines() {
for (let j = i + 1; j < agentList.length; j++) {
const a = agentList[i];
const b = agentList[j];
if (a.position.distanceTo(b.position) <= 8) {
if (a.position.distanceTo(b.position) <= 14) {
const points = [a.position.clone(), b.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geo, CONNECTION_MAT);
@@ -179,3 +179,84 @@ export function getAgentDefs() {
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
}));
}
/**
* Dynamic agent hot-add (Issue #12).
*
* Spawns a new 3D agent at runtime when the backend sends an agent_joined event.
* If x/z are not provided, the agent is auto-placed in the next available slot
* on a circle around the origin (radius 8) to avoid overlapping existing agents.
*
* @param {object} def — Agent definition { id, label, color, role, direction, x, z }
* @returns {boolean} true if added, false if agent with that id already exists
*/
export function addAgent(def) {
if (agents.has(def.id)) {
console.warn('[Agents] Agent', def.id, 'already exists — skipping hot-add');
return false;
}
// Auto-place if no position given
if (def.x == null || def.z == null) {
const placed = autoPlace();
def.x = placed.x;
def.z = placed.z;
}
const agent = new Agent(def);
agents.set(def.id, agent);
scene.add(agent.group);
// Rebuild connection lines to include the new agent
buildConnectionLines();
console.info('[Agents] Hot-added agent:', def.id, 'at', def.x, def.z);
return true;
}
/**
* Find an unoccupied position on a circle around the origin.
* Tries radius 8 first (same ring as the original 4), then expands.
*/
function autoPlace() {
const existing = [...agents.values()].map(a => a.position);
const RADIUS_START = 8;
const RADIUS_STEP = 4;
const ANGLE_STEP = Math.PI / 6; // 30° increments = 12 slots per ring
const MIN_DISTANCE = 3; // minimum gap between agents
for (let r = RADIUS_START; r <= RADIUS_START + RADIUS_STEP * 3; r += RADIUS_STEP) {
for (let angle = 0; angle < Math.PI * 2; angle += ANGLE_STEP) {
const x = Math.round(r * Math.sin(angle) * 10) / 10;
const z = Math.round(r * Math.cos(angle) * 10) / 10;
const candidate = new THREE.Vector3(x, 0, z);
const tooClose = existing.some(p => p.distanceTo(candidate) < MIN_DISTANCE);
if (!tooClose) {
return { x, z };
}
}
}
// Fallback: random offset if all slots taken (very unlikely)
return { x: (Math.random() - 0.5) * 20, z: (Math.random() - 0.5) * 20 };
}
/**
* Remove an agent from the scene and dispose its resources.
* Useful for agent_left events.
*
* @param {string} agentId
* @returns {boolean} true if removed
*/
export function removeAgent(agentId) {
const agent = agents.get(agentId);
if (!agent) return false;
scene.remove(agent.group);
agent.dispose();
agents.delete(agentId);
buildConnectionLines();
console.info('[Agents] Removed agent:', agentId);
return true;
}

68
js/config.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* config.js — Connection configuration for The Matrix.
*
* Override at deploy time via URL query params:
* ?ws=ws://tower:8080/ws/world-state — WebSocket endpoint
* ?token=my-secret — Auth token (Phase 1 shared secret)
* ?mock=true — Force mock mode (no real WS)
*
* Or via Vite env vars:
* VITE_WS_URL — WebSocket endpoint
* VITE_WS_TOKEN — Auth token
* VITE_MOCK_MODE — 'true' to force mock mode
*
* Priority: URL params > env vars > defaults.
*
* Resolves Issue #7 — js/config.js
* Resolves Issue #11 — WS authentication strategy (Phase 1: shared secret)
*/
const params = new URLSearchParams(window.location.search);
function param(name, envKey, fallback) {
return params.get(name)
?? (import.meta.env[envKey] || null)
?? fallback;
}
export const Config = Object.freeze({
/** WebSocket endpoint. Empty string = no live connection (mock mode). */
wsUrl: param('ws', 'VITE_WS_URL', ''),
/** Auth token appended as ?token= query param on WS connect (Issue #11). */
wsToken: param('token', 'VITE_WS_TOKEN', ''),
/** Force mock mode even if wsUrl is set. Useful for local dev. */
mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true',
/** Reconnection timing */
reconnectBaseMs: 2000,
reconnectMaxMs: 30000,
/** Heartbeat / zombie detection */
heartbeatIntervalMs: 30000,
heartbeatTimeoutMs: 5000,
/**
* Computed: should we use the real WebSocket client?
* True when wsUrl is non-empty AND mockMode is false.
*/
get isLive() {
return this.wsUrl !== '' && !this.mockMode;
},
/**
* Build the final WS URL with auth token appended as a query param.
* Returns null if not in live mode.
*
* Result: ws://tower:8080/ws/world-state?token=my-secret
*/
get wsUrlWithAuth() {
if (!this.isLive) return null;
const url = new URL(this.wsUrl);
if (this.wsToken) {
url.searchParams.set('token', this.wsToken);
}
return url.toString();
},
});

View File

@@ -1,8 +1,18 @@
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState } from './agents.js';
import { appendChatMessage } from './ui.js';
/**
* websocket.js — WebSocket client for The Matrix.
*
* Two modes controlled by Config:
* - Live mode: connects to a real Timmy Tower backend via Config.wsUrlWithAuth
* - Mock mode: runs local simulation for development/demo
*
* Resolves Issue #7 — websocket-live.js with reconnection + backoff
* Resolves Issue #11 — WS auth token sent via query param on connect
*/
const WS_URL = import.meta.env.VITE_WS_URL || '';
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { setAgentState, addAgent } from './agents.js';
import { appendChatMessage } from './ui.js';
import { Config } from './config.js';
const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d]));
@@ -11,23 +21,42 @@ let connectionState = 'disconnected';
let jobCount = 0;
let reconnectTimer = null;
let reconnectAttempts = 0;
const RECONNECT_BASE_MS = 2000;
const RECONNECT_MAX_MS = 30000;
const HEARTBEAT_INTERVAL_MS = 30000;
const HEARTBEAT_TIMEOUT_MS = 5000;
let heartbeatTimer = null;
let heartbeatTimeout = null;
/* ── Public API ── */
export function initWebSocket(_scene) {
if (!WS_URL) {
connectionState = 'disconnected';
return;
if (Config.isLive) {
logEvent('Connecting to ' + Config.wsUrl + '…');
connect();
} else {
connectionState = 'mock';
logEvent('Mock mode — no live backend');
}
connect();
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}
/**
* Send a message to the backend. In mock mode this is a no-op.
* @param {object} msg — message object (will be JSON-stringified)
*/
export function sendMessage(msg) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(msg));
} catch { /* onclose will fire */ }
}
/* ── Live WebSocket Client ── */
function connect() {
if (ws) {
ws.onclose = null;
@@ -36,8 +65,15 @@ function connect() {
connectionState = 'connecting';
const url = Config.wsUrlWithAuth;
if (!url) {
connectionState = 'disconnected';
logEvent('No WS URL configured');
return;
}
try {
ws = new WebSocket(WS_URL);
ws = new WebSocket(url);
} catch (err) {
console.warn('[Matrix WS] Connection failed:', err.message || err);
logEvent('WebSocket connection failed');
@@ -51,20 +87,22 @@ function connect() {
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
startHeartbeat();
ws.send(JSON.stringify({
logEvent('Connected to backend');
// Subscribe to agent world-state channel
sendMessage({
type: 'subscribe',
channel: 'agents',
clientId: crypto.randomUUID(),
}));
});
};
ws.onmessage = (event) => {
// Any message counts as a heartbeat response
resetHeartbeatTimeout();
try {
handleMessage(JSON.parse(event.data));
} catch (err) {
console.warn('[Matrix WS] Message parse/handle error:', err.message, '| raw:', event.data?.slice?.(0, 200));
console.warn('[Matrix WS] Parse error:', err.message, '| raw:', event.data?.slice?.(0, 200));
}
};
@@ -80,24 +118,30 @@ function connect() {
// Don't reconnect on clean close (1000) or going away (1001)
if (event.code === 1000 || event.code === 1001) {
console.info('[Matrix WS] Clean close (code ' + event.code + '), not reconnecting');
logEvent('Disconnected (clean)');
return;
}
console.warn('[Matrix WS] Unexpected close — code:', event.code, 'reason:', event.reason || '(none)');
logEvent('Connection lost — reconnecting…');
scheduleReconnect();
};
}
/* ── Reconnection with exponential backoff ── */
function scheduleReconnect() {
clearTimeout(reconnectTimer);
// Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), RECONNECT_MAX_MS);
const delay = Math.min(
Config.reconnectBaseMs * Math.pow(2, reconnectAttempts),
Config.reconnectMaxMs,
);
reconnectAttempts++;
console.info('[Matrix WS] Reconnecting in', Math.round(delay / 1000), 's (attempt', reconnectAttempts + ')');
reconnectTimer = setTimeout(connect, delay);
}
/* ── Heartbeat / zombie connection detection ── */
/* ── Heartbeat / zombie detection ── */
function startHeartbeat() {
stopHeartbeat();
@@ -105,13 +149,13 @@ function startHeartbeat() {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'ping' }));
} catch { /* ignore send failures, onclose will fire */ }
} catch { /* ignore, onclose will fire */ }
heartbeatTimeout = setTimeout(() => {
console.warn('[Matrix WS] Heartbeat timeout — closing zombie connection');
if (ws) ws.close(4000, 'heartbeat timeout');
}, HEARTBEAT_TIMEOUT_MS);
}, Config.heartbeatTimeoutMs);
}
}, HEARTBEAT_INTERVAL_MS);
}, Config.heartbeatIntervalMs);
}
function stopHeartbeat() {
@@ -126,7 +170,7 @@ function resetHeartbeatTimeout() {
heartbeatTimeout = null;
}
/* ── Message handler ── */
/* ── Message dispatcher ── */
function handleMessage(msg) {
switch (msg.type) {
@@ -136,18 +180,21 @@ function handleMessage(msg) {
}
break;
}
case 'job_started': {
jobCount++;
if (msg.agentId) setAgentState(msg.agentId, 'active');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`);
break;
}
case 'job_completed': {
if (jobCount > 0) jobCount--;
if (msg.agentId) setAgentState(msg.agentId, 'idle');
logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`);
break;
}
case 'chat': {
const def = agentById[msg.agentId];
if (def && msg.text) {
@@ -155,9 +202,48 @@ function handleMessage(msg) {
}
break;
}
/**
* Dynamic agent hot-add (Issue #12).
*
* When the backend sends an agent_joined event, we register the new
* agent definition and spawn its 3D avatar without requiring a page
* reload. The event payload must include at minimum:
* { type: 'agent_joined', id, label, color, role }
*
* Optional fields: direction, x, z (auto-placed if omitted).
*/
case 'agent_joined': {
if (!msg.id || !msg.label) {
console.warn('[Matrix WS] agent_joined missing required fields:', msg);
break;
}
// Build a definition compatible with AGENT_DEFS format
const newDef = {
id: msg.id,
label: msg.label,
color: typeof msg.color === 'number' ? msg.color : parseInt(msg.color, 16) || 0x00ff88,
role: msg.role || 'agent',
direction: msg.direction || 'north',
x: msg.x ?? null,
z: msg.z ?? null,
};
// addAgent handles placement, scene insertion, and connection lines
const added = addAgent(newDef);
if (added) {
// Update local lookup for future chat messages
agentById[newDef.id] = newDef;
logEvent(`Agent ${newDef.label} joined the swarm`);
}
break;
}
case 'pong':
case 'agent_count':
break;
default:
console.debug('[Matrix WS] Unhandled message type:', msg.type);
break;
@@ -167,11 +253,3 @@ function handleMessage(msg) {
function logEvent(text) {
appendChatMessage('SYS', text, '#005500');
}
export function getConnectionState() {
return connectionState;
}
export function getJobCount() {
return jobCount;
}