Agents can now provision their sandbox on a connected fleet node instead of the gateway host. Local stays the strict default, so existing agents are byte-for- byte unaffected until explicitly placed elsewhere. Security parity: the daemon links the REAL cm-sandbox DockerDriver and runs the typed container ops (sb_provision/sb_exec/sb_destroy/sb_health/sb_list) through it — identical hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) to local sandboxes. cm-sandbox spec types are now Serialize/Deserialize so the spec crosses the channel. - cm-api: RemoteDriver (impl SandboxDriver over the node channel) + HubDriverProvider (impl cm_runtime::NodeDriverProvider, hands out a driver only for connected nodes via a sync online set) + NodeHub.call/is_connected. AppState.with_node_hub so the hub is shared with the placement provider. - cm-runtime SandboxManager: driver_for(node_id) routes by the recorded agent_containers.node_id (local default = existing driver, identical path); placement_node() reads the workspace setting and falls back to local if the node is offline; exec/release route accordingly. NodeDriverProvider trait. - DB: 0020_workspace_placement + repo (for_agent/get/set/clear). - main.rs: build the NodeHub first; inject HubDriverProvider into the agent manager + share the hub with AppState. - API+UI: GET/PUT /api/fleet/placement + a "Run agents on: Local / <node>" selector in the Fleet overview. Note: a node must be able to pull the agent image (the daemon docker-pulls it); interactive PTY for agent containers on remote nodes is not wired (Terminal app stays local) — the in-dashboard node shell already covers host access. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
82 lines
3.6 KiB
JavaScript
82 lines
3.6 KiB
JavaScript
// Clawmates Live — client adapter
|
|
// --------------------------------
|
|
// Drop-in: include this in any visualization page. It connects to the live bridge and
|
|
// dispatches typed events. If no live URL is provided, it does NOTHING and the page keeps
|
|
// its built-in synthetic animation (safe fallback).
|
|
//
|
|
// <script src="clawmates-live.js"></script>
|
|
//
|
|
// Live URL resolution order:
|
|
// 1. window.CLAWMATES_LIVE_URL
|
|
// 2. ?live=<url> query param
|
|
// 3. (none) → inert
|
|
//
|
|
// Usage from a visualization's logic:
|
|
// ClawmatesLive.on('world.touch', e => worldEngine.touch(e.agentId, e.nodeId));
|
|
// ClawmatesLive.on('telemetry', e => setTopbar(e));
|
|
// Handlers registered AFTER events have arrived still receive the last replayed value
|
|
// for stateful event types (status/task/telemetry/topology/routine/node).
|
|
|
|
(function () {
|
|
const TYPES = [
|
|
'agent.status', 'agent.task.update', 'agent.reasoning.delta', 'agent.computer.frame',
|
|
'agent.tool.call', 'door.request', 'door.resolve', 'agent.message',
|
|
'world.touch', 'node.activity', 'topology.update', 'telemetry', 'routine.update',
|
|
];
|
|
const STATEFUL = new Set(['agent.status', 'agent.task.update', 'node.activity', 'telemetry', 'topology.update', 'routine.update']);
|
|
|
|
const handlers = {}; // type → Set<fn>
|
|
const last = {}; // stateKey → payload (replay buffer)
|
|
TYPES.forEach(t => (handlers[t] = new Set()));
|
|
|
|
function stateKey(type, d) {
|
|
if (type === 'agent.status' || type === 'agent.task.update') return type + ':' + d.agentId;
|
|
if (type === 'node.activity') return type + ':' + d.nodeId;
|
|
if (type === 'routine.update') return type + ':' + d.routineId;
|
|
return type; // telemetry, topology.update
|
|
}
|
|
|
|
function dispatch(type, data) {
|
|
if (STATEFUL.has(type)) last[stateKey(type, data)] = data;
|
|
(handlers[type] || []).forEach(fn => { try { fn(data); } catch (e) { console.warn('[ClawmatesLive]', type, e); } });
|
|
try { window.dispatchEvent(new CustomEvent('clawmates:' + type, { detail: data })); } catch {}
|
|
}
|
|
|
|
function resolveUrl() {
|
|
if (window.CLAWMATES_LIVE_URL) return window.CLAWMATES_LIVE_URL;
|
|
try { return new URLSearchParams(location.search).get('live') || null; } catch { return null; }
|
|
}
|
|
|
|
const API = {
|
|
connected: false,
|
|
url: null,
|
|
on(type, fn) {
|
|
if (!handlers[type]) handlers[type] = new Set();
|
|
handlers[type].add(fn);
|
|
// replay last-known stateful value so late subscribers paint immediately
|
|
for (const k in last) if (k === type || k.startsWith(type + ':')) { try { fn(last[k]); } catch {} }
|
|
return () => handlers[type].delete(fn);
|
|
},
|
|
off(type, fn) { handlers[type] && handlers[type].delete(fn); },
|
|
connect(url) {
|
|
url = url || resolveUrl();
|
|
if (!url) { console.info('[ClawmatesLive] no live URL — running in synthetic/offline mode'); return; }
|
|
this.url = url;
|
|
const es = new EventSource(url);
|
|
es.onopen = () => { this.connected = true; window.dispatchEvent(new CustomEvent('clawmates:open')); };
|
|
es.onerror = () => { this.connected = false; window.dispatchEvent(new CustomEvent('clawmates:reconnecting')); };
|
|
TYPES.forEach(type => es.addEventListener(type, ev => {
|
|
let data; try { data = JSON.parse(ev.data); } catch { return; }
|
|
dispatch(type, data);
|
|
}));
|
|
this._es = es;
|
|
},
|
|
disconnect() { if (this._es) { this._es.close(); this._es = null; this.connected = false; } },
|
|
};
|
|
|
|
window.ClawmatesLive = API;
|
|
// auto-connect on load if a URL is resolvable
|
|
if (document.readyState !== 'loading') API.connect();
|
|
else window.addEventListener('DOMContentLoaded', () => API.connect());
|
|
})();
|