diff --git a/mcp/src/lab/wfbench/workflows/wfbench-run.yaml b/mcp/src/lab/wfbench/workflows/wfbench-run.yaml index df93c9953..a08c4b86d 100644 --- a/mcp/src/lab/wfbench/workflows/wfbench-run.yaml +++ b/mcp/src/lab/wfbench/workflows/wfbench-run.yaml @@ -97,11 +97,23 @@ steps: node_data: "{{ roster.trigger.node_data }}" namespace: "{{ input.namespace || params.namespace }}" + # graph/* steps return an error STRING instead of throwing (a rejected + # write is fail-soft), so a roster that did not land shows up as a missing + # ref_id. Gate the hop/edge/link chain on it: without this the neighbors + # hop's schema rejects the undefined ref_id and the whole run errors — + # after the callback already went out (observed live on swarm38). + - id: roster_ok + type: if + depends: trig + config: + cond: "{{ evalset.ref_id && trig.ref_id }}" + # hop_check_trigger_exists + guard_first_run: prior triggers ⇒ HAS_TRIGGER, # none ⇒ HAS_BASELINE_TRIGGER. - id: hop type: graph/graph-neighbors - depends: trig + depends: roster_ok + when: true config: ref_id: "{{ evalset.ref_id }}" edge_type: [HAS_TRIGGER, HAS_BASELINE_TRIGGER] @@ -352,9 +364,11 @@ steps: judge_model: "{{ params.judgeModel }}" # ── record (58312's twin) ───────────────────────────────────────────── + # Depends on the gate too, so it runs whether or not the roster landed + # (a skipped `link` alone would skip-propagate). - id: chain type: wfbench/build-eval-output - depends: [scores, link] + depends: [scores, link, roster_ok] config: task_slug: "{{ task.task_slug }}" scores: "{{ scores }}" @@ -432,6 +446,8 @@ steps: n_materials: "{{ mats.n_materials }}" material_warnings: "{{ mats.warnings }}" graph: + roster_ok: "{{ roster_ok }}" + evalset: "{{ evalset }}" evalset_ref_id: "{{ evalset.ref_id }}" trigger_ref_id: "{{ trig.ref_id }}" trigger_id: "{{ roster.trigger_id }}" diff --git a/vein/src/graph/schema-resolver.test.ts b/vein/src/graph/schema-resolver.test.ts index 58c4dfa22..32ed675c8 100644 --- a/vein/src/graph/schema-resolver.test.ts +++ b/vein/src/graph/schema-resolver.test.ts @@ -78,7 +78,7 @@ describe("jarvis ontology + resolver + jarvis-typed writes (live Neo4j)", { skip assert.ok(names.constraints.includes("unique_document_node_key")); }); - it("resolves types case-insensitively (labels → Schema.type), Vein types exactly", async () => { + it("resolves types case-insensitively (Schema.type → labels), Vein types exactly", async () => { assert.equal(await resolver.resolveType("evalset"), "EvalSet"); assert.equal(await resolver.resolveType(" document "), "Document"); assert.equal(await resolver.resolveType("VeinRun"), "VeinRun"); @@ -87,6 +87,28 @@ describe("jarvis ontology + resolver + jarvis-typed writes (live Neo4j)", { skip assert.equal(await resolver.resolveType("*"), null); }); + it("a LEGACY case-variant label with no Schema never shadows the real type (swarm38's Evalset/EvalSet)", async () => { + // A long-lived jarvis graph carries labels from earlier node generations + // next to the schema-backed ones (swarm38 lists Evalset/Evaltrigger/… + // with zero nodes; Neo4j 5 drops label tokens once unused, so keep one + // legacy node per label alive here — the resolver must not be fooled + // either way). + await bolt.run(`CREATE (a:Evalset {ref_id: "legacy-1"}), (b:Evaltrigger {ref_id: "legacy-2"})`); + const labels = await bolt.run(`CALL db.labels() YIELD label WHERE toLower(label) = "evalset" RETURN collect(label) AS l`); + assert.deepEqual([...(labels[0]!["l"] as string[])].sort(), ["EvalSet", "Evalset"], "both spellings are registered labels"); + resolver.invalidate(); + assert.equal(await resolver.resolveType("EvalSet"), "EvalSet"); + assert.equal(await resolver.resolveType("evalset"), "EvalSet"); + assert.equal(await resolver.resolveType("EvalTrigger"), "EvalTrigger"); + assert.ok(await resolver.schema("EvalSet"), "the EvalSet Schema resolves through the legacy label"); + // A jarvis-typed write — the exact call that failed on swarm38. + const r = await nodes.write({ type: "EvalSet", data: { id: "legacy-label-probe", name: "probe" } }, "create"); + assert.ok(r.ref_id); + const written = await bolt.run(`MATCH (n:EvalSet {id: "legacy-label-probe"}) RETURN labels(n) AS l`); + assert.ok((written[0]!["l"] as string[]).includes("EvalSet")); + assert.ok(!(written[0]!["l"] as string[]).includes("Evalset")); + }); + it("merges CHILD_OF ancestors, exposes index/domain, forces name optional", async () => { const doc = (await resolver.schema("document"))!; assert.equal(doc.type, "Document"); diff --git a/vein/src/graph/schema-resolver.ts b/vein/src/graph/schema-resolver.ts index 1548e0f99..371b167f9 100644 --- a/vein/src/graph/schema-resolver.ts +++ b/vein/src/graph/schema-resolver.ts @@ -113,7 +113,13 @@ export class SchemaResolver { /** * Canonical type for a user-supplied string, or null. Vein types must * match exactly (registry); everything else resolves case-insensitively - * against live labels, then `Schema.type`. + * against `Schema.type` first, then live labels — exact case preferred in + * both. Schema first, because a long-lived jarvis graph carries LEGACY + * labels that differ only by case (`Evalset` next to `EvalSet`, with no + * nodes and no Schema): Neo4j keeps them in `db.labels()` forever, and + * "first label that matches case-insensitively" then canonicalizes to a + * type that has no Schema, so every write fails UNKNOWN_TYPE while the + * real schema sits right there. */ async resolveType(raw: string, tx?: ManagedTransaction): Promise { const key = raw.trim(); @@ -122,15 +128,21 @@ export class SchemaResolver { const c = this.types.get(key.toLowerCase()); if (this.fresh(c)) return c.value; let out: string | null = null; - const labels = await this.rows(tx, `CALL db.labels() YIELD label WHERE toLower(label) = toLower($t) RETURN label LIMIT 1`, { t: key }); - if (labels.length) out = String(labels[0]!["label"]); + const s = await this.rows( + tx, + `MATCH (n:Schema) WHERE toLower(n.type) = toLower($t) AND (n.is_deleted IS NULL OR n.is_deleted = false) AND n.type <> "*" + RETURN n.type AS t ORDER BY CASE WHEN n.type = $t THEN 0 ELSE 1 END, n.type LIMIT 1`, + { t: key }, + ); + if (s.length) out = String(s[0]!["t"]); else { - const s = await this.rows( + const labels = await this.rows( tx, - `MATCH (n:Schema) WHERE toLower(n.type) = toLower($t) AND (n.is_deleted IS NULL OR n.is_deleted = false) AND n.type <> "*" RETURN n.type AS t LIMIT 1`, + `CALL db.labels() YIELD label WHERE toLower(label) = toLower($t) + RETURN label ORDER BY CASE WHEN label = $t THEN 0 ELSE 1 END, label LIMIT 1`, { t: key }, ); - if (s.length) out = String(s[0]!["t"]); + if (labels.length) out = String(labels[0]!["label"]); } if (out && getVeinSchema(out)) out = getVeinSchema(out)!.type; this.types.set(key.toLowerCase(), { at: Date.now(), value: out });