docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies (#1229)

* docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies

ADR-263 — @ruvnet/ruview@0.1.0 harness review (O1–O9):
- HIGH: claim-check CLI fails open on empty input (no --text/--file -> PASS exit 0)
- HIGH: MCP stdio server head-of-line blocking (spawnSync verify/calibrate up to 600s)
- MEASURED: optionalDependencies triple the cold npx install (4 pkgs/620kB/71 files
  vs 1 pkg/172kB/22 files with --omit=optional) for a path that never imports them
- maxBuffer truncation, python -c port interpolation, version drift, duplicate skills,
  guardrail METRIC_TERMS substring false positives ('map'/'F1' — found by dogfooding
  claim-check on these very ADRs), zero CI

ADR-264 — @ruvnet/rvagent@0.1.0 + @ruv/ruview-cli review (O1–O9), verified against
the published registry tarball:
- HIGH: exports.require -> dist/index.cjs which is never built nor published
- MEASURED: 44 dead source-map files = 62,698B of the 188kB unpacked payload
- stdio-only server described as dual-transport; mixed dot/underscore tool names;
  double Zod validation + hand-duplicated advertised schemas; 2-fd leak per training
  job; unbounded body in the unwired HTTP scaffold; dead detectCogBinary candidates;
  ruview bin-name collision

ADR-265 — cross-cutting npm distribution strategy: npm-packages.yml CI matrix
(test + pack-content/size gate + tarball-install smoke test), publish-from-CI-only
with npm provenance, version single-sourcing from package.json, bin/namespace
ownership (ruview bin belongs to @ruvnet/ruview), claim-check on package READMEs.

Docs only — no runtime code changed. Index/CHANGELOG/CLAUDE.md/README counts updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate

ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
  ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
  (MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
  scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins

ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
  npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
  one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
  port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
  Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
  persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
  log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
  manifest suite); stdio handshake + HTTP session flow smoke-tested live

ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
  version-literal grep (D3), pack-content/size gate, tarball-install smoke
  test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
  npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
  @ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20

ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* perf(rvagent): lazy-load HTTP transport + memoize generated tool schemas

stdio time-to-first-response ~242ms -> ~189ms (-22%; MEASURED, median of
repeated initialize round-trips against dist/index.js in this container).

- ./http-transport.js now imported lazily inside the RVAGENT_HTTP_PORT
  branch: it chain-loads the MCP SDK streamableHttp module (~48ms MEASURED
  via per-module import() timing) which the default stdio path never uses
- toolInputJsonSchema memoized per tool: schemas are static for the process
  lifetime; under the session-per-server HTTP model every session calls
  tools/list, so stop re-walking the Zod tree each time

No behavior change: 99/99 jest tests; HTTP session flow re-smoke-tested
through the lazy import path (initialize -> 200 + mcp-session-id).

Profiled @ruvnet/ruview too and left it alone: 50ms CLI startup vs ~29ms
bare 'node -e ""' floor on the same box (MEASURED) — already near the
interpreter floor with zero dependencies.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* ci(ruview-cli): pass jest --passWithNoTests so the private no-test package doesn't fail the npm-packages matrix

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish

harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
  metric tokens followed by ':' or a nearby number are no longer scrubbed
  (fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
  (hardware/mutating tools never overlap) while ping/tools/list stay
  immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups

tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
  connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
  any-port convenience applies only with no configured allowlist);
  session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
  'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch

.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.

Tests: harness 30→36, rvagent 99→112, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
rUv
2026-07-02 13:11:15 -04:00
committed by GitHub
parent 1a0492992f
commit e6f26e9ac9
48 changed files with 2457 additions and 981 deletions
+30 -11
View File
@@ -8,6 +8,7 @@
import os from "node:os";
import path from "node:path";
import { existsSync } from "node:fs";
import type { RuviewConfig } from "./types.js";
function env(key: string): string | undefined {
@@ -51,17 +52,35 @@ export function loadConfig(): RuviewConfig {
}
/**
* Attempt to locate a cog binary on PATH or in common install locations.
* Returns the bare binary name if not found (will fail gracefully at invocation).
* Ordered cog-binary candidate paths for a host of the given CPU architecture.
* The native-arch build is probed FIRST: an appliance that ships both
* `cog-<id>-arm` and `cog-<id>-x86_64` must never hand back the wrong-arch
* binary (ADR-264 F8/O7 — the pre-review order tried `-arm` unconditionally).
* The `/usr/local/bin` and bare-name (PATH) fallbacks follow, arch-agnostic.
*
* Pure and arch-injectable so the ordering is unit-testable.
*/
export function cogBinaryCandidates(
name: string,
arch: string = process.arch
): string[] {
const id = name.replace("cog-", "");
const dir = `/var/lib/cognitum/apps/${id}`;
const arm = `${dir}/cog-${id}-arm`;
const x86 = `${dir}/cog-${id}-x86_64`;
// arm64 → prefer -arm; everything else (notably x64) → prefer -x86_64.
const archOrdered = arch === "arm64" ? [arm, x86] : [x86, arm];
return [...archOrdered, `/usr/local/bin/${name}`];
}
/**
* Locate a cog binary in the common appliance install locations, probing each
* candidate in native-arch-first order. Falls back to the bare name (PATH
* resolution at spawn time) when no candidate exists.
*/
function detectCogBinary(name: string): string {
// Common install paths for Cognitum cog binaries on Linux/macOS appliances.
const candidates = [
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-arm`,
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-x86_64`,
`/usr/local/bin/${name}`,
name, // bare name — rely on PATH
];
// Return the first candidate that might exist; actual existence is checked at call time.
return candidates[candidates.length - 1] ?? name;
for (const candidate of cogBinaryCandidates(name)) {
if (existsSync(candidate)) return candidate;
}
return name; // bare name — rely on PATH; spawn fails gracefully if absent
}
+242 -69
View File
@@ -1,30 +1,44 @@
/**
* Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3).
* Streamable HTTP transport for @ruvnet/rvagent (ADR-124 §3, hardened per
* ADR-264 F7/O3).
*
* Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by
* Binds to 127.0.0.1 by default and mounts an /mcp endpoint backed by
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
*
* Security model (ADR-124 §6):
* - Origin validation: requests from origins other than the configured
* allowlist are rejected with 403 Forbidden before reaching the MCP layer.
* - Default allowlist: ['http://localhost', 'http://127.0.0.1'] — covers
* Claude Code and Cursor on the same machine.
* Session model (ADR-264 F7): the SDK's stateful mode requires ONE transport
* (and one MCP Server) per session. An `initialize` POST creates a fresh
* transport + server pair via the caller-supplied factory; follow-up
* POST/GET/DELETE requests are routed to their session by the
* `mcp-session-id` header. Transports are dropped when their session closes.
*
* Security model (ADR-124 §6 + ADR-264 F7):
* - Origin validation: browser-style requests whose Origin is not local
* are rejected with 403 before reaching the MCP layer. With NO explicit
* allowlist, localhost origins match on hostname, ANY port
* (http://localhost:5173 is local). When an explicit allowedOrigins list is
* configured, matching is exact — the any-port-localhost convenience is off,
* so a localhost peer on an unlisted port must be added to be accepted.
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
* Authorization: Bearer <token>; missing/wrong tokens → 401.
* - Body cap: request bodies over 1 MiB are rejected with 413 (the
* unbounded-buffering DoS from the pre-ADR-264 scaffold).
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
*
* Usage:
* import { createHttpTransport } from './http-transport.js';
* const { server: httpServer, transport } = await createHttpTransport(mcpServer);
* const { httpServer } = await createHttpTransport(() => buildServer(config));
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
*/
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
export type McpServerFactory = () => McpServer;
export interface HttpTransportOptions {
/** TCP host to bind (default: 127.0.0.1). */
host?: string;
@@ -32,8 +46,8 @@ export interface HttpTransportOptions {
port?: number;
/**
* Allowed Origin header values. Requests with an Origin not in this list
* are rejected with 403. Use '*' to disable Origin validation entirely
* (not recommended outside of local-dev flags).
* (and not a localhost origin) are rejected with 403. Use '*' to disable
* Origin validation entirely (not recommended outside of local-dev flags).
*/
allowedOrigins?: string[];
/**
@@ -42,32 +56,51 @@ export interface HttpTransportOptions {
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
*/
bearerToken?: string;
/** Maximum accepted request body size in bytes (default: 1 MiB). */
maxBodyBytes?: number;
/**
* Maximum number of concurrent live sessions (default: 64). When a new
* `initialize` arrives at the cap, the oldest-idle session is evicted (its
* transport closed) to make room — bounds memory against a flaky client that
* loops `initialize` or a malicious localhost peer (ADR-264 F7).
*/
maxSessions?: number;
/**
* Idle time-to-live for a session in ms (default: 5 min). Sessions with no
* request activity for longer than this are swept and closed.
*/
sessionIdleMs?: number;
/** How often the idle-session sweeper runs, in ms (default: 60 s). */
sweepIntervalMs?: number;
}
export interface HttpTransportResult {
/** The raw Node.js HTTP server — call .close() to shut down. */
httpServer: HttpServer;
/** The MCP Streamable HTTP transport instance wired to the MCP server. */
transport: StreamableHTTPServerTransport;
/** Live sessions keyed by session id (exposed for tests/observability). */
sessions: Map<string, StreamableHTTPServerTransport>;
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
boundAddress: string;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3001;
const LOCALHOST_ORIGINS = new Set([
"http://localhost",
"http://127.0.0.1",
"https://localhost",
"https://127.0.0.1",
]);
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_SESSIONS = 64;
const DEFAULT_SESSION_IDLE_MS = 5 * 60 * 1000;
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
/**
* Validate Origin header against the allowlist.
* Returns true if the request should be allowed, false if it should be rejected.
*
* An absent Origin header is allowed (same-origin non-browser requests, curl, etc.).
* A present Origin that is not in the allowlist is rejected.
* An absent Origin header is allowed (same-origin non-browser requests, curl,
* etc.). When NO explicit allowlist was configured (empty list), a localhost
* origin is allowed on any port as a convenience — real browser origins carry
* ports (ADR-264 F7). When an explicit allowlist IS configured, matching is
* exact: the any-port-localhost shortcut is disabled so an operator who pins an
* allowlist actually gets it (a looped-back peer on an unlisted port is denied).
*/
export function isOriginAllowed(
origin: string | undefined,
@@ -75,76 +108,222 @@ export function isOriginAllowed(
): boolean {
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
if (allowedOrigins.includes("*")) return true;
return allowedOrigins.some((o) => o === origin);
if (allowedOrigins.includes(origin)) return true;
// Explicit allowlist ⇒ exact matching only; skip the localhost convenience.
if (allowedOrigins.length > 0) return false;
try {
const u = new URL(origin);
return (
(u.protocol === "http:" || u.protocol === "https:") &&
LOCAL_HOSTNAMES.has(u.hostname === "::1" ? "[::1]" : u.hostname)
);
} catch {
return false;
}
}
/** Read a request body with a hard size cap; null = payload too large. */
function readBody(
req: IncomingMessage,
maxBytes: number
): Promise<string | null> {
return new Promise((resolve, reject) => {
let size = 0;
let tooLarge = false;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
if (tooLarge) return; // keep draining so the 413 response can flush
size += chunk.length;
if (size > maxBytes) {
tooLarge = true;
chunks.length = 0;
resolve(null);
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
function json(res: ServerResponse, status: number, body: object): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
/**
* Build and wire a Streamable HTTP transport to the provided MCP server.
* Returns the Node.js HTTP server (not yet listening) plus the transport.
* Build the HTTP server around a per-session MCP transport map.
* Returns the Node.js HTTP server (not yet listening) plus the session map.
* Call httpServer.listen(port, host) or rely on createHttpTransport which
* does that for you.
*/
export function buildHttpApp(
mcpServer: McpServer,
serverFactory: McpServerFactory,
opts: HttpTransportOptions = {}
): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [
...LOCALHOST_ORIGINS,
];
): { httpServer: HttpServer; sessions: Map<string, StreamableHTTPServerTransport> } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [];
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
const sessionIdleMs = opts.sessionIdleMs ?? DEFAULT_SESSION_IDLE_MS;
const sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
const sessions = new Map<string, StreamableHTTPServerTransport>();
// lastSeen tracks per-session request activity so the sweeper and the
// oldest-idle eviction can bound the session map (ADR-264 F7).
const lastSeen = new Map<string, number>();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
/** Mark a session as freshly used. */
function touch(sessionId: string): void {
lastSeen.set(sessionId, Date.now());
}
const httpServer = createServer(
(req: IncomingMessage, res: ServerResponse) => {
// ── Origin validation ────────────────────────────────────────────────
/** Close a session's transport and drop it from the bookkeeping maps. */
function closeSession(id: string): void {
const transport = sessions.get(id);
sessions.delete(id);
lastSeen.delete(id);
if (transport) {
try {
void transport.close(); // onclose is idempotent against the maps above
} catch {
/* best-effort: a half-open transport must not block eviction */
}
}
}
/** Evict the session that has been idle longest — called when at capacity. */
function evictOldestIdle(): void {
let oldestId: string | undefined;
let oldestSeen = Infinity;
for (const [id, seen] of lastSeen) {
if (seen < oldestSeen) {
oldestSeen = seen;
oldestId = id;
}
}
if (oldestId !== undefined) closeSession(oldestId);
}
/** Periodic sweep: close sessions idle beyond sessionIdleMs. */
function sweepIdleSessions(): void {
const now = Date.now();
for (const [id, seen] of lastSeen) {
if (now - seen > sessionIdleMs) closeSession(id);
}
}
const sweepTimer = setInterval(sweepIdleSessions, sweepIntervalMs);
sweepTimer.unref(); // never keep the process alive just to sweep
const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
// ── Origin validation ──────────────────────────────────────────────
const origin = req.headers["origin"] as string | undefined;
if (!isOriginAllowed(origin, allowedOrigins)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" }));
json(res, 403, { error: "Forbidden: cross-origin request rejected" });
return;
}
// ── Bearer token auth ────────────────────────────────────────────────
// ── Bearer token auth ──────────────────────────────────────────────
if (bearerToken !== undefined && bearerToken !== "") {
const authHeader = req.headers["authorization"] as string | undefined;
const supplied = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: undefined;
if (supplied !== bearerToken) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
json(res, 401, { error: "Unauthorized: missing or invalid bearer token" });
return;
}
}
// ── Route: POST /mcp ─────────────────────────────────────────────────
if (req.method === "POST" && req.url === "/mcp") {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" }));
return;
}
void transport.handleRequest(req, res, parsed);
});
// ── Route: /mcp ────────────────────────────────────────────────────
if (req.url !== "/mcp") {
json(res, 404, { error: "Not found. MCP endpoint: /mcp" });
return;
}
// ── Fallback ─────────────────────────────────────────────────────────
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" }));
}
);
const sessionId = req.headers["mcp-session-id"] as string | undefined;
return { httpServer, transport };
if (req.method === "POST") {
const body = await readBody(req, maxBodyBytes);
if (body === null) {
json(res, 413, { error: `Payload too large (max ${maxBodyBytes} bytes)` });
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
json(res, 400, { error: "Bad Request: invalid JSON body" });
return;
}
// Existing session → route to its transport.
if (sessionId !== undefined) {
const transport = sessions.get(sessionId);
if (!transport) {
json(res, 404, { error: `Unknown session "${sessionId}"` });
return;
}
touch(sessionId);
await transport.handleRequest(req, res, parsed);
return;
}
// New session: must be an initialize request (ADR-264 F7 — one
// transport + one MCP Server per session).
if (!isInitializeRequest(parsed)) {
json(res, 400, {
error: "Bad Request: no mcp-session-id and not an initialize request",
});
return;
}
// Bound the session map: at capacity, reclaim the oldest-idle slot
// before minting a new session (ADR-264 F7).
if (sessions.size >= maxSessions) evictOldestIdle();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
sessions.set(id, transport);
touch(id);
},
});
transport.onclose = () => {
if (transport.sessionId !== undefined) {
sessions.delete(transport.sessionId);
lastSeen.delete(transport.sessionId);
}
};
const mcpServer = serverFactory();
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
await transport.handleRequest(req, res, parsed);
return;
}
// GET (SSE stream) / DELETE (session termination) — session-scoped.
if (req.method === "GET" || req.method === "DELETE") {
const transport = sessionId !== undefined ? sessions.get(sessionId) : undefined;
if (!transport) {
json(res, 400, { error: "Bad Request: missing or unknown mcp-session-id" });
return;
}
if (sessionId !== undefined) touch(sessionId);
await transport.handleRequest(req, res);
return;
}
json(res, 405, { error: "Method not allowed. Use POST/GET/DELETE on /mcp" });
})().catch(() => {
if (!res.headersSent) json(res, 500, { error: "Internal server error" });
else res.end();
});
});
httpServer.on("close", () => clearInterval(sweepTimer));
return { httpServer, sessions };
}
/**
@@ -152,19 +331,13 @@ export function buildHttpApp(
* is bound and listening.
*/
export async function createHttpTransport(
mcpServer: McpServer,
serverFactory: McpServerFactory,
opts: HttpTransportOptions = {}
): Promise<HttpTransportResult> {
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
const { httpServer, transport } = buildHttpApp(mcpServer, opts);
// Wire MCP server to the transport only after the HTTP server is built.
// Cast needed: StreamableHTTPServerTransport implements Transport but
// exactOptionalPropertyTypes causes a false incompatibility on optional
// callback properties; the cast is safe — the SDK types are consistent.
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
const { httpServer, sessions } = buildHttpApp(serverFactory, opts);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
@@ -173,7 +346,7 @@ export async function createHttpTransport(
return {
httpServer,
transport,
sessions,
boundAddress: `http://${host}:${port}`,
};
}
+197 -269
View File
@@ -1,29 +1,39 @@
#!/usr/bin/env node
/**
* @ruv/ruview-mcp — RuView MCP Server
* @ruvnet/rvagent — RuView MCP Server
*
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
* can call directly.
*
* Tools exposed:
* ruview_csi_latest — pull the latest CSI window from the sensing-server
* ruview_pose_infer — single-shot 17-keypoint pose estimation
* ruview_count_infer — single-shot person count with confidence interval
* ruview_registry_list — list cogs from the Cognitum edge registry (ADR-102)
* ruview_train_count — kick off a count-cog training run (returns job ID)
* ruview_job_status — poll a background training job
* Transports (ADR-264 O3):
* stdio (default) node dist/index.js
* Streamable HTTP RVAGENT_HTTP_PORT=3001 node dist/index.js
* (127.0.0.1-bound, Origin-gated, optional bearer token —
* see http-transport.ts for the security model)
*
* Usage:
* node dist/index.js # stdio transport (default)
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 node dist/index.js
* Tool naming (ADR-264 O4): canonical names are underscore-form
* (host tool-name regexes commonly enforce ^[a-zA-Z0-9_-]{1,64}$). The
* pre-ADR-264 dotted names (ruview.bfld.last_scan, …) remain callable as
* router-only aliases for one deprecation cycle; tools/list advertises the
* underscore form only.
*
* Validation (ADR-264 O5): each tool declares ONE Zod schema. The CallTool
* gate parses exactly once and hands the typed result to the handler; the
* advertised JSON Schema is generated from the same Zod source, so what is
* advertised is what is enforced.
*
* To register with Claude Code:
* claude mcp add ruview -- node /path/to/tools/ruview-mcp/dist/index.js
* claude mcp add ruview -- npx -y @ruvnet/rvagent
*
* See ADR-104 for the full design rationale and security model.
* See ADR-104 for the original design rationale and ADR-264 for the npm
* deep-review this layout implements.
*/
import { createRequire } from "node:module";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { argv } from "node:process";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
@@ -32,6 +42,8 @@ import {
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import type { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { loadConfig } from "./config.js";
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
@@ -44,40 +56,51 @@ import {
jobStatusSchema,
jobStatus,
} from "./tools/train-count.js";
import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js";
import { bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNow } from "./tools/presence-now.js";
import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js";
import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAll } from "./tools/vitals-get-all.js";
import { bfldLastScanSchema, bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribeSchema, bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNowSchema, presenceNow } from "./tools/presence-now.js";
import {
vitalsGetBreathingSchema,
vitalsGetBreathing,
} from "./tools/vitals-get-breathing.js";
import {
vitalsGetHeartRateSchema,
vitalsGetHeartRate,
} from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAllSchema, vitalsGetAll } from "./tools/vitals-get-all.js";
// NOTE: ./http-transport.js is imported lazily in main() — it chain-loads the
// SDK's streamableHttp module (~48 ms MEASURED), which the default stdio path
// never uses.
const PACKAGE_VERSION = "0.1.0";
// Single-source the version from package.json (ADR-264 O8/ADR-265 D3).
const require = createRequire(import.meta.url);
const PACKAGE_VERSION: string = (
require("../package.json") as { version: string }
).version;
const SERVER_NAME = "rvagent";
// ── Tool registry ──────────────────────────────────────────────────────────
const TOOLS = [
type RuviewConfig = ReturnType<typeof loadConfig>;
interface ToolDef {
name: string;
description: string;
/** The single validation source; the advertised JSON Schema derives from it. */
schema: z.ZodTypeAny;
handler: (parsedArgs: unknown, config: RuviewConfig) => Promise<object>;
}
export const TOOLS: ToolDef[] = [
{
name: "ruview_csi_latest",
description:
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
"downstream inference or research analysis.",
inputSchema: {
type: "object" as const,
properties: {
sensing_server_url: {
type: "string",
description:
"Base URL of the sensing-server (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = csiLatestSchema.parse(args);
return csiLatest(input, config);
},
schema: csiLatestSchema,
handler: (args, config) =>
csiLatest(args as Parameters<typeof csiLatest>[0], config),
},
{
name: "ruview_pose_infer",
@@ -86,23 +109,9 @@ const TOOLS = [
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
"or uses the live sensing-server if no window is provided. " +
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-pose-estimation binary.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = poseInferSchema.parse(args);
return poseInfer(input, config);
},
schema: poseInferSchema,
handler: (args, config) =>
poseInfer(args as Parameters<typeof poseInfer>[0], config),
},
{
name: "ruview_count_infer",
@@ -110,29 +119,9 @@ const TOOLS = [
"Run a single-shot person-count inference using the cog-person-count Cog " +
"binary (ADR-103). Returns {count, confidence, count_p95_low, count_p95_high} " +
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-person-count binary.",
},
max_persons: {
type: "integer",
minimum: 1,
maximum: 7,
description: "Upper bound on person count (17). Default: 7.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = countInferSchema.parse(args);
return countInfer(input, config);
},
schema: countInferSchema,
handler: (args, config) =>
countInfer(args as Parameters<typeof countInfer>[0], config),
},
{
name: "ruview_registry_list",
@@ -140,33 +129,9 @@ const TOOLS = [
"List cogs from the Cognitum edge module registry (ADR-102). " +
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
inputSchema: {
type: "object" as const,
properties: {
category: {
type: "string",
description:
"Filter by category: health, security, building, retail, industrial, " +
"research, ai, swarm, signal, network, developer.",
},
search: {
type: "string",
description: "Search substring matched against cog id and name (case-insensitive).",
},
refresh: {
type: "boolean",
description: "Bypass the 1-hour registry cache.",
},
sensing_server_url: {
type: "string",
description: "Override the sensing-server URL.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = registryListSchema.parse(args);
return registryList(input, config);
},
schema: registryListSchema,
handler: (args, config) =>
registryList(args as Parameters<typeof registryList>[0], config),
},
{
name: "ruview_train_count",
@@ -174,211 +139,139 @@ const TOOLS = [
"Kick off a cog-person-count training run using the Candle GPU trainer " +
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
"person-count labels. Returns a job_id to poll with ruview_job_status.",
inputSchema: {
type: "object" as const,
required: ["paired_jsonl"],
properties: {
paired_jsonl: {
type: "string",
description:
"Path to the paired JSONL training file (produced by scripts/align-ground-truth.js).",
},
epochs: {
type: "integer",
minimum: 1,
maximum: 10000,
description: "Training epochs (default: 400).",
},
learning_rate: {
type: "number",
description: "Initial learning rate (default: 0.001).",
},
output_dir: {
type: "string",
description:
"Directory for model artifacts (default: v2/crates/cog-person-count/cog/artifacts/).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = trainCountSchema.parse(args);
return trainCount(input, config);
},
schema: trainCountSchema,
handler: (args, config) =>
trainCount(args as Parameters<typeof trainCount>[0], config),
},
{
name: "ruview_job_status",
description:
"Poll the status of a background training job started by ruview_train_count. " +
"Returns {status, epochs_done, epochs_total, recent_log} for the given job_id.",
inputSchema: {
type: "object" as const,
required: ["job_id"],
properties: {
job_id: {
type: "string",
description: "UUID returned by ruview_train_count.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = jobStatusSchema.parse(args);
return jobStatus(input, config);
},
schema: jobStatusSchema,
handler: (args, config) =>
jobStatus(args as Parameters<typeof jobStatus>[0], config),
},
// ── ADR-124 BFLD tools (Phase 4 Refinement) ─────────────────────────────
// ── ADR-124 BFLD tools (Phase 4 Refinement; underscore names per ADR-264)
{
name: "ruview.bfld.last_scan",
name: "ruview_bfld_last_scan",
description:
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
inputSchema: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config);
},
schema: bfldLastScanSchema,
handler: (args, config) =>
bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config),
},
{
name: "ruview.bfld.subscribe",
name: "ruview_bfld_subscribe",
description:
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
"a network error from an invalid request.",
inputSchema: {
type: "object" as const,
required: ["duration_s"],
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
duration_s: {
type: "number",
minimum: 0,
maximum: 3600,
description: "Subscription duration in seconds (max 3600).",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config);
},
schema: bfldSubscribeSchema,
handler: (args, config) =>
bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config),
},
// ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ──────────
// ── ADR-124 Presence + Vitals tools ───────────────────────────────────────
{
name: "ruview.presence.now",
name: "ruview_presence_now",
description:
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: presenceNowSchema,
handler: (args, config) =>
presenceNow(args as Parameters<typeof presenceNow>[0], config),
},
{
name: "ruview.vitals.get_breathing",
name: "ruview_vitals_get_breathing",
description:
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetBreathingSchema,
handler: (args, config) =>
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
},
{
name: "ruview.vitals.get_heart_rate",
name: "ruview_vitals_get_heart_rate",
description:
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetHeartRateSchema,
handler: (args, config) =>
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
},
{
name: "ruview.vitals.get_all",
name: "ruview_vitals_get_all",
description:
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
"Full surface of ws.py:74-88.",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetAllSchema,
handler: (args, config) =>
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
},
] as const;
];
// ── Server bootstrap ────────────────────────────────────────────────────────
/**
* Pre-ADR-264 dotted tool names, accepted at call time for one deprecation
* cycle. Router-only: tools/list never advertises these.
*/
export const TOOL_ALIASES: Record<string, string> = {
"ruview.bfld.last_scan": "ruview_bfld_last_scan",
"ruview.bfld.subscribe": "ruview_bfld_subscribe",
"ruview.presence.now": "ruview_presence_now",
"ruview.vitals.get_breathing": "ruview_vitals_get_breathing",
"ruview.vitals.get_heart_rate": "ruview_vitals_get_heart_rate",
"ruview.vitals.get_all": "ruview_vitals_get_all",
};
async function main(): Promise<void> {
const config = loadConfig();
/**
* Advertised JSON Schema, generated from the Zod source (ADR-264 O5).
* Memoized: schemas are static for the process lifetime, and tools/list is
* called once per session (per HTTP session under the session-per-server
* model) — no point re-walking the Zod tree each time.
*/
const jsonSchemaCache = new Map<string, object>();
export function toolInputJsonSchema(def: ToolDef): object {
const cached = jsonSchemaCache.get(def.name);
if (cached !== undefined) return cached;
const raw = zodToJsonSchema(def.schema, { $refStrategy: "none" }) as Record<
string,
unknown
>;
delete raw["$schema"];
jsonSchemaCache.set(def.name, raw);
return raw;
}
// ── Server factory ──────────────────────────────────────────────────────────
/**
* Build a fully-wired MCP Server. A factory (not a singleton) because each
* Streamable-HTTP session needs its own Server instance (ADR-264 F7/O3).
*/
export function buildServer(config: RuviewConfig = loadConfig()): Server {
const server = new Server(
{
name: SERVER_NAME,
version: PACKAGE_VERSION,
},
{
capabilities: {
tools: {},
},
}
{ name: SERVER_NAME, version: PACKAGE_VERSION },
{ capabilities: { tools: {} } }
);
// List tools handler.
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
inputSchema: toolInputJsonSchema(t),
})),
}));
// Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture).
// If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first.
// Parse failures throw McpError(InvalidParams) so the client sees a typed
// JSON-RPC error rather than a wrapped string error.
// Call tool handler — the SINGLE Zod validation gate (ADR-264 O5): parse
// once, hand the typed result (with defaults applied) to the handler.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const { name: rawName, arguments: args } = request.params;
const name = TOOL_ALIASES[rawName] ?? rawName;
const tool = TOOLS.find((t) => t.name === name);
if (!tool) {
@@ -388,7 +281,7 @@ async function main(): Promise<void> {
type: "text" as const,
text: JSON.stringify({
ok: false,
error: `Unknown tool "${name}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
error: `Unknown tool "${rawName}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
}),
},
],
@@ -396,22 +289,16 @@ async function main(): Promise<void> {
};
}
// Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS.
const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)
? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS]
: undefined;
if (schemaEntry !== undefined) {
const parsed = schemaEntry.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${name}": ${parsed.error.message}`
);
}
const parsed = tool.schema.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${rawName}": ${parsed.error.message}`
);
}
try {
const result = await tool.handler(args ?? {}, config);
const result = await tool.handler(parsed.data, config);
return {
content: [
{
@@ -438,18 +325,59 @@ async function main(): Promise<void> {
}
});
// Wire up stdio transport.
return server;
}
// ── Server bootstrap ────────────────────────────────────────────────────────
async function main(): Promise<void> {
const config = loadConfig();
// stdio transport (default, always on).
const stdioServer = buildServer(config);
const transport = new StdioServerTransport();
await server.connect(transport);
await stdioServer.connect(transport);
// Streamable HTTP transport — explicit opt-in only (ADR-264 O3). Lazily
// imported so the stdio path never pays the streamableHttp load cost.
const httpPort = process.env["RVAGENT_HTTP_PORT"];
let httpNote = "";
if (httpPort !== undefined && httpPort !== "") {
const { createHttpTransport } = await import("./http-transport.js");
const { boundAddress } = await createHttpTransport(
() => buildServer(config),
{ port: Number(httpPort) }
);
httpNote = ` HTTP: ${boundAddress}/mcp.`;
}
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
process.stderr.write(
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
`Sensing server: ${config.sensingServerUrl}\n`
`Sensing server: ${config.sensingServerUrl}.${httpNote}\n`
);
}
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
// CLI guard: boot the server only when this module is the entrypoint — invoked
// as the `rvagent` / `ruview-mcp` bin or `node dist/index.js`. Importing it as a
// library (`import { buildServer } from "@ruvnet/rvagent"`) must NOT side-effect
// connect a StdioServerTransport to the consumer's stdin/stdout. Realpath both
// sides because npm's bin shim is a symlink and passes a non-normalized,
// possibly case-skewed argv[1] on Windows (mirrors harness/ruview/bin/cli.js).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch {
return false;
}
})();
if (invokedDirectly) {
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
}
+160 -31
View File
@@ -17,7 +17,16 @@
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { mkdirSync, appendFileSync, openSync } from "node:fs";
import {
mkdirSync,
appendFileSync,
openSync,
closeSync,
readFileSync,
writeFileSync,
statSync,
readSync,
} from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
@@ -66,17 +75,101 @@ export const jobStatusSchema = z.object({
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
// In-process job registry (survives for the lifetime of the MCP server process).
// For a production implementation, persist to ~/.ruview/jobs/<id>.json.
const jobRegistry = new Map<
string,
{
status: "queued" | "running" | "done" | "failed";
log_path: string;
queued_at: number;
epochs_total: number;
interface JobRecord {
status: "queued" | "running" | "done" | "failed" | "unknown";
log_path: string;
queued_at: number;
epochs_total: number;
/**
* OS pid of the training child. Persisted so a later process (e.g. after an
* MCP server restart) can tell whether a job still marked 'running' actually
* outlived the process that spawned it (ADR-264 O6).
*/
pid?: number | undefined;
/** Human-readable explanation attached during reconciliation (unknown state). */
reason?: string | undefined;
}
// In-process job registry, mirrored to <jobsDir>/<id>.json on every state
// change so ruview_job_status survives an MCP server restart (ADR-264 O6).
const jobRegistry = new Map<string, JobRecord>();
function jobRecordPath(jobsDir: string, jobId: string): string {
return path.join(jobsDir, `${jobId}.json`);
}
function persistJob(jobsDir: string, jobId: string, record: JobRecord): void {
try {
writeFileSync(
jobRecordPath(jobsDir, jobId),
JSON.stringify({ job_id: jobId, ...record }, null, 2)
);
} catch {
// Persistence is best-effort; the in-memory record still serves this process.
}
>();
}
function loadPersistedJob(jobsDir: string, jobId: string): JobRecord | undefined {
try {
const raw = JSON.parse(readFileSync(jobRecordPath(jobsDir, jobId), "utf8")) as
Partial<JobRecord>;
if (typeof raw.log_path !== "string" || typeof raw.status !== "string") {
return undefined;
}
return {
status: raw.status,
log_path: raw.log_path,
queued_at: typeof raw.queued_at === "number" ? raw.queued_at : 0,
epochs_total: typeof raw.epochs_total === "number" ? raw.epochs_total : 0,
pid: typeof raw.pid === "number" ? raw.pid : undefined,
reason: typeof raw.reason === "string" ? raw.reason : undefined,
};
} catch {
return undefined;
}
}
/**
* Is `pid` still a live process? `process.kill(pid, 0)` sends no signal but
* probes existence: ESRCH ⇒ gone; EPERM ⇒ alive but owned by another user
* (treated as alive so we never falsely reconcile a still-running job).
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* Scan log lines (tail) for the "# exit code: N" marker the child.on('close')
* handler appends. `found:false` means the process died without the marker —
* i.e. this server never saw the close (it restarted mid-run).
*/
function findExitMarker(lines: string[]): { found: boolean; code: number | null } {
for (let i = lines.length - 1; i >= 0; i--) {
const m = /^# exit code: (-?\d+|null)$/.exec((lines[i] ?? "").trim());
if (m) return { found: true, code: m[1] === "null" ? null : Number(m[1]) };
}
return { found: false, code: null };
}
/** Read the last `maxLines` lines of a file without loading the whole log. */
function tailLines(filePath: string, maxLines: number, maxBytes = 64 * 1024): string[] {
const size = statSync(filePath).size;
const start = Math.max(0, size - maxBytes);
const buf = Buffer.alloc(size - start);
const fd = openSync(filePath, "r");
try {
readSync(fd, buf, 0, buf.length, start);
} finally {
closeSync(fd);
}
const lines = buf.toString("utf8").split("\n");
return lines.slice(Math.max(0, lines.length - maxLines));
}
export async function trainCount(
input: TrainCountInput,
@@ -92,13 +185,16 @@ export async function trainCount(
const outputDir =
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
// Record the job immediately so ruview_job_status can find it.
jobRegistry.set(jobId, {
// Record the job immediately so ruview_job_status can find it — in memory
// and on disk (survives server restarts, ADR-264 O6).
const record: JobRecord = {
status: "queued",
log_path: logPath,
queued_at: queuedAt,
epochs_total: input.epochs,
});
};
jobRegistry.set(jobId, record);
persistJob(logDir, jobId, record);
// Write the header synchronously so the log file exists before spawn.
const header = [
@@ -142,21 +238,29 @@ export async function trainCount(
child.unref(); // Allow the MCP server process to exit without waiting for training.
const entry = jobRegistry.get(jobId);
if (entry) {
entry.status = "running";
}
// The child holds its own duplicates of the log fds; close the parent's
// copies immediately or every job leaks 2 fds for the server's lifetime
// (ADR-264 F6/O6).
closeSync(logFdOut);
closeSync(logFdErr);
// Record the child pid so a later process can reconcile a stale 'running'
// record after a server restart (child.pid is undefined only if spawn failed
// synchronously, in which case the 'error' handler flips status to 'failed').
record.pid = child.pid;
record.status = "running";
persistJob(logDir, jobId, record);
child.on("error", (e) => {
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = "failed";
record.status = "failed";
persistJob(logDir, jobId, record);
});
child.on("close", (code) => {
appendFileSync(logPath, `\n# exit code: ${code}\n`);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = code === 0 ? "done" : "failed";
record.status = code === 0 ? "done" : "failed";
persistJob(logDir, jobId, record);
});
const result: TrainJobResult = {
@@ -178,24 +282,48 @@ export async function trainCount(
export async function jobStatus(
input: JobStatusInput,
_config: RuviewConfig
config: RuviewConfig
): Promise<object> {
const job = jobRegistry.get(input.job_id);
// Memory first, then the persisted record (survives server restarts).
let job = jobRegistry.get(input.job_id) ?? loadPersistedJob(config.jobsDir, input.job_id);
if (!job) {
return {
ok: false,
error: `Job ${input.job_id} not found. ` +
"The MCP server may have restarted — check the log directory directly.",
error: `Job ${input.job_id} not found in this server or in ${config.jobsDir}.`,
};
}
// Read the last 20 lines of the log file.
// Reconcile a 'running' record whose owning process is gone. The status flip
// to done/failed lives only in the spawning process's child.on('close'/'error')
// handlers; if this server restarted mid-run, the record froze at 'running'
// (ADR-264 O6). When the pid is dead, recover the true outcome from the log's
// "# exit code: N" marker, else surface an honest 'unknown'.
if (job.status === "running" && typeof job.pid === "number" && !isProcessAlive(job.pid)) {
let tail: string[] = [];
try {
tail = tailLines(job.log_path, 40);
} catch {
/* log unreadable — treated as no marker below */
}
const marker = findExitMarker(tail);
const reconciled: JobRecord = { ...job };
if (marker.found) {
reconciled.status = marker.code === 0 ? "done" : "failed";
reconciled.reason = undefined;
} else {
reconciled.status = "unknown";
reconciled.reason =
"process gone, no exit marker — server likely restarted mid-run";
}
jobRegistry.set(input.job_id, reconciled);
persistJob(config.jobsDir, input.job_id, reconciled);
job = reconciled;
}
// Bounded tail read — never load a multi-GB training log wholesale.
let recentLog: string[] = [];
try {
const { readFileSync } = await import("node:fs");
const content = readFileSync(job.log_path, "utf8");
const lines = content.split("\n");
recentLog = lines.slice(Math.max(0, lines.length - 20));
recentLog = tailLines(job.log_path, 20);
} catch {
recentLog = ["(log not readable yet)"];
}
@@ -206,6 +334,7 @@ export async function jobStatus(
log_path: job.log_path,
recent_log: recentLog,
epochs_total: job.epochs_total,
...(job.reason !== undefined ? { reason: job.reason } : {}),
};
return { ok: true, result };
+8 -1
View File
@@ -115,7 +115,12 @@ export interface TrainJobResult {
/** Output of ruview_job_status. */
export interface JobStatusResult {
job_id: string;
status: "queued" | "running" | "done" | "failed";
/**
* 'unknown' is only ever produced by post-restart reconciliation: a record
* frozen at 'running' whose owning process is gone and whose log carries no
* exit-code marker (see reason).
*/
status: "queued" | "running" | "done" | "failed" | "unknown";
progress_pct?: number | undefined;
/** Most recent log lines (last 20). */
recent_log: string[];
@@ -124,6 +129,8 @@ export interface JobStatusResult {
epochs_done?: number | undefined;
/** Total epochs scheduled. */
epochs_total?: number | undefined;
/** Explanation attached when status was reconciled to 'unknown'. */
reason?: string | undefined;
}
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────