infinicode 2.3.5 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/plugins/home-logo-animation.tsx +5 -1
- package/.opencode/plugins/mesh-commands.tsx +4 -3
- package/.opencode/plugins/routing-mode-display.tsx +6 -2
- package/.opencode/tui.json +2 -1
- package/bin/robopark.js +2 -0
- package/dist/cli.js +46 -1
- package/dist/commands/maintain.d.ts +29 -0
- package/dist/commands/maintain.js +186 -0
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mesh-install.d.ts +10 -0
- package/dist/commands/mesh-install.js +160 -0
- package/dist/commands/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/run.js +82 -22
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +152 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
- package/dist/kernel/agents/backends/cli-backend.js +140 -0
- package/dist/kernel/agents/backends/detect.d.ts +17 -0
- package/dist/kernel/agents/backends/detect.js +140 -0
- package/dist/kernel/agents/backends/index.d.ts +11 -0
- package/dist/kernel/agents/backends/index.js +3 -0
- package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
- package/dist/kernel/agents/backends/parse-utils.js +72 -0
- package/dist/kernel/agents/backends/profiles.d.ts +67 -0
- package/dist/kernel/agents/backends/profiles.js +48 -0
- package/dist/kernel/agents/backends/registry.d.ts +31 -0
- package/dist/kernel/agents/backends/registry.js +174 -0
- package/dist/kernel/agents/backends/rotation.d.ts +51 -0
- package/dist/kernel/agents/backends/rotation.js +107 -0
- package/dist/kernel/agents/backends/types.d.ts +67 -0
- package/dist/kernel/agents/backends/types.js +1 -0
- package/dist/kernel/agents/index.d.ts +8 -0
- package/dist/kernel/agents/index.js +8 -0
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +371 -0
- package/dist/kernel/federation/federation.d.ts +112 -2
- package/dist/kernel/federation/federation.js +270 -10
- package/dist/kernel/federation/moltfed-adapter.js +1 -0
- package/dist/kernel/federation/telemetry.d.ts +1 -1
- package/dist/kernel/federation/telemetry.js +2 -1
- package/dist/kernel/federation/transport-http.d.ts +17 -1
- package/dist/kernel/federation/transport-http.js +65 -2
- package/dist/kernel/federation/types.d.ts +46 -1
- package/dist/kernel/free-providers.js +83 -0
- package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
- package/dist/kernel/gateway/openai-gateway.js +428 -49
- package/dist/kernel/index.d.ts +2 -1
- package/dist/kernel/index.js +3 -1
- package/dist/kernel/logger.d.ts +16 -3
- package/dist/kernel/logger.js +38 -0
- package/dist/kernel/mcp/mcp-server.js +65 -10
- package/dist/kernel/orchestrator.d.ts +4 -0
- package/dist/kernel/orchestrator.js +34 -0
- package/dist/kernel/provider-manager.d.ts +13 -0
- package/dist/kernel/provider-manager.js +58 -5
- package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
- package/dist/kernel/providers/openai-compatible-provider.js +22 -3
- package/dist/kernel/recovery-manager.js +55 -15
- package/dist/kernel/router.js +15 -2
- package/dist/kernel/types.d.ts +12 -2
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +6 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MeshServer, MeshClient } from './transport-http.js';
|
|
2
|
+
import { DASHBOARD_HTML } from './dashboard-html.js';
|
|
2
3
|
import { PeerMesh } from './peer-mesh.js';
|
|
3
4
|
import { EventBridge } from './event-bridge.js';
|
|
4
5
|
import { ConfigSync } from './config-sync.js';
|
|
@@ -11,6 +12,9 @@ import { loadRole, saveRole, rolePreamble } from './role-context.js';
|
|
|
11
12
|
import { discoverTailscalePeers } from './discovery.js';
|
|
12
13
|
import { LanBeacon } from './lan-discovery.js';
|
|
13
14
|
import { DEFAULT_MESH_PORT } from './types.js';
|
|
15
|
+
import { createDefaultBackendRegistry } from '../agents/index.js';
|
|
16
|
+
import { RotationState, selectChain } from '../agents/backends/rotation.js';
|
|
17
|
+
import { loadCoderConfig } from '../agents/backends/profiles.js';
|
|
14
18
|
export class Federation {
|
|
15
19
|
deps;
|
|
16
20
|
server;
|
|
@@ -30,6 +34,12 @@ export class Federation {
|
|
|
30
34
|
configSync;
|
|
31
35
|
autoUpdate;
|
|
32
36
|
compute = new ComputeRouter();
|
|
37
|
+
/** Executors available on this node (native kernel + installed CLIs). */
|
|
38
|
+
backends;
|
|
39
|
+
/** Resolved coder profiles + rotation policy for this node. */
|
|
40
|
+
coderConfig;
|
|
41
|
+
/** Per-node round-robin cursor for backend rotation. */
|
|
42
|
+
rotationState = new RotationState();
|
|
33
43
|
/** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
|
|
34
44
|
frameSubs = new Set();
|
|
35
45
|
/** Recent frames for catch-up. */
|
|
@@ -41,6 +51,8 @@ export class Federation {
|
|
|
41
51
|
constructor(deps) {
|
|
42
52
|
this.deps = deps;
|
|
43
53
|
this.client = new MeshClient({ token: deps.options.token });
|
|
54
|
+
this.backends = deps.backends ?? createDefaultBackendRegistry();
|
|
55
|
+
this.coderConfig = loadCoderConfig(deps.options.coder);
|
|
44
56
|
this.configSync = new ConfigSync({ logger: deps.logger, apply: deps.options.applyConfig });
|
|
45
57
|
this.autoUpdate = new AutoUpdate({
|
|
46
58
|
version: deps.options.version,
|
|
@@ -59,9 +71,13 @@ export class Federation {
|
|
|
59
71
|
host: options.host,
|
|
60
72
|
token: options.token,
|
|
61
73
|
logger,
|
|
62
|
-
getManifest: () => buildManifest(identity, this.
|
|
74
|
+
getManifest: () => buildManifest(identity, this.describeWithBackends()),
|
|
63
75
|
getNodes: () => this.nodeStatuses(),
|
|
76
|
+
getStatus: () => this.statusSnapshot(),
|
|
64
77
|
runCommand: (input) => this.deps.kernel.command(input),
|
|
78
|
+
commandGate: options.commandGate,
|
|
79
|
+
onApply: options.onApply,
|
|
80
|
+
dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
|
|
65
81
|
onRpc: (env, remote) => this.onRpc(env, remote),
|
|
66
82
|
});
|
|
67
83
|
await this.server.start();
|
|
@@ -77,9 +93,15 @@ export class Federation {
|
|
|
77
93
|
});
|
|
78
94
|
this.mesh.start();
|
|
79
95
|
// Bridge local kernel events → mesh stream (coalesced, animation-friendly).
|
|
96
|
+
// Also record into frameHistory so this node's own activity shows up in the
|
|
97
|
+
// /fed/status snapshot the dashboard + `robopark fleet` poll — not just in
|
|
98
|
+
// the live SSE push to connected harnesses.
|
|
80
99
|
this.bridge = new EventBridge({
|
|
81
100
|
nodeId: identity.nodeId,
|
|
82
|
-
emit: f =>
|
|
101
|
+
emit: f => {
|
|
102
|
+
this.server?.broadcast(f);
|
|
103
|
+
this.ingestFrame(identity.nodeId, f);
|
|
104
|
+
},
|
|
83
105
|
});
|
|
84
106
|
this.unsub = this.deps.kernel.subscribeAll(e => this.bridge?.handle(e));
|
|
85
107
|
// Telemetry heartbeat → stream a HardwareSnapshot on a fixed cadence.
|
|
@@ -107,7 +129,7 @@ export class Federation {
|
|
|
107
129
|
return reply(env, 'ack', self, { load: loadFromSnapshot(this.lastHw) });
|
|
108
130
|
case 'hello':
|
|
109
131
|
this.learnInboundPeer(env, remote);
|
|
110
|
-
return reply(env, 'welcome', self, buildManifest(this.deps.identity, this.
|
|
132
|
+
return reply(env, 'welcome', self, buildManifest(this.deps.identity, this.describeWithBackends()));
|
|
111
133
|
case 'task.dispatch': {
|
|
112
134
|
// Non-blocking handoff: register the run, ack with a runId immediately,
|
|
113
135
|
// execute in the background, and let the caller follow it.
|
|
@@ -144,6 +166,29 @@ export class Federation {
|
|
|
144
166
|
const saved = saveRole(r);
|
|
145
167
|
return reply(env, 'ack', self, saved);
|
|
146
168
|
}
|
|
169
|
+
case 'agents.list':
|
|
170
|
+
return reply(env, 'agents.offer', self, this.localAgents());
|
|
171
|
+
case 'message.send': {
|
|
172
|
+
// Wake / continue a CLI agent session on this node with a new message.
|
|
173
|
+
const d = env.data;
|
|
174
|
+
const runId = newId();
|
|
175
|
+
this.runs.set(runId, {
|
|
176
|
+
runId,
|
|
177
|
+
status: 'running',
|
|
178
|
+
description: `msg→${(d.sessionId ?? '').slice(0, 8)}`,
|
|
179
|
+
agent: d.agent,
|
|
180
|
+
sessionId: d.sessionId,
|
|
181
|
+
startedAt: Date.now(),
|
|
182
|
+
});
|
|
183
|
+
void this.runTaskAsync(runId, {
|
|
184
|
+
description: 'message',
|
|
185
|
+
capabilities: [],
|
|
186
|
+
prompt: d.message,
|
|
187
|
+
agent: d.agent,
|
|
188
|
+
sessionId: d.sessionId,
|
|
189
|
+
});
|
|
190
|
+
return reply(env, 'message.accepted', self, { runId, sessionId: d.sessionId });
|
|
191
|
+
}
|
|
147
192
|
default:
|
|
148
193
|
return reply(env, 'ack', self);
|
|
149
194
|
}
|
|
@@ -207,12 +252,25 @@ export class Federation {
|
|
|
207
252
|
if (!rec)
|
|
208
253
|
return;
|
|
209
254
|
const nodeId = this.deps.identity.nodeId;
|
|
210
|
-
|
|
255
|
+
// Rotation is requested when a profile/strategy/route/policy is given, or the
|
|
256
|
+
// caller asks for 'auto'. Otherwise a concrete agent is a single fixed backend.
|
|
257
|
+
const wantsRotation = !!(task.profile || task.strategy || task.taskType || task.policy || task.agent === 'auto');
|
|
258
|
+
const backendId = task.agent && task.agent !== 'kernel' && task.agent !== 'auto' ? task.agent : null;
|
|
259
|
+
rec.agent = wantsRotation ? 'rotate' : (backendId ?? 'kernel');
|
|
260
|
+
this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_STARTED', run: runId, desc: task.description, agent: rec.agent }));
|
|
211
261
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
262
|
+
if (wantsRotation) {
|
|
263
|
+
await this.runViaBackendChain(runId, task, rec);
|
|
264
|
+
}
|
|
265
|
+
else if (backendId) {
|
|
266
|
+
await this.runViaBackend(runId, backendId, task, rec);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const result = await this.runTask(task);
|
|
270
|
+
rec.status = result.status === 'COMPLETED' ? 'completed' : 'failed';
|
|
271
|
+
rec.missionId = result.missionId;
|
|
272
|
+
rec.outputs = result.outputs;
|
|
273
|
+
}
|
|
216
274
|
rec.completedAt = Date.now();
|
|
217
275
|
}
|
|
218
276
|
catch (err) {
|
|
@@ -222,6 +280,90 @@ export class Federation {
|
|
|
222
280
|
}
|
|
223
281
|
this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_COMPLETED', run: runId, status: rec.status }));
|
|
224
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Execute a dispatched task with a third-party CLI backend (Claude Code /
|
|
285
|
+
* Codex / OpenCode) instead of the native kernel. The backend's activity
|
|
286
|
+
* streams up the same mesh SSE feed (log/ev frames), and its native session id
|
|
287
|
+
* is captured on the RunRecord so the run can be followed and later messaged.
|
|
288
|
+
*/
|
|
289
|
+
async runViaBackend(runId, backendId, task, rec) {
|
|
290
|
+
const res = await this.runBackendOnce(runId, backendId, task, task.model);
|
|
291
|
+
this.writeResult(rec, runId, backendId, res);
|
|
292
|
+
}
|
|
293
|
+
/** Stream one backend's frames up the mesh, tagged with the backend id. */
|
|
294
|
+
backendFrameSink(runId, backendId) {
|
|
295
|
+
const nodeId = this.deps.identity.nodeId;
|
|
296
|
+
return (f) => {
|
|
297
|
+
if (f.kind === 'log' && f.text) {
|
|
298
|
+
this.server?.broadcast(frame('log', nodeId, { run: runId, agent: backendId, line: f.text }));
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
this.server?.broadcast(frame('ev', nodeId, { k: 'AGENT_OUTPUT', run: runId, agent: backendId, kind: f.kind, text: f.text }));
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** Run a single backend attempt. Never throws — a missing/unavailable backend
|
|
306
|
+
* returns a failed result so a rotation chain can fall through to the next. */
|
|
307
|
+
async runBackendOnce(runId, backendId, task, model, args) {
|
|
308
|
+
const backend = this.backends.get(backendId);
|
|
309
|
+
if (!backend || !backend.available()) {
|
|
310
|
+
return { status: 'failed', error: `backend not available on this node: ${backendId}` };
|
|
311
|
+
}
|
|
312
|
+
const preamble = rolePreamble(loadRole());
|
|
313
|
+
try {
|
|
314
|
+
return await backend.run({ prompt: task.prompt, capabilities: task.capabilities, model: model ?? task.model, args, sessionId: task.sessionId }, { runId, rolePreamble: preamble, onFrame: this.backendFrameSink(runId, backendId) });
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return { status: 'failed', error: err instanceof Error ? err.message : String(err) };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Write a backend result onto the run record. */
|
|
321
|
+
writeResult(rec, runId, backendId, res) {
|
|
322
|
+
rec.agent = backendId;
|
|
323
|
+
rec.status = res.status;
|
|
324
|
+
rec.sessionId = res.sessionId;
|
|
325
|
+
rec.missionId = res.sessionId ?? runId;
|
|
326
|
+
rec.outputs = [{ status: res.status, content: res.content, error: res.error }];
|
|
327
|
+
rec.error = res.status === 'failed' ? res.error : undefined;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resolve a rotation chain for the task and run it with fallback: try each
|
|
331
|
+
* profile's backend in order, streaming its activity; the first `completed`
|
|
332
|
+
* wins, otherwise fall through to the next. Composes task-type routing, policy
|
|
333
|
+
* tiers, round-robin and fallback (see agents/backends/rotation.ts).
|
|
334
|
+
*/
|
|
335
|
+
async runViaBackendChain(runId, task, rec) {
|
|
336
|
+
const nodeId = this.deps.identity.nodeId;
|
|
337
|
+
const req = {
|
|
338
|
+
strategy: task.strategy,
|
|
339
|
+
profile: task.profile,
|
|
340
|
+
model: task.model,
|
|
341
|
+
taskType: task.taskType,
|
|
342
|
+
policy: task.policy,
|
|
343
|
+
backend: task.agent && task.agent !== 'auto' && task.agent !== 'kernel' ? task.agent : undefined,
|
|
344
|
+
};
|
|
345
|
+
const chain = selectChain(this.coderConfig, req, this.rotationState);
|
|
346
|
+
let last = { status: 'failed', error: 'no backend available in rotation chain' };
|
|
347
|
+
for (let i = 0; i < chain.length; i++) {
|
|
348
|
+
const step = chain[i];
|
|
349
|
+
this.server?.broadcast(frame('ev', nodeId, {
|
|
350
|
+
k: 'ROTATION_ATTEMPT', run: runId, agent: step.backend, model: step.model,
|
|
351
|
+
profile: step.profileName, attempt: i + 1, of: chain.length,
|
|
352
|
+
}));
|
|
353
|
+
last = await this.runBackendOnce(runId, step.backend, task, step.model, step.args);
|
|
354
|
+
if (last.status === 'completed') {
|
|
355
|
+
this.writeResult(rec, runId, step.backend, last);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (i < chain.length - 1) {
|
|
359
|
+
this.server?.broadcast(frame('ev', nodeId, {
|
|
360
|
+
k: 'ROTATION_FALLBACK', run: runId, from: step.backend,
|
|
361
|
+
next: chain[i + 1].backend, reason: (last.error ?? '').slice(0, 200),
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.writeResult(rec, runId, chain[chain.length - 1]?.backend ?? 'kernel', last);
|
|
366
|
+
}
|
|
225
367
|
// ── Outbound operations ──────────────────────────────────────────────────
|
|
226
368
|
/**
|
|
227
369
|
* Hand a task to the best-fit connected peer (compute-routed) or a named one.
|
|
@@ -292,6 +434,96 @@ export class Federation {
|
|
|
292
434
|
const res = await this.client.rpc(peer.url, envelope('command', this.deps.identity.nodeId, { input }, { to: nodeId }));
|
|
293
435
|
return res.data;
|
|
294
436
|
}
|
|
437
|
+
// ── Agent mailbox (CLI sessions message each other) ────────────────────────
|
|
438
|
+
/**
|
|
439
|
+
* CLI agent sessions on THIS node — any run that used a CLI backend and
|
|
440
|
+
* captured a session id. Live while running, dormant (still resumable) once
|
|
441
|
+
* complete. Deduped by session, newest kept, so a session that's been messaged
|
|
442
|
+
* several times shows once.
|
|
443
|
+
*/
|
|
444
|
+
localAgents() {
|
|
445
|
+
const self = this.deps.identity.nodeId;
|
|
446
|
+
const bySession = new Map();
|
|
447
|
+
for (const rec of this.runs.values()) {
|
|
448
|
+
if (!rec.sessionId || !rec.agent || rec.agent === 'kernel')
|
|
449
|
+
continue;
|
|
450
|
+
const existing = bySession.get(rec.sessionId);
|
|
451
|
+
if (existing && existing.lastActiveAt >= (rec.completedAt ?? rec.startedAt))
|
|
452
|
+
continue;
|
|
453
|
+
bySession.set(rec.sessionId, {
|
|
454
|
+
agentId: rec.runId,
|
|
455
|
+
nodeId: self,
|
|
456
|
+
backend: rec.agent,
|
|
457
|
+
sessionId: rec.sessionId,
|
|
458
|
+
address: `${self}/${rec.sessionId}`,
|
|
459
|
+
status: rec.status,
|
|
460
|
+
description: rec.description,
|
|
461
|
+
lastActiveAt: rec.completedAt ?? rec.startedAt,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return [...bySession.values()];
|
|
465
|
+
}
|
|
466
|
+
/** All messageable CLI agent sessions across the mesh (local + peers). */
|
|
467
|
+
async agents() {
|
|
468
|
+
const self = this.deps.identity.nodeId;
|
|
469
|
+
const out = [...this.localAgents()];
|
|
470
|
+
for (const peer of this.mesh?.connected() ?? []) {
|
|
471
|
+
try {
|
|
472
|
+
const res = await this.client.rpc(peer.url, envelope('agents.list', self, {}, { to: peer.nodeId }));
|
|
473
|
+
if (Array.isArray(res.data))
|
|
474
|
+
out.push(...res.data);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// peer unreachable — skip
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
/** Resolve an address (`nodeId/sessionId`, a sessionId, or a runId) to a handle. */
|
|
483
|
+
async resolveAgent(address) {
|
|
484
|
+
const dir = await this.agents();
|
|
485
|
+
if (address.includes('/')) {
|
|
486
|
+
const slash = address.indexOf('/');
|
|
487
|
+
const nodeId = address.slice(0, slash);
|
|
488
|
+
const sessionId = address.slice(slash + 1);
|
|
489
|
+
return dir.find(a => a.nodeId === nodeId && a.sessionId === sessionId) ?? null;
|
|
490
|
+
}
|
|
491
|
+
return dir.find(a => a.agentId === address) ?? dir.find(a => a.sessionId === address) ?? null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Message a CLI agent session — wherever it lives on the mesh. Resumes that
|
|
495
|
+
* exact session with the text (so the agent keeps its context) and returns a
|
|
496
|
+
* runId to follow the reply. This is how one CLI agent talks to another.
|
|
497
|
+
*/
|
|
498
|
+
async sendTo(address, text, opts) {
|
|
499
|
+
const target = await this.resolveAgent(address);
|
|
500
|
+
if (!target)
|
|
501
|
+
return null;
|
|
502
|
+
const self = this.deps.identity.nodeId;
|
|
503
|
+
if (target.nodeId === self) {
|
|
504
|
+
const runId = newId();
|
|
505
|
+
this.runs.set(runId, {
|
|
506
|
+
runId, status: 'running', description: `msg→${target.sessionId.slice(0, 8)}`,
|
|
507
|
+
agent: target.backend, sessionId: target.sessionId, startedAt: Date.now(),
|
|
508
|
+
});
|
|
509
|
+
void this.runTaskAsync(runId, {
|
|
510
|
+
description: 'message', capabilities: [], prompt: text,
|
|
511
|
+
agent: target.backend, sessionId: target.sessionId,
|
|
512
|
+
});
|
|
513
|
+
const record = opts?.wait ? await this.awaitRun(runId, opts.timeoutMs ?? 120_000) : undefined;
|
|
514
|
+
return { runId, nodeId: self, record };
|
|
515
|
+
}
|
|
516
|
+
const peer = this.mesh?.get(target.nodeId);
|
|
517
|
+
if (!peer)
|
|
518
|
+
return null;
|
|
519
|
+
const res = await this.client.rpc(peer.url, envelope('message.send', self, {
|
|
520
|
+
sessionId: target.sessionId, agent: target.backend, message: text, from: self,
|
|
521
|
+
}, { to: target.nodeId }));
|
|
522
|
+
const runId = res.data.runId;
|
|
523
|
+
this.dispatchedRuns.set(runId, target.nodeId);
|
|
524
|
+
const record = opts?.wait ? await this.awaitRun(runId, opts?.timeoutMs ?? 120_000) : undefined;
|
|
525
|
+
return { runId, nodeId: target.nodeId, record };
|
|
526
|
+
}
|
|
295
527
|
/** Hub: publish shared config (providers/API keys/policy) to the fleet. */
|
|
296
528
|
publishConfig(cfg) {
|
|
297
529
|
return this.configSync.publish(cfg);
|
|
@@ -406,20 +638,48 @@ export class Federation {
|
|
|
406
638
|
this.lan.start();
|
|
407
639
|
}
|
|
408
640
|
// ── Views ────────────────────────────────────────────────────────────────
|
|
641
|
+
/** Backend ids this node can execute tasks with ('kernel' + installed CLIs). */
|
|
642
|
+
availableBackends() {
|
|
643
|
+
return this.backends.availableIds();
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The manifest inputs augmented with `backend:<id>` capability tags for each
|
|
647
|
+
* installed executor. This lets the compute-router match a task that asks for
|
|
648
|
+
* a specific agent (e.g. capabilities:['backend:claude']) to a node that
|
|
649
|
+
* actually has that CLI — without changing the caller's describe().
|
|
650
|
+
*/
|
|
651
|
+
describeWithBackends() {
|
|
652
|
+
const d = this.deps.describe();
|
|
653
|
+
const tags = this.backends.availableIds().map(id => `backend:${id}`);
|
|
654
|
+
return { ...d, capabilities: [...d.capabilities, ...tags] };
|
|
655
|
+
}
|
|
409
656
|
/** Neutral node-status list (self + peers) — feeds the mesh map. */
|
|
410
657
|
nodeStatuses() {
|
|
411
|
-
const d = this.
|
|
658
|
+
const d = this.describeWithBackends();
|
|
412
659
|
const self = selfNodeStatus(this.deps.identity, this.lastHw ?? this.telemetry.collect(), {
|
|
413
660
|
version: d.version,
|
|
414
661
|
capabilities: d.capabilities,
|
|
415
662
|
commands: d.commands,
|
|
416
|
-
});
|
|
663
|
+
}, loadRole()?.site);
|
|
417
664
|
const peers = (this.mesh?.list() ?? []).map(nodeStatusFromPeer);
|
|
418
665
|
return [self, ...peers];
|
|
419
666
|
}
|
|
420
667
|
peers() {
|
|
421
668
|
return this.mesh?.list() ?? [];
|
|
422
669
|
}
|
|
670
|
+
/** Consolidated fleet view for the dashboard + `robopark fleet` (GET /fed/status). */
|
|
671
|
+
statusSnapshot() {
|
|
672
|
+
return {
|
|
673
|
+
self: buildManifest(this.deps.identity, this.describeWithBackends()),
|
|
674
|
+
role: this.getRole(),
|
|
675
|
+
nodes: this.nodeStatuses(),
|
|
676
|
+
hardware: this.lastHw,
|
|
677
|
+
runs: this.localRuns(),
|
|
678
|
+
agents: this.localAgents(),
|
|
679
|
+
activity: this.recentFrames(60),
|
|
680
|
+
ts: Date.now(),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
423
683
|
/** Latest local hardware snapshot (for the MOLTFED connector / panels). */
|
|
424
684
|
latestHardware() {
|
|
425
685
|
return this.lastHw;
|
|
@@ -120,7 +120,7 @@ export function nodeStatusFromPeer(peer) {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
/** Local node's own status entry. */
|
|
123
|
-
export function selfNodeStatus(identity, hw, manifest) {
|
|
123
|
+
export function selfNodeStatus(identity, hw, manifest, site) {
|
|
124
124
|
return {
|
|
125
125
|
nodeId: identity.nodeId,
|
|
126
126
|
displayName: identity.displayName,
|
|
@@ -134,5 +134,6 @@ export function selfNodeStatus(identity, hw, manifest) {
|
|
|
134
134
|
connectedAtMs: Date.now(),
|
|
135
135
|
load: loadFromSnapshot(hw),
|
|
136
136
|
lastSeenAt: Date.now(),
|
|
137
|
+
site,
|
|
137
138
|
};
|
|
138
139
|
}
|
|
@@ -9,9 +9,22 @@ export interface MeshServerOptions {
|
|
|
9
9
|
getManifest: () => NodeManifest;
|
|
10
10
|
/** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
|
|
11
11
|
getNodes?: () => unknown;
|
|
12
|
+
/** Optional: consolidated fleet snapshot (self + nodes + hardware + roles +
|
|
13
|
+
* runs + recent activity) for the dashboard and `robopark fleet` (GET /fed/status). */
|
|
14
|
+
getStatus?: () => unknown;
|
|
12
15
|
/** Optional: run a kernel slash-command on this node (POST /fed/command).
|
|
13
|
-
* Powers the in-TUI mesh command palette. */
|
|
16
|
+
* Powers the in-TUI mesh command palette + the dashboard's action buttons. */
|
|
14
17
|
runCommand?: (input: string) => Promise<unknown>;
|
|
18
|
+
/** Optional gate on POST /fed/command inputs. Returns {ok:false,reason} to
|
|
19
|
+
* reject — used to keep an unauthenticated, network-exposed node read-only. */
|
|
20
|
+
commandGate?: (input: string) => {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Optional: publish this node's shared config to the fleet (POST /fed/apply). */
|
|
25
|
+
onApply?: () => Promise<unknown> | unknown;
|
|
26
|
+
/** Optional: static HTML for the control dashboard, served at GET / and /ui. */
|
|
27
|
+
dashboardHtml?: string;
|
|
15
28
|
onRpc: RpcHandler;
|
|
16
29
|
logger: Logger;
|
|
17
30
|
}
|
|
@@ -25,6 +38,9 @@ export declare class MeshServer {
|
|
|
25
38
|
start(): Promise<void>;
|
|
26
39
|
/** Push a frame to every connected stream subscriber. */
|
|
27
40
|
broadcast(frame: StreamFrame): void;
|
|
41
|
+
/** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
|
|
42
|
+
* (browsers, which can't set headers on navigation or EventSource). */
|
|
43
|
+
private presentedToken;
|
|
28
44
|
private authOk;
|
|
29
45
|
private handle;
|
|
30
46
|
private openStream;
|
|
@@ -14,7 +14,16 @@
|
|
|
14
14
|
* MeshClient/MeshServer shape later for deep InfiniBot-fabric interop.
|
|
15
15
|
*/
|
|
16
16
|
import { createServer } from 'node:http';
|
|
17
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
17
18
|
import { frameToSSE, parseSSE } from './protocol.js';
|
|
19
|
+
/** Constant-time string compare — avoids leaking the token via response timing. */
|
|
20
|
+
function safeEqual(a, b) {
|
|
21
|
+
const ab = Buffer.from(a);
|
|
22
|
+
const bb = Buffer.from(b);
|
|
23
|
+
if (ab.length !== bb.length)
|
|
24
|
+
return false;
|
|
25
|
+
return timingSafeEqual(ab, bb);
|
|
26
|
+
}
|
|
18
27
|
export class MeshServer {
|
|
19
28
|
opts;
|
|
20
29
|
server;
|
|
@@ -51,19 +60,67 @@ export class MeshServer {
|
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
}
|
|
63
|
+
/** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
|
|
64
|
+
* (browsers, which can't set headers on navigation or EventSource). */
|
|
65
|
+
presentedToken(req) {
|
|
66
|
+
const h = req.headers['authorization'];
|
|
67
|
+
if (typeof h === 'string' && h.startsWith('Bearer '))
|
|
68
|
+
return h.slice(7);
|
|
69
|
+
const url = req.url ?? '';
|
|
70
|
+
const q = url.indexOf('?');
|
|
71
|
+
if (q >= 0) {
|
|
72
|
+
const t = new URLSearchParams(url.slice(q + 1)).get('token');
|
|
73
|
+
if (t)
|
|
74
|
+
return t;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
54
78
|
authOk(req) {
|
|
55
79
|
if (!this.opts.token)
|
|
56
80
|
return true;
|
|
57
|
-
const
|
|
58
|
-
return
|
|
81
|
+
const presented = this.presentedToken(req);
|
|
82
|
+
return presented !== undefined && safeEqual(presented, this.opts.token);
|
|
59
83
|
}
|
|
60
84
|
handle(req, res) {
|
|
61
85
|
const url = req.url ?? '/';
|
|
86
|
+
const path = url.split('?', 1)[0];
|
|
62
87
|
if (!this.authOk(req)) {
|
|
63
88
|
res.writeHead(401).end('unauthorized');
|
|
64
89
|
return;
|
|
65
90
|
}
|
|
66
91
|
const remote = req.socket.remoteAddress ?? 'unknown';
|
|
92
|
+
// Control dashboard (single self-contained page). Same-origin fetches carry
|
|
93
|
+
// the ?token= through, so no CORS is opened.
|
|
94
|
+
if (req.method === 'GET' && (path === '/' || path === '/ui' || path === '/dashboard')) {
|
|
95
|
+
if (!this.opts.dashboardHtml) {
|
|
96
|
+
res.writeHead(404).end('dashboard not enabled (start with --dashboard)');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
100
|
+
res.end(this.opts.dashboardHtml);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (req.method === 'GET' && path === '/fed/status') {
|
|
104
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
105
|
+
res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.method === 'POST' && path.startsWith('/fed/apply')) {
|
|
109
|
+
if (!this.opts.onApply) {
|
|
110
|
+
res.writeHead(404).end('no apply handler');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
Promise.resolve(this.opts.onApply())
|
|
114
|
+
.then(result => {
|
|
115
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
116
|
+
res.end(JSON.stringify(result ?? { ok: true }));
|
|
117
|
+
})
|
|
118
|
+
.catch(err => {
|
|
119
|
+
this.opts.logger.warn(`[federation] apply error: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
res.writeHead(500).end('apply failed');
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
67
124
|
if (req.method === 'GET' && url.startsWith('/fed/manifest')) {
|
|
68
125
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
69
126
|
res.end(JSON.stringify(this.opts.getManifest()));
|
|
@@ -93,6 +150,12 @@ export class MeshServer {
|
|
|
93
150
|
res.writeHead(400).end('bad json');
|
|
94
151
|
return;
|
|
95
152
|
}
|
|
153
|
+
const gate = this.opts.commandGate?.(input);
|
|
154
|
+
if (gate && !gate.ok) {
|
|
155
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
156
|
+
res.end(JSON.stringify({ ok: false, text: gate.reason ?? 'command not permitted on this node' }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
96
159
|
const result = await this.opts.runCommand(input);
|
|
97
160
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
98
161
|
res.end(JSON.stringify(result ?? { ok: false, text: 'no result' }));
|
|
@@ -125,8 +125,12 @@ export interface NodeStatus {
|
|
|
125
125
|
/** 0–100 rough load, from the latest hardware snapshot. */
|
|
126
126
|
load?: number;
|
|
127
127
|
lastSeenAt: number;
|
|
128
|
+
/** Physical deployment this node belongs to (e.g. "Tel Aviv"). Sourced from
|
|
129
|
+
* this node's own RoleContext.site — lets a fleet UI group by location as
|
|
130
|
+
* a customer scales from one site to many, without per-site bespoke code. */
|
|
131
|
+
site?: string;
|
|
128
132
|
}
|
|
129
|
-
export type MessageKind = 'hello' | 'welcome' | 'heartbeat' | 'manifest' | 'task.dispatch' | 'task.accepted' | 'task.status' | 'task.result' | 'task.cancel' | 'command' | 'command.result' | 'config.request' | 'config.offer' | 'update.announce' | 'update.request' | 'role.get' | 'role.set' | 'event' | 'telemetry' | 'ack' | 'error';
|
|
133
|
+
export type MessageKind = 'hello' | 'welcome' | 'heartbeat' | 'manifest' | 'task.dispatch' | 'task.accepted' | 'task.status' | 'task.result' | 'task.cancel' | 'command' | 'command.result' | 'config.request' | 'config.offer' | 'update.announce' | 'update.request' | 'role.get' | 'role.set' | 'agents.list' | 'agents.offer' | 'message.send' | 'message.accepted' | 'event' | 'telemetry' | 'ack' | 'error';
|
|
130
134
|
/** A dispatched/received task run — tracked for non-blocking handoff + monitoring. */
|
|
131
135
|
export interface RunRecord {
|
|
132
136
|
runId: string;
|
|
@@ -141,11 +145,52 @@ export interface RunRecord {
|
|
|
141
145
|
error?: string;
|
|
142
146
|
startedAt: number;
|
|
143
147
|
completedAt?: number;
|
|
148
|
+
/** Which executor ran this — 'kernel' (native) or a CLI backend id. */
|
|
149
|
+
agent?: string;
|
|
150
|
+
/** The executor's native session id (CLI backends) — enables resume + messaging. */
|
|
151
|
+
sessionId?: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A messageable CLI agent session on the mesh. Any run that used a CLI backend
|
|
155
|
+
* and captured a native session id becomes an addressable agent — live while
|
|
156
|
+
* running, dormant (but still resumable) once complete. Its address is
|
|
157
|
+
* `nodeId/sessionId`; sending a message resumes that exact CLI session.
|
|
158
|
+
*/
|
|
159
|
+
export interface AgentHandle {
|
|
160
|
+
/** runId that created/owns the session (the follow handle). */
|
|
161
|
+
agentId: string;
|
|
162
|
+
/** node holding the session. */
|
|
163
|
+
nodeId: string;
|
|
164
|
+
/** which CLI backend runs it — 'claude' | 'codex' | 'opencode'. */
|
|
165
|
+
backend: string;
|
|
166
|
+
/** the CLI's native session id (what resume targets). */
|
|
167
|
+
sessionId: string;
|
|
168
|
+
/** routable address: `${nodeId}/${sessionId}`. */
|
|
169
|
+
address: string;
|
|
170
|
+
status: 'running' | 'completed' | 'failed';
|
|
171
|
+
description: string;
|
|
172
|
+
lastActiveAt: number;
|
|
173
|
+
}
|
|
174
|
+
/** Result of a one-shot `infinicode maintain` run, pushed to an InfiniBot gateway. */
|
|
175
|
+
export interface MaintenanceReport {
|
|
176
|
+
nodeId: string;
|
|
177
|
+
role: string;
|
|
178
|
+
site?: string;
|
|
179
|
+
taskName: string;
|
|
180
|
+
startedAt: number;
|
|
181
|
+
finishedAt: number;
|
|
182
|
+
durationMs: number;
|
|
183
|
+
ok: boolean;
|
|
184
|
+
summary: string;
|
|
185
|
+
errors?: string[];
|
|
144
186
|
}
|
|
145
187
|
/** Persistent per-device role + server-architecture context. */
|
|
146
188
|
export interface RoleContext {
|
|
147
189
|
/** e.g. "RoboPanda" — the identity a spawned agent adopts. */
|
|
148
190
|
role: string;
|
|
191
|
+
/** Physical deployment/location this device belongs to (e.g. "Tel Aviv").
|
|
192
|
+
* Optional — a single-site deployment can leave it unset. */
|
|
193
|
+
site?: string;
|
|
149
194
|
/** Free-text description of this device's job + the wider architecture. */
|
|
150
195
|
architecture?: string;
|
|
151
196
|
/** Extra structured context injected into every task run on this node. */
|