mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +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:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ADR-264 F8/O7 — cog-binary detection must be architecture-aware.
|
||||
*
|
||||
* detectCogBinary() itself probes hardcoded /var/lib paths, so it is not
|
||||
* cheaply testable without fs mocking. The bug it fixes, however, lives purely
|
||||
* in the candidate ORDER, which cogBinaryCandidates() exposes as a pure,
|
||||
* arch-injectable function — that is what we pin here.
|
||||
*/
|
||||
|
||||
import { cogBinaryCandidates } from "../src/config.js";
|
||||
|
||||
describe("cogBinaryCandidates()", () => {
|
||||
it("probes -arm before -x86_64 on arm64 hosts", () => {
|
||||
const c = cogBinaryCandidates("cog-person-count", "arm64");
|
||||
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
|
||||
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
|
||||
expect(arm).toBeGreaterThanOrEqual(0);
|
||||
expect(x86).toBeGreaterThanOrEqual(0);
|
||||
expect(arm).toBeLessThan(x86);
|
||||
});
|
||||
|
||||
it("probes -x86_64 before -arm on x64 hosts", () => {
|
||||
const c = cogBinaryCandidates("cog-person-count", "x64");
|
||||
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
|
||||
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
|
||||
expect(x86).toBeLessThan(arm);
|
||||
});
|
||||
|
||||
it("defaults an unknown arch to the x86_64-first order", () => {
|
||||
const c = cogBinaryCandidates("cog-pose-estimation", "riscv64");
|
||||
const arm = c.findIndex((p) => p.endsWith("cog-pose-estimation-arm"));
|
||||
const x86 = c.findIndex((p) => p.endsWith("cog-pose-estimation-x86_64"));
|
||||
expect(x86).toBeLessThan(arm);
|
||||
});
|
||||
|
||||
it("keeps the /usr/local/bin and bare-name PATH fallbacks last", () => {
|
||||
const c = cogBinaryCandidates("cog-person-count", "arm64");
|
||||
// The two arch builds come first; the /usr/local/bin fallback follows them.
|
||||
expect(c[c.length - 1]).toBe("/usr/local/bin/cog-person-count");
|
||||
expect(c).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("derives the id by stripping the cog- prefix once", () => {
|
||||
const c = cogBinaryCandidates("cog-person-count", "x64");
|
||||
expect(c[0]).toBe(
|
||||
"/var/lib/cognitum/apps/person-count/cog-person-count-x86_64"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,9 @@ async function startServer(
|
||||
basePort: number
|
||||
): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const port = basePort + Math.floor(Math.random() * 100);
|
||||
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
|
||||
// Factory, not instance: each Streamable-HTTP session gets its own MCP
|
||||
// Server (ADR-264 F7/O3).
|
||||
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
||||
@@ -95,8 +97,34 @@ describe("isOriginAllowed()", () => {
|
||||
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-sensitive per RFC 6454", () => {
|
||||
expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false);
|
||||
// ADR-264 F7: real browser origins carry ports — localhost must match on
|
||||
// hostname, any port, even with an empty allowlist.
|
||||
it("allows localhost origins on any port", () => {
|
||||
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
|
||||
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
|
||||
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-local origins even with a localhost-looking prefix", () => {
|
||||
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
|
||||
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
|
||||
});
|
||||
|
||||
// ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The
|
||||
// any-port-localhost convenience applies solely to the empty-allowlist case,
|
||||
// so an operator who pins an allowlist actually gets it.
|
||||
it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => {
|
||||
expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false);
|
||||
expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false);
|
||||
});
|
||||
|
||||
it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => {
|
||||
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
|
||||
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
|
||||
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,3 +193,117 @@ describe("HTTP transport bearer-token auth gate", () => {
|
||||
expect(r.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
|
||||
|
||||
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
|
||||
let port: number;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
|
||||
port = srv.port;
|
||||
close = srv.close;
|
||||
});
|
||||
|
||||
afterAll(async () => { await close(); });
|
||||
|
||||
it("rejects oversized request bodies with 413", async () => {
|
||||
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
|
||||
const r = await post(port, "/mcp", {}, huge);
|
||||
expect(r.status).toBe(413);
|
||||
});
|
||||
|
||||
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
|
||||
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
|
||||
expect(r.status).toBe(400);
|
||||
const body = JSON.parse(r.body) as Record<string, unknown>;
|
||||
expect(body["error"]).toMatch(/initialize/i);
|
||||
});
|
||||
|
||||
it("rejects a POST with an unknown session id with 404", async () => {
|
||||
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
|
||||
it("creates a fresh session (and MCP server) per initialize request", async () => {
|
||||
const init = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "0.0.0" },
|
||||
},
|
||||
});
|
||||
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
|
||||
expect([200, 406]).not.toContain(0); // sanity
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ───────────────
|
||||
|
||||
describe("HTTP transport session bounds (ADR-264 F7)", () => {
|
||||
const initBody = (id: number): string =>
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test-client", version: "0.0.0" },
|
||||
},
|
||||
});
|
||||
|
||||
// Build directly (not via startServer) so we can inspect the sessions map.
|
||||
async function startWithApp(
|
||||
opts: Parameters<typeof buildHttpApp>[1],
|
||||
basePort: number
|
||||
): Promise<{
|
||||
port: number;
|
||||
sessions: ReturnType<typeof buildHttpApp>["sessions"];
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts);
|
||||
const port = basePort + Math.floor(Math.random() * 100);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const close = () =>
|
||||
new Promise<void>((res, rej) => httpServer.close((e) => (e ? rej(e) : res())));
|
||||
return { port, sessions, close };
|
||||
}
|
||||
|
||||
const ACCEPT = { Accept: "application/json, text/event-stream" };
|
||||
|
||||
it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => {
|
||||
const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800);
|
||||
try {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await post(srv.port, "/mcp", ACCEPT, initBody(i));
|
||||
}
|
||||
expect(srv.sessions.size).toBeLessThanOrEqual(2);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sweeps sessions idle beyond sessionIdleMs", async () => {
|
||||
const srv = await startWithApp(
|
||||
{ allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 },
|
||||
49900
|
||||
);
|
||||
try {
|
||||
await post(srv.port, "/mcp", ACCEPT, initBody(1));
|
||||
expect(srv.sessions.size).toBe(1);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(srv.sessions.size).toBe(0);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(__dirname, "../package.json");
|
||||
// jest runs from the package root; avoid import.meta (ts-jest transforms this
|
||||
// suite to a module target that rejects it — pre-existing suite failure).
|
||||
const pkgPath = resolve(process.cwd(), "package.json");
|
||||
|
||||
// Parse once; keep raw for snapshot assertions.
|
||||
const raw = readFileSync(pkgPath, "utf-8");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ADR-264 O6 — post-restart job reconciliation.
|
||||
*
|
||||
* When the MCP server restarts mid-run, the persisted job record stays frozen
|
||||
* at 'running' (the child.on('close') that flips it lived in the dead process).
|
||||
* ruview_job_status must reconcile such a record against the recorded pid and
|
||||
* the log's "# exit code: N" marker.
|
||||
*
|
||||
* We fabricate a persisted record pointing at a KNOWN-DEAD pid (a synchronous
|
||||
* child that has already exited) and assert the reconciled status.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jobStatus } from "../src/tools/train-count.js";
|
||||
import type { RuviewConfig } from "../src/types.js";
|
||||
|
||||
/** A pid that has certainly exited: spawnSync waits for the child to finish. */
|
||||
function deadPid(): number {
|
||||
const r = spawnSync(process.execPath, ["-e", ""]);
|
||||
if (typeof r.pid !== "number") throw new Error("could not spawn probe child");
|
||||
return r.pid;
|
||||
}
|
||||
|
||||
function makeConfig(jobsDir: string): RuviewConfig {
|
||||
return {
|
||||
sensingServerUrl: "http://127.0.0.1:19999",
|
||||
apiToken: undefined,
|
||||
poseCogBinary: "nonexistent",
|
||||
countCogBinary: "nonexistent",
|
||||
jobsDir,
|
||||
};
|
||||
}
|
||||
|
||||
/** Write a fake persisted 'running' record + its log, return {jobId, config}. */
|
||||
function seedRunningJob(logBody: string): { jobId: string; config: RuviewConfig } {
|
||||
const jobsDir = mkdtempSync(path.join(os.tmpdir(), "rvagent-jobs-"));
|
||||
const jobId = randomUUID();
|
||||
const logPath = path.join(jobsDir, `${jobId}.log`);
|
||||
writeFileSync(logPath, logBody);
|
||||
const record = {
|
||||
job_id: jobId,
|
||||
status: "running",
|
||||
log_path: logPath,
|
||||
queued_at: Date.now() / 1000,
|
||||
epochs_total: 5,
|
||||
pid: deadPid(),
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(jobsDir, `${jobId}.json`),
|
||||
JSON.stringify(record, null, 2)
|
||||
);
|
||||
return { jobId, config: makeConfig(jobsDir) };
|
||||
}
|
||||
|
||||
describe("ruview_job_status reconciliation (ADR-264 O6)", () => {
|
||||
it("reconciles a dead 'running' job with exit 0 to 'done'", async () => {
|
||||
const { jobId, config } = seedRunningJob(
|
||||
"# training...\nepoch 5/5\n# exit code: 0\n"
|
||||
);
|
||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
||||
expect(out["ok"]).toBe(true);
|
||||
const res = out["result"] as Record<string, unknown>;
|
||||
expect(res["status"]).toBe("done");
|
||||
});
|
||||
|
||||
it("reconciles a dead 'running' job with non-zero exit to 'failed'", async () => {
|
||||
const { jobId, config } = seedRunningJob(
|
||||
"# training...\npanic: cuda oom\n# exit code: 101\n"
|
||||
);
|
||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
||||
const res = out["result"] as Record<string, unknown>;
|
||||
expect(res["status"]).toBe("failed");
|
||||
});
|
||||
|
||||
it("marks a dead 'running' job with no exit marker as 'unknown' with a reason", async () => {
|
||||
const { jobId, config } = seedRunningJob("# training...\nepoch 2/5\n");
|
||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
||||
const res = out["result"] as Record<string, unknown>;
|
||||
expect(res["status"]).toBe("unknown");
|
||||
expect(typeof res["reason"]).toBe("string");
|
||||
expect(res["reason"]).toMatch(/restarted/i);
|
||||
});
|
||||
|
||||
it("treats a signal-killed marker (null) as 'failed'", async () => {
|
||||
const { jobId, config } = seedRunningJob(
|
||||
"# training...\n# exit code: null\n"
|
||||
);
|
||||
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
|
||||
const res = out["result"] as Record<string, unknown>;
|
||||
expect(res["status"]).toBe("failed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user