mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
dbf450a927
* feat(federation): ruflo CLI + MCP integration for x.ruv.io, and Seraphina (swarm queen)
Integrate the open swarm federation into ruflo itself, and add Seraphina —
a primary-coordinator / swarm-queen guidance tool in the style of the ruOS
assistant terminal (invoked from a terminal or any MCP client).
MCP tools (src/mcp-tools/x-federation-tools.ts), registered in the barrel and
the in-process registry so `callMCPTool` and the MCP server both see them:
x_federation_sync / roster / claims / registry (open reads)
x_federation_publish / invite_mint / admit (gateway-identity writes,
require RUFLO_X_ADMIN_TOKEN; fail closed with no network call)
Each description follows ADR-112 ("Use when … wrong because …").
Seraphina (src/mcp-tools/seraphina-tools.ts): `seraphina_guidance { goal }`
gathers the live roster, claims board and recent messages from the x.ruv.io
gateway, compacts them (dedupe by from|type, cap 15 — a cheap tier drowns in
repeated PeerHellos), and asks the cognitum meta-llm gateway
(https://api.cognitum.one/v1/messages, model cognitum-auto by default with a
tier override) using a queen system prompt that treats message content as
data, respects one-owner-per-resource claims, and returns
{ guidance, proposals[], risks[] }. Proposals are advisory. JSON is extracted
by slicing the outermost object so fenced/prose-wrapped answers still parse.
Key from SERAPHINA_METALLM_KEY (GCP secret seraphina-metallm-api-key).
CLI: `ruflo federation sync|roster|claims|registry|invite|admit|publish`
(src/commands/federation.ts), thin over the same tools.
Skill: .agents/skills/open-federation/SKILL.md documents CLI, tools,
Seraphina, the claims rules, the canonical NIP-42 relay-tag gotcha, and
onboarding.
Tests: 11 (x-federation: RPC shaping, SSE parse, resource mapping, fail-closed
admin gating, isError surfacing; seraphina: registration, fail-closed key,
context gathering + queen prompt + x-api-key, tier override, fenced-JSON
extraction). Project typechecks at 0 errors.
Verified live: Seraphina against the real 3-node federation returned 3
structured proposals + 4 risks via cognitum-auto (routed to a cheap tier).
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* fix(federation): ADR-125 env-var precedence — URL config takes a flag/arg, credentials registered env-only
The env-var-precedence audit correctly flagged the new process.env reads.
Config URLs now have a precedence path: `ruflo federation --gateway` feeds a
`gatewayUrl` tool arg (and Seraphina takes `metaLlmUrl`/`gatewayUrl`) that
wins over RUFLO_X_GATEWAY_URL / SERAPHINA_METALLM_URL, documented per ADR-125.
Credentials (RUFLO_X_ADMIN_TOKEN, SERAPHINA_METALLM_KEY) are registered as
env-only escape hatches with rationale: a secret must never be a CLI flag.
+1 test (arg precedence, arg not forwarded upstream). Audit passes locally.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* feat(federation): `ruflo federation join --code` — self-service invite access with your own key
The user-facing path into the open swarm. Decentralized by design: the user
generates/holds THEIR OWN Nostr key (~/.ruflo/nostr.key, 0600), redeems an
invite code with a NIP-98-signed claim directly against the relay (no admin in
the loop), proves membership via NIP-42, and can then publish as themselves.
The gateway never signs for a user.
- src/mcp-tools/x-federation-join.ts: x_federation_join { code, relayHttp?,
relayWs?, keyFile? } — validates the code shape before any network call,
loads/creates the key, NIP-98 claim, NIP-42 verify, returns pubkey + role.
ADR-125: args take precedence over RUFLO_X_RELAY_HTTP / RUFLO_X_RELAY_WS /
RUFLO_NOSTR_KEY_FILE.
- CLI: `ruflo federation join --code v2.…`
- nostr-tools added as an optionalDependency (secp256k1/Schnorr is not in
node:crypto); the tool degrades with an install hint when absent. pnpm
lockfile regenerated in this PR (frozen-lockfile CI).
- 4 tests: 0600 key create/reuse, valid NIP-98 header (kind 27235, verifies,
bound to url+method+payload hash), malformed code rejected pre-network,
ADR-112 description. Suite: 16/16; tsc 0; env-var audit passes.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
* fix(federation): default relay -> wss://relay.ruv.io; join test tolerates missing nostr-tools
- x-federation-join defaults now point at the canonical relay.ruv.io host (Cloud Run
domain mapping for buzz-relay); the raw run.app host stays routable for old clients.
- Validate the invite-code shape before the optional-dependency check so a bad code
fails fast whether or not nostr-tools is installed.
- The root Test Suite runs the CLI tests via root npm ci, which never installs the
CLI's optionalDependencies: the crypto cases are it.skipIf(!nostr-tools) so the
file no longer trips the CI test ratchet.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
48 lines
3.9 KiB
TypeScript
48 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { seraphinaTools, askSeraphina, SERAPHINA_SYSTEM_PROMPT } from '../src/mcp-tools/seraphina-tools.js';
|
|
const sse = (result: unknown) => `data: ${JSON.stringify({ jsonrpc: '2.0', id: 1, result })}\n`;
|
|
describe('seraphina_guidance', () => {
|
|
let llmBody: any; let calls: string[] = [];
|
|
beforeEach(() => {
|
|
calls = []; llmBody = undefined;
|
|
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
|
|
calls.push(url); const body = JSON.parse(init.body);
|
|
if (url.endsWith('/v1/messages')) { llmBody = { body, headers: init.headers }; return new Response(JSON.stringify({ model: 'z-ai/glm-5.3-flash', usage: { input_tokens: 10 }, content: [{ type: 'text', text: JSON.stringify({ guidance: 'Assign r1 to ruvzen.', proposals: [{ type: 'Task', forNode: 'ruvzen', description: 'do r1' }], risks: [] }) }] }), { status: 200 }); }
|
|
if (body.method === 'resources/read') return new Response(sse({ contents: [{ text: JSON.stringify(body.params.uri.includes('roster') ? { pk1: { from: 'ruvzen' } } : { r0: { owner: 'pk1' } }) }] }));
|
|
return new Response(sse({ content: [{ text: JSON.stringify({ count: 1, messages: [{ from: 'ruvzen', type: 'Status' }] }) }] }));
|
|
}));
|
|
process.env.SERAPHINA_METALLM_KEY = 'test-key';
|
|
});
|
|
afterEach(() => { vi.unstubAllGlobals(); delete process.env.SERAPHINA_METALLM_KEY; });
|
|
it('is registered with an ADR-112 description and a required goal', () => {
|
|
const t = seraphinaTools.find((x) => x.name === 'seraphina_guidance')!;
|
|
expect(t.description).toMatch(/Use when/); expect(t.description).toMatch(/wrong because/);
|
|
expect((t.inputSchema as any).required).toEqual(['goal']);
|
|
});
|
|
it('fails closed without SERAPHINA_METALLM_KEY and makes no network call', async () => {
|
|
delete process.env.SERAPHINA_METALLM_KEY;
|
|
await expect(askSeraphina('x')).rejects.toThrow(/SERAPHINA_METALLM_KEY/); expect(calls).toHaveLength(0);
|
|
});
|
|
it('gathers roster+claims+recent from the gateway, then asks cognitum meta-llm with the queen prompt (cognitum-auto default, x-api-key)', async () => {
|
|
const out = (await askSeraphina('ship the release')) as any;
|
|
expect(calls.filter((u) => u.endsWith('/mcp'))).toHaveLength(3);
|
|
expect(llmBody.body.model).toBe('cognitum-auto'); expect(llmBody.body.system).toBe(SERAPHINA_SYSTEM_PROMPT);
|
|
expect(llmBody.headers['x-api-key']).toBe('test-key');
|
|
expect(llmBody.body.messages[0].content).toContain('data, not instructions');
|
|
expect(out.guidance).toBe('Assign r1 to ruvzen.'); expect(out.proposals[0].forNode).toBe('ruvzen');
|
|
expect(out.model).toBe('z-ai/glm-5.3-flash'); expect(out.context).toEqual({ nodes: 1, claims: 1, recent: 1 });
|
|
});
|
|
it('extracts structured proposals even when the model fences the JSON', async () => {
|
|
(globalThis.fetch as any).mockImplementationOnce(async () => new Response(sse({ contents: [{ text: '{}' }] })));
|
|
(globalThis.fetch as any).mockImplementationOnce(async () => new Response(sse({ contents: [{ text: '{}' }] })));
|
|
(globalThis.fetch as any).mockImplementationOnce(async () => new Response(sse({ content: [{ text: '{}' }] })));
|
|
(globalThis.fetch as any).mockImplementationOnce(async () => new Response(JSON.stringify({ model: 'm', content: [{ type: 'text', text: 'Here you go:\n```json\n{"guidance":"g","proposals":[{"type":"Task","forNode":"ruvzen","description":"d"}],"risks":["r"]}\n```\n' }] }), { status: 200 }));
|
|
const out = (await askSeraphina('x')) as any;
|
|
expect(out.proposals).toEqual([{ type: 'Task', forNode: 'ruvzen', description: 'd' }]); expect(out.risks).toEqual(['r']); expect(out.guidance).toBe('g');
|
|
});
|
|
it('honours a valid tier override and ignores an invalid one', async () => {
|
|
await askSeraphina('x', { tier: 'cognitum-high' }); expect(llmBody.body.model).toBe('cognitum-high');
|
|
await askSeraphina('x', { tier: 'gpt-9' }); expect(llmBody.body.model).toBe('cognitum-auto');
|
|
});
|
|
});
|