mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (1–7). 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) ───────────────
|
||||
|
||||
Reference in New Issue
Block a user