infinicode 2.3.6 → 2.5.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/bin/robopark.js +2 -0
- package/dist/cli.js +26 -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/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/serve.d.ts +5 -0
- package/dist/commands/serve.js +170 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +4 -0
- package/dist/kernel/agents/backends/cli-backend.js +13 -5
- package/dist/kernel/agents/backends/detect.d.ts +12 -0
- package/dist/kernel/agents/backends/detect.js +102 -2
- 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 +2 -1
- package/dist/kernel/agents/backends/registry.js +39 -5
- 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 +3 -1
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +561 -0
- package/dist/kernel/federation/federation.d.ts +75 -1
- package/dist/kernel/federation/federation.js +119 -23
- 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 +34 -1
- package/dist/kernel/federation/transport-http.js +117 -4
- package/dist/kernel/federation/types.d.ts +20 -0
- package/dist/kernel/mcp/mcp-server.js +23 -7
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +7 -3
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
* (heartbeat / task.dispatch / command / config / update).
|
|
12
12
|
*/
|
|
13
13
|
import type { Capability, KernelLike, Logger } from '../types.js';
|
|
14
|
-
import type { HardwareSnapshot, NodeIdentity, NodeStatus, PeerInfo, SharedConfig, StreamFrame } from './types.js';
|
|
14
|
+
import type { HardwareSnapshot, NodeIdentity, NodeManifest, NodeStatus, PeerInfo, SharedConfig, StreamFrame } from './types.js';
|
|
15
15
|
import { ConfigSync } from './config-sync.js';
|
|
16
16
|
import { AutoUpdate } from './auto-update.js';
|
|
17
17
|
import { ComputeRouter, type ComputeNeed } from './compute-router.js';
|
|
18
18
|
import { type ManifestInputs } from './node-identity.js';
|
|
19
19
|
import { type AgentHandle, type RoleContext, type RunRecord } from './types.js';
|
|
20
20
|
import { AgentBackendRegistry, type BackendId } from '../agents/index.js';
|
|
21
|
+
import { loadCoderConfig, type CoderConfig } from '../agents/backends/profiles.js';
|
|
21
22
|
export interface DispatchTask {
|
|
22
23
|
description: string;
|
|
23
24
|
capabilities: Capability[];
|
|
@@ -34,6 +35,14 @@ export interface DispatchTask {
|
|
|
34
35
|
sessionId?: string;
|
|
35
36
|
/** Model hint passed to a CLI backend (provider/model or a CLI-native id). */
|
|
36
37
|
model?: string;
|
|
38
|
+
/** Named coder profile to run (e.g. "deep", "cheap"). */
|
|
39
|
+
profile?: string;
|
|
40
|
+
/** Rotation strategy: 'auto' | 'tasktype' | 'policy' | 'roundrobin' | 'fallback'. */
|
|
41
|
+
strategy?: string;
|
|
42
|
+
/** Policy tier: 'free-first' | 'cheapest' | 'fastest' | 'highest-quality' | 'balanced'. */
|
|
43
|
+
policy?: string;
|
|
44
|
+
/** Task-type key for routing (e.g. 'refactor', 'docs', 'bulk'). */
|
|
45
|
+
taskType?: string;
|
|
37
46
|
}
|
|
38
47
|
export interface FederationOptions {
|
|
39
48
|
version: string;
|
|
@@ -51,6 +60,51 @@ export interface FederationOptions {
|
|
|
51
60
|
autoPullConfig?: boolean;
|
|
52
61
|
/** Satellite: how often to re-pull shared config (ms, default 30000; 0 disables). */
|
|
53
62
|
configPullMs?: number;
|
|
63
|
+
/** Coder profiles + rotation config (from node config `coder`). Merged over
|
|
64
|
+
* built-in defaults, so the mesh rotates across backends out of the box. */
|
|
65
|
+
coder?: Parameters<typeof loadCoderConfig>[0];
|
|
66
|
+
/** Serve the web control dashboard at GET / and /ui (off unless enabled). */
|
|
67
|
+
dashboard?: boolean;
|
|
68
|
+
/** Publish this node's shared config to the fleet (backs POST /fed/apply +
|
|
69
|
+
* the dashboard's "Apply config to fleet" button). */
|
|
70
|
+
onApply?: () => Promise<unknown> | unknown;
|
|
71
|
+
/** Gate remote/dashboard slash-commands. Returns {ok:false,reason} to reject —
|
|
72
|
+
* used to keep an unauthenticated, network-exposed node read-only. */
|
|
73
|
+
commandGate?: (input: string) => {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
reason?: string;
|
|
76
|
+
};
|
|
77
|
+
/** TLS material — when set, the node + dashboard are served over HTTPS. */
|
|
78
|
+
tls?: {
|
|
79
|
+
cert: Buffer;
|
|
80
|
+
key: Buffer;
|
|
81
|
+
};
|
|
82
|
+
/** RoboPark scheduler base URL — proxied under /robopark/* for the dashboard. */
|
|
83
|
+
schedulerUrl?: string;
|
|
84
|
+
/** Bearer token presented to the scheduler on proxied requests. */
|
|
85
|
+
schedulerToken?: string;
|
|
86
|
+
/** Refuse write actions through the scheduler proxy (read-only console). */
|
|
87
|
+
schedulerReadOnly?: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** Consolidated fleet view served at GET /fed/status. */
|
|
90
|
+
export interface FleetStatus {
|
|
91
|
+
self: NodeManifest;
|
|
92
|
+
role: RoleContext | null;
|
|
93
|
+
nodes: NodeStatus[];
|
|
94
|
+
hardware?: HardwareSnapshot;
|
|
95
|
+
runs: RunRecord[];
|
|
96
|
+
agents: AgentHandle[];
|
|
97
|
+
activity: Array<{
|
|
98
|
+
peerId: string;
|
|
99
|
+
f: StreamFrame;
|
|
100
|
+
}>;
|
|
101
|
+
/** Console capabilities for the dashboard: whether control writes are allowed
|
|
102
|
+
* on this node and whether a RoboPark scheduler is linked. */
|
|
103
|
+
control: {
|
|
104
|
+
readonly: boolean;
|
|
105
|
+
schedulerLinked: boolean;
|
|
106
|
+
};
|
|
107
|
+
ts: number;
|
|
54
108
|
}
|
|
55
109
|
export interface FederationDeps {
|
|
56
110
|
kernel: KernelLike;
|
|
@@ -83,6 +137,10 @@ export declare class Federation {
|
|
|
83
137
|
readonly compute: ComputeRouter;
|
|
84
138
|
/** Executors available on this node (native kernel + installed CLIs). */
|
|
85
139
|
readonly backends: AgentBackendRegistry;
|
|
140
|
+
/** Resolved coder profiles + rotation policy for this node. */
|
|
141
|
+
readonly coderConfig: CoderConfig;
|
|
142
|
+
/** Per-node round-robin cursor for backend rotation. */
|
|
143
|
+
private readonly rotationState;
|
|
86
144
|
/** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
|
|
87
145
|
private frameSubs;
|
|
88
146
|
/** Recent frames for catch-up. */
|
|
@@ -110,6 +168,20 @@ export declare class Federation {
|
|
|
110
168
|
* is captured on the RunRecord so the run can be followed and later messaged.
|
|
111
169
|
*/
|
|
112
170
|
private runViaBackend;
|
|
171
|
+
/** Stream one backend's frames up the mesh, tagged with the backend id. */
|
|
172
|
+
private backendFrameSink;
|
|
173
|
+
/** Run a single backend attempt. Never throws — a missing/unavailable backend
|
|
174
|
+
* returns a failed result so a rotation chain can fall through to the next. */
|
|
175
|
+
private runBackendOnce;
|
|
176
|
+
/** Write a backend result onto the run record. */
|
|
177
|
+
private writeResult;
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a rotation chain for the task and run it with fallback: try each
|
|
180
|
+
* profile's backend in order, streaming its activity; the first `completed`
|
|
181
|
+
* wins, otherwise fall through to the next. Composes task-type routing, policy
|
|
182
|
+
* tiers, round-robin and fallback (see agents/backends/rotation.ts).
|
|
183
|
+
*/
|
|
184
|
+
private runViaBackendChain;
|
|
113
185
|
/**
|
|
114
186
|
* Hand a task to the best-fit connected peer (compute-routed) or a named one.
|
|
115
187
|
* Non-blocking by default — returns a runId to follow. Pass wait to block
|
|
@@ -203,6 +275,8 @@ export declare class Federation {
|
|
|
203
275
|
/** Neutral node-status list (self + peers) — feeds the mesh map. */
|
|
204
276
|
nodeStatuses(): NodeStatus[];
|
|
205
277
|
peers(): PeerInfo[];
|
|
278
|
+
/** Consolidated fleet view for the dashboard + `robopark fleet` (GET /fed/status). */
|
|
279
|
+
statusSnapshot(): FleetStatus;
|
|
206
280
|
/** Latest local hardware snapshot (for the MOLTFED connector / panels). */
|
|
207
281
|
latestHardware(): HardwareSnapshot | undefined;
|
|
208
282
|
/** Subscribe to the merged mesh frame stream (local + peer frames). */
|
|
@@ -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';
|
|
@@ -12,6 +13,8 @@ import { discoverTailscalePeers } from './discovery.js';
|
|
|
12
13
|
import { LanBeacon } from './lan-discovery.js';
|
|
13
14
|
import { DEFAULT_MESH_PORT } from './types.js';
|
|
14
15
|
import { createDefaultBackendRegistry } from '../agents/index.js';
|
|
16
|
+
import { RotationState, selectChain } from '../agents/backends/rotation.js';
|
|
17
|
+
import { loadCoderConfig } from '../agents/backends/profiles.js';
|
|
15
18
|
export class Federation {
|
|
16
19
|
deps;
|
|
17
20
|
server;
|
|
@@ -33,6 +36,10 @@ export class Federation {
|
|
|
33
36
|
compute = new ComputeRouter();
|
|
34
37
|
/** Executors available on this node (native kernel + installed CLIs). */
|
|
35
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();
|
|
36
43
|
/** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
|
|
37
44
|
frameSubs = new Set();
|
|
38
45
|
/** Recent frames for catch-up. */
|
|
@@ -45,6 +52,7 @@ export class Federation {
|
|
|
45
52
|
this.deps = deps;
|
|
46
53
|
this.client = new MeshClient({ token: deps.options.token });
|
|
47
54
|
this.backends = deps.backends ?? createDefaultBackendRegistry();
|
|
55
|
+
this.coderConfig = loadCoderConfig(deps.options.coder);
|
|
48
56
|
this.configSync = new ConfigSync({ logger: deps.logger, apply: deps.options.applyConfig });
|
|
49
57
|
this.autoUpdate = new AutoUpdate({
|
|
50
58
|
version: deps.options.version,
|
|
@@ -65,7 +73,15 @@ export class Federation {
|
|
|
65
73
|
logger,
|
|
66
74
|
getManifest: () => buildManifest(identity, this.describeWithBackends()),
|
|
67
75
|
getNodes: () => this.nodeStatuses(),
|
|
76
|
+
getStatus: () => this.statusSnapshot(),
|
|
68
77
|
runCommand: (input) => this.deps.kernel.command(input),
|
|
78
|
+
commandGate: options.commandGate,
|
|
79
|
+
onApply: options.onApply,
|
|
80
|
+
dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
|
|
81
|
+
tls: options.tls,
|
|
82
|
+
schedulerUrl: options.schedulerUrl,
|
|
83
|
+
schedulerToken: options.schedulerToken,
|
|
84
|
+
schedulerReadOnly: options.schedulerReadOnly,
|
|
69
85
|
onRpc: (env, remote) => this.onRpc(env, remote),
|
|
70
86
|
});
|
|
71
87
|
await this.server.start();
|
|
@@ -81,9 +97,15 @@ export class Federation {
|
|
|
81
97
|
});
|
|
82
98
|
this.mesh.start();
|
|
83
99
|
// Bridge local kernel events → mesh stream (coalesced, animation-friendly).
|
|
100
|
+
// Also record into frameHistory so this node's own activity shows up in the
|
|
101
|
+
// /fed/status snapshot the dashboard + `robopark fleet` poll — not just in
|
|
102
|
+
// the live SSE push to connected harnesses.
|
|
84
103
|
this.bridge = new EventBridge({
|
|
85
104
|
nodeId: identity.nodeId,
|
|
86
|
-
emit: f =>
|
|
105
|
+
emit: f => {
|
|
106
|
+
this.server?.broadcast(f);
|
|
107
|
+
this.ingestFrame(identity.nodeId, f);
|
|
108
|
+
},
|
|
87
109
|
});
|
|
88
110
|
this.unsub = this.deps.kernel.subscribeAll(e => this.bridge?.handle(e));
|
|
89
111
|
// Telemetry heartbeat → stream a HardwareSnapshot on a fixed cadence.
|
|
@@ -234,11 +256,17 @@ export class Federation {
|
|
|
234
256
|
if (!rec)
|
|
235
257
|
return;
|
|
236
258
|
const nodeId = this.deps.identity.nodeId;
|
|
237
|
-
|
|
238
|
-
|
|
259
|
+
// Rotation is requested when a profile/strategy/route/policy is given, or the
|
|
260
|
+
// caller asks for 'auto'. Otherwise a concrete agent is a single fixed backend.
|
|
261
|
+
const wantsRotation = !!(task.profile || task.strategy || task.taskType || task.policy || task.agent === 'auto');
|
|
262
|
+
const backendId = task.agent && task.agent !== 'kernel' && task.agent !== 'auto' ? task.agent : null;
|
|
263
|
+
rec.agent = wantsRotation ? 'rotate' : (backendId ?? 'kernel');
|
|
239
264
|
this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_STARTED', run: runId, desc: task.description, agent: rec.agent }));
|
|
240
265
|
try {
|
|
241
|
-
if (
|
|
266
|
+
if (wantsRotation) {
|
|
267
|
+
await this.runViaBackendChain(runId, task, rec);
|
|
268
|
+
}
|
|
269
|
+
else if (backendId) {
|
|
242
270
|
await this.runViaBackend(runId, backendId, task, rec);
|
|
243
271
|
}
|
|
244
272
|
else {
|
|
@@ -263,31 +291,82 @@ export class Federation {
|
|
|
263
291
|
* is captured on the RunRecord so the run can be followed and later messaged.
|
|
264
292
|
*/
|
|
265
293
|
async runViaBackend(runId, backendId, task, rec) {
|
|
294
|
+
const res = await this.runBackendOnce(runId, backendId, task, task.model);
|
|
295
|
+
this.writeResult(rec, runId, backendId, res);
|
|
296
|
+
}
|
|
297
|
+
/** Stream one backend's frames up the mesh, tagged with the backend id. */
|
|
298
|
+
backendFrameSink(runId, backendId) {
|
|
299
|
+
const nodeId = this.deps.identity.nodeId;
|
|
300
|
+
return (f) => {
|
|
301
|
+
if (f.kind === 'log' && f.text) {
|
|
302
|
+
this.server?.broadcast(frame('log', nodeId, { run: runId, agent: backendId, line: f.text }));
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.server?.broadcast(frame('ev', nodeId, { k: 'AGENT_OUTPUT', run: runId, agent: backendId, kind: f.kind, text: f.text }));
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Run a single backend attempt. Never throws — a missing/unavailable backend
|
|
310
|
+
* returns a failed result so a rotation chain can fall through to the next. */
|
|
311
|
+
async runBackendOnce(runId, backendId, task, model, args) {
|
|
266
312
|
const backend = this.backends.get(backendId);
|
|
267
313
|
if (!backend || !backend.available()) {
|
|
268
|
-
|
|
314
|
+
return { status: 'failed', error: `backend not available on this node: ${backendId}` };
|
|
269
315
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
this.server?.broadcast(frame('ev', nodeId, { k: 'AGENT_OUTPUT', run: runId, agent: backendId, kind: f.kind, text: f.text }));
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
});
|
|
316
|
+
const preamble = rolePreamble(loadRole());
|
|
317
|
+
try {
|
|
318
|
+
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) });
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
return { status: 'failed', error: err instanceof Error ? err.message : String(err) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Write a backend result onto the run record. */
|
|
325
|
+
writeResult(rec, runId, backendId, res) {
|
|
326
|
+
rec.agent = backendId;
|
|
285
327
|
rec.status = res.status;
|
|
286
328
|
rec.sessionId = res.sessionId;
|
|
287
329
|
rec.missionId = res.sessionId ?? runId;
|
|
288
330
|
rec.outputs = [{ status: res.status, content: res.content, error: res.error }];
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
rec.error = res.status === 'failed' ? res.error : undefined;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Resolve a rotation chain for the task and run it with fallback: try each
|
|
335
|
+
* profile's backend in order, streaming its activity; the first `completed`
|
|
336
|
+
* wins, otherwise fall through to the next. Composes task-type routing, policy
|
|
337
|
+
* tiers, round-robin and fallback (see agents/backends/rotation.ts).
|
|
338
|
+
*/
|
|
339
|
+
async runViaBackendChain(runId, task, rec) {
|
|
340
|
+
const nodeId = this.deps.identity.nodeId;
|
|
341
|
+
const req = {
|
|
342
|
+
strategy: task.strategy,
|
|
343
|
+
profile: task.profile,
|
|
344
|
+
model: task.model,
|
|
345
|
+
taskType: task.taskType,
|
|
346
|
+
policy: task.policy,
|
|
347
|
+
backend: task.agent && task.agent !== 'auto' && task.agent !== 'kernel' ? task.agent : undefined,
|
|
348
|
+
};
|
|
349
|
+
const chain = selectChain(this.coderConfig, req, this.rotationState);
|
|
350
|
+
let last = { status: 'failed', error: 'no backend available in rotation chain' };
|
|
351
|
+
for (let i = 0; i < chain.length; i++) {
|
|
352
|
+
const step = chain[i];
|
|
353
|
+
this.server?.broadcast(frame('ev', nodeId, {
|
|
354
|
+
k: 'ROTATION_ATTEMPT', run: runId, agent: step.backend, model: step.model,
|
|
355
|
+
profile: step.profileName, attempt: i + 1, of: chain.length,
|
|
356
|
+
}));
|
|
357
|
+
last = await this.runBackendOnce(runId, step.backend, task, step.model, step.args);
|
|
358
|
+
if (last.status === 'completed') {
|
|
359
|
+
this.writeResult(rec, runId, step.backend, last);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (i < chain.length - 1) {
|
|
363
|
+
this.server?.broadcast(frame('ev', nodeId, {
|
|
364
|
+
k: 'ROTATION_FALLBACK', run: runId, from: step.backend,
|
|
365
|
+
next: chain[i + 1].backend, reason: (last.error ?? '').slice(0, 200),
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
this.writeResult(rec, runId, chain[chain.length - 1]?.backend ?? 'kernel', last);
|
|
291
370
|
}
|
|
292
371
|
// ── Outbound operations ──────────────────────────────────────────────────
|
|
293
372
|
/**
|
|
@@ -585,13 +664,30 @@ export class Federation {
|
|
|
585
664
|
version: d.version,
|
|
586
665
|
capabilities: d.capabilities,
|
|
587
666
|
commands: d.commands,
|
|
588
|
-
});
|
|
667
|
+
}, loadRole()?.site);
|
|
589
668
|
const peers = (this.mesh?.list() ?? []).map(nodeStatusFromPeer);
|
|
590
669
|
return [self, ...peers];
|
|
591
670
|
}
|
|
592
671
|
peers() {
|
|
593
672
|
return this.mesh?.list() ?? [];
|
|
594
673
|
}
|
|
674
|
+
/** Consolidated fleet view for the dashboard + `robopark fleet` (GET /fed/status). */
|
|
675
|
+
statusSnapshot() {
|
|
676
|
+
return {
|
|
677
|
+
self: buildManifest(this.deps.identity, this.describeWithBackends()),
|
|
678
|
+
role: this.getRole(),
|
|
679
|
+
nodes: this.nodeStatuses(),
|
|
680
|
+
hardware: this.lastHw,
|
|
681
|
+
runs: this.localRuns(),
|
|
682
|
+
agents: this.localAgents(),
|
|
683
|
+
activity: this.recentFrames(60),
|
|
684
|
+
control: {
|
|
685
|
+
readonly: Boolean(this.deps.options.schedulerReadOnly),
|
|
686
|
+
schedulerLinked: Boolean(this.deps.options.schedulerUrl),
|
|
687
|
+
},
|
|
688
|
+
ts: Date.now(),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
595
691
|
/** Latest local hardware snapshot (for the MOLTFED connector / panels). */
|
|
596
692
|
latestHardware() {
|
|
597
693
|
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,37 @@ 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;
|
|
28
|
+
/** Optional TLS — when set, the node serves HTTPS/WSS instead of plain HTTP. */
|
|
29
|
+
tls?: {
|
|
30
|
+
cert: Buffer;
|
|
31
|
+
key: Buffer;
|
|
32
|
+
};
|
|
33
|
+
/** Optional: base URL of the RoboPark scheduler. When set, the node proxies
|
|
34
|
+
* GET/POST /robopark/* to it so the dashboard can show sessions + telemetry
|
|
35
|
+
* (product data) alongside the mesh. */
|
|
36
|
+
schedulerUrl?: string;
|
|
37
|
+
/** Optional bearer token to present to the scheduler on proxied requests. */
|
|
38
|
+
schedulerToken?: string;
|
|
39
|
+
/** When true, only GET/HEAD are proxied to the scheduler — write actions
|
|
40
|
+
* (end-session, production_mode toggle, trigger) are refused. Set for a
|
|
41
|
+
* network-exposed, tokenless node so the operator console stays read-only. */
|
|
42
|
+
schedulerReadOnly?: boolean;
|
|
15
43
|
onRpc: RpcHandler;
|
|
16
44
|
logger: Logger;
|
|
17
45
|
}
|
|
@@ -23,8 +51,13 @@ export declare class MeshServer {
|
|
|
23
51
|
constructor(opts: MeshServerOptions);
|
|
24
52
|
get streamCount(): number;
|
|
25
53
|
start(): Promise<void>;
|
|
54
|
+
/** Proxy a dashboard request to the RoboPark scheduler (product data). */
|
|
55
|
+
private proxyToScheduler;
|
|
26
56
|
/** Push a frame to every connected stream subscriber. */
|
|
27
57
|
broadcast(frame: StreamFrame): void;
|
|
58
|
+
/** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
|
|
59
|
+
* (browsers, which can't set headers on navigation or EventSource). */
|
|
60
|
+
private presentedToken;
|
|
28
61
|
private authOk;
|
|
29
62
|
private handle;
|
|
30
63
|
private openStream;
|
|
@@ -14,7 +14,17 @@
|
|
|
14
14
|
* MeshClient/MeshServer shape later for deep InfiniBot-fabric interop.
|
|
15
15
|
*/
|
|
16
16
|
import { createServer } from 'node:http';
|
|
17
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
18
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
17
19
|
import { frameToSSE, parseSSE } from './protocol.js';
|
|
20
|
+
/** Constant-time string compare — avoids leaking the token via response timing. */
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
const ab = Buffer.from(a);
|
|
23
|
+
const bb = Buffer.from(b);
|
|
24
|
+
if (ab.length !== bb.length)
|
|
25
|
+
return false;
|
|
26
|
+
return timingSafeEqual(ab, bb);
|
|
27
|
+
}
|
|
18
28
|
export class MeshServer {
|
|
19
29
|
opts;
|
|
20
30
|
server;
|
|
@@ -27,7 +37,10 @@ export class MeshServer {
|
|
|
27
37
|
return this.sse.size;
|
|
28
38
|
}
|
|
29
39
|
async start() {
|
|
30
|
-
|
|
40
|
+
const handler = (req, res) => this.handle(req, res);
|
|
41
|
+
this.server = this.opts.tls
|
|
42
|
+
? createHttpsServer({ cert: this.opts.tls.cert, key: this.opts.tls.key }, handler)
|
|
43
|
+
: createServer(handler);
|
|
31
44
|
await new Promise((resolve, reject) => {
|
|
32
45
|
this.server.once('error', reject);
|
|
33
46
|
this.server.listen(this.opts.port, this.opts.host ?? '0.0.0.0', () => {
|
|
@@ -35,7 +48,34 @@ export class MeshServer {
|
|
|
35
48
|
resolve();
|
|
36
49
|
});
|
|
37
50
|
});
|
|
38
|
-
|
|
51
|
+
const scheme = this.opts.tls ? 'https' : 'http';
|
|
52
|
+
this.opts.logger.info(`[federation] mesh listening on ${scheme}://${this.opts.host ?? '0.0.0.0'}:${this.opts.port}`);
|
|
53
|
+
}
|
|
54
|
+
/** Proxy a dashboard request to the RoboPark scheduler (product data). */
|
|
55
|
+
proxyToScheduler(req, res, path) {
|
|
56
|
+
const base = this.opts.schedulerUrl.replace(/\/+$/, '');
|
|
57
|
+
// strip the /robopark prefix; forward the rest (incl. query) to the scheduler
|
|
58
|
+
const rest = path.replace(/^\/robopark/, '') || '/';
|
|
59
|
+
const target = `${base}${rest}`;
|
|
60
|
+
this.readBody(req)
|
|
61
|
+
.then(async (body) => {
|
|
62
|
+
const headers = { 'content-type': req.headers['content-type'] ?? 'application/json' };
|
|
63
|
+
if (this.opts.schedulerToken)
|
|
64
|
+
headers['authorization'] = `Bearer ${this.opts.schedulerToken}`;
|
|
65
|
+
const upstream = await fetch(target, {
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers,
|
|
68
|
+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
|
|
69
|
+
signal: AbortSignal.timeout(10_000),
|
|
70
|
+
});
|
|
71
|
+
const text = await upstream.text();
|
|
72
|
+
res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' });
|
|
73
|
+
res.end(text);
|
|
74
|
+
})
|
|
75
|
+
.catch(err => {
|
|
76
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
77
|
+
res.end(JSON.stringify({ ok: false, error: `scheduler unreachable: ${err instanceof Error ? err.message : String(err)}` }));
|
|
78
|
+
});
|
|
39
79
|
}
|
|
40
80
|
/** Push a frame to every connected stream subscriber. */
|
|
41
81
|
broadcast(frame) {
|
|
@@ -51,19 +91,86 @@ export class MeshServer {
|
|
|
51
91
|
}
|
|
52
92
|
}
|
|
53
93
|
}
|
|
94
|
+
/** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
|
|
95
|
+
* (browsers, which can't set headers on navigation or EventSource). */
|
|
96
|
+
presentedToken(req) {
|
|
97
|
+
const h = req.headers['authorization'];
|
|
98
|
+
if (typeof h === 'string' && h.startsWith('Bearer '))
|
|
99
|
+
return h.slice(7);
|
|
100
|
+
const url = req.url ?? '';
|
|
101
|
+
const q = url.indexOf('?');
|
|
102
|
+
if (q >= 0) {
|
|
103
|
+
const t = new URLSearchParams(url.slice(q + 1)).get('token');
|
|
104
|
+
if (t)
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
54
109
|
authOk(req) {
|
|
55
110
|
if (!this.opts.token)
|
|
56
111
|
return true;
|
|
57
|
-
const
|
|
58
|
-
return
|
|
112
|
+
const presented = this.presentedToken(req);
|
|
113
|
+
return presented !== undefined && safeEqual(presented, this.opts.token);
|
|
59
114
|
}
|
|
60
115
|
handle(req, res) {
|
|
61
116
|
const url = req.url ?? '/';
|
|
117
|
+
const path = url.split('?', 1)[0];
|
|
62
118
|
if (!this.authOk(req)) {
|
|
63
119
|
res.writeHead(401).end('unauthorized');
|
|
64
120
|
return;
|
|
65
121
|
}
|
|
66
122
|
const remote = req.socket.remoteAddress ?? 'unknown';
|
|
123
|
+
// Control dashboard (single self-contained page). Same-origin fetches carry
|
|
124
|
+
// the ?token= through, so no CORS is opened.
|
|
125
|
+
if (req.method === 'GET' && (path === '/' || path === '/ui' || path === '/dashboard')) {
|
|
126
|
+
if (!this.opts.dashboardHtml) {
|
|
127
|
+
res.writeHead(404).end('dashboard not enabled (start with --dashboard)');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
131
|
+
res.end(this.opts.dashboardHtml);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (req.method === 'GET' && path === '/fed/status') {
|
|
135
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
136
|
+
res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// RoboPark product data (sessions, telemetry, robots) — proxied to the
|
|
140
|
+
// scheduler so the dashboard is one pane of glass. Already token-gated above.
|
|
141
|
+
if (path.startsWith('/robopark/')) {
|
|
142
|
+
if (!this.opts.schedulerUrl) {
|
|
143
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
144
|
+
res.end(JSON.stringify({ ok: false, disabled: true, error: 'no scheduler linked — start with --scheduler-url' }));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Keep a network-exposed, tokenless node read-only: mesh writes are gated
|
|
148
|
+
// by commandGate; the scheduler proxy is the product-side equivalent.
|
|
149
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
150
|
+
if (this.opts.schedulerReadOnly && method !== 'GET' && method !== 'HEAD') {
|
|
151
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
152
|
+
res.end(JSON.stringify({ ok: false, readonly: true, error: 'this node is network-exposed without a token — control actions are disabled. Restart with --token <secret> to enable them.' }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.proxyToScheduler(req, res, path);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (req.method === 'POST' && path.startsWith('/fed/apply')) {
|
|
159
|
+
if (!this.opts.onApply) {
|
|
160
|
+
res.writeHead(404).end('no apply handler');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
Promise.resolve(this.opts.onApply())
|
|
164
|
+
.then(result => {
|
|
165
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
166
|
+
res.end(JSON.stringify(result ?? { ok: true }));
|
|
167
|
+
})
|
|
168
|
+
.catch(err => {
|
|
169
|
+
this.opts.logger.warn(`[federation] apply error: ${err instanceof Error ? err.message : String(err)}`);
|
|
170
|
+
res.writeHead(500).end('apply failed');
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
67
174
|
if (req.method === 'GET' && url.startsWith('/fed/manifest')) {
|
|
68
175
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
69
176
|
res.end(JSON.stringify(this.opts.getManifest()));
|
|
@@ -93,6 +200,12 @@ export class MeshServer {
|
|
|
93
200
|
res.writeHead(400).end('bad json');
|
|
94
201
|
return;
|
|
95
202
|
}
|
|
203
|
+
const gate = this.opts.commandGate?.(input);
|
|
204
|
+
if (gate && !gate.ok) {
|
|
205
|
+
res.writeHead(403, { 'content-type': 'application/json' });
|
|
206
|
+
res.end(JSON.stringify({ ok: false, text: gate.reason ?? 'command not permitted on this node' }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
96
209
|
const result = await this.opts.runCommand(input);
|
|
97
210
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
98
211
|
res.end(JSON.stringify(result ?? { ok: false, text: 'no result' }));
|
|
@@ -125,6 +125,10 @@ 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
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. */
|
|
@@ -167,10 +171,26 @@ export interface AgentHandle {
|
|
|
167
171
|
description: string;
|
|
168
172
|
lastActiveAt: number;
|
|
169
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[];
|
|
186
|
+
}
|
|
170
187
|
/** Persistent per-device role + server-architecture context. */
|
|
171
188
|
export interface RoleContext {
|
|
172
189
|
/** e.g. "RoboPanda" — the identity a spawned agent adopts. */
|
|
173
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;
|
|
174
194
|
/** Free-text description of this device's job + the wider architecture. */
|
|
175
195
|
architecture?: string;
|
|
176
196
|
/** Extra structured context injected into every task run on this node. */
|