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.
Files changed (34) hide show
  1. package/bin/robopark.js +2 -0
  2. package/dist/cli.js +26 -1
  3. package/dist/commands/maintain.d.ts +29 -0
  4. package/dist/commands/maintain.js +186 -0
  5. package/dist/commands/mcp.js +1 -1
  6. package/dist/commands/robopark.d.ts +6 -0
  7. package/dist/commands/robopark.js +450 -0
  8. package/dist/commands/serve.d.ts +5 -0
  9. package/dist/commands/serve.js +170 -11
  10. package/dist/kernel/agents/backends/cli-backend.d.ts +4 -0
  11. package/dist/kernel/agents/backends/cli-backend.js +13 -5
  12. package/dist/kernel/agents/backends/detect.d.ts +12 -0
  13. package/dist/kernel/agents/backends/detect.js +102 -2
  14. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  15. package/dist/kernel/agents/backends/profiles.js +48 -0
  16. package/dist/kernel/agents/backends/registry.d.ts +2 -1
  17. package/dist/kernel/agents/backends/registry.js +39 -5
  18. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  19. package/dist/kernel/agents/backends/rotation.js +107 -0
  20. package/dist/kernel/agents/backends/types.d.ts +3 -1
  21. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  22. package/dist/kernel/federation/dashboard-html.js +561 -0
  23. package/dist/kernel/federation/federation.d.ts +75 -1
  24. package/dist/kernel/federation/federation.js +119 -23
  25. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  26. package/dist/kernel/federation/telemetry.d.ts +1 -1
  27. package/dist/kernel/federation/telemetry.js +2 -1
  28. package/dist/kernel/federation/transport-http.d.ts +34 -1
  29. package/dist/kernel/federation/transport-http.js +117 -4
  30. package/dist/kernel/federation/types.d.ts +20 -0
  31. package/dist/kernel/mcp/mcp-server.js +23 -7
  32. package/dist/robopark-cli.d.ts +2 -0
  33. package/dist/robopark-cli.js +23 -0
  34. 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 => this.server?.broadcast(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
- const backendId = task.agent && task.agent !== 'kernel' ? task.agent : null;
238
- rec.agent = backendId ?? 'kernel';
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 (backendId) {
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
- throw new Error(`backend not available on this node: ${backendId}`);
314
+ return { status: 'failed', error: `backend not available on this node: ${backendId}` };
269
315
  }
270
- const nodeId = this.deps.identity.nodeId;
271
- const localRole = loadRole();
272
- const preamble = rolePreamble(localRole);
273
- const res = await backend.run({ prompt: task.prompt, capabilities: task.capabilities, model: task.model, sessionId: task.sessionId }, {
274
- runId,
275
- rolePreamble: preamble,
276
- onFrame: f => {
277
- if (f.kind === 'log' && f.text) {
278
- this.server?.broadcast(frame('log', nodeId, { run: runId, agent: backendId, line: f.text }));
279
- }
280
- else {
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
- if (res.status === 'failed' && res.error)
290
- rec.error = res.error;
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;
@@ -17,6 +17,7 @@ export function toNodesStatus(statuses) {
17
17
  // extra infinicode fields are ignored by InfiniBot but useful to richer UIs
18
18
  role: s.role,
19
19
  load: s.load,
20
+ site: s.site,
20
21
  })),
21
22
  };
22
23
  }
@@ -18,4 +18,4 @@ export declare function selfNodeStatus(identity: NodeIdentity, hw: HardwareSnaps
18
18
  version: string;
19
19
  capabilities: NodeStatus['capabilities'];
20
20
  commands: string[];
21
- }): NodeStatus;
21
+ }, site?: string): NodeStatus;
@@ -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
- this.server = createServer((req, res) => this.handle(req, res));
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
- this.opts.logger.info(`[federation] mesh listening on ${this.opts.host ?? '0.0.0.0'}:${this.opts.port}`);
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 h = req.headers['authorization'];
58
- return typeof h === 'string' && h === `Bearer ${this.opts.token}`;
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. */