Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions mcp/src/gitree/__tests__/jarvis-node-key.test.ts
Original file line number Diff line number Diff line change
@@ -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)}`);
});
});
43 changes: 42 additions & 1 deletion mcp/src/gitree/store/graphStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 =
Expand Down
31 changes: 31 additions & 0 deletions mcp/src/gitree/store/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "crypto";
import { Concept, PRRecord, CommitRecord } from "../types.js";
import { Storage } from "./index.js";

Expand Down Expand Up @@ -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
*/
Expand Down
Loading