From 83483773d571a9247e19f35643df93f88f465930 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 17:19:39 +0200 Subject: [PATCH 1/2] fix(graph): reach the event stream through get_stream(), not the module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.1.0 removed the module-level emit/close/reset forwarders from graph_event_stream — correctly, because one of them silently dropped `event_meta` once emit grew it — on the stated grounds that they "had never had a caller in this repository's history". They had four. graph_build_run called reset, emit and close on the module; graph_build_merge calls emit on whatever it is handed, and was handed the module. So run_build died on its first statement: AttributeError: module 'cortex_viz.server.graph_event_stream' has no attribute 'reset' graph_build_run.py:155 before a single source loaded. /api/graph/progress reported `phase: "starting", pct: 0.0` forever and every DB-backed view stayed empty. Trace was unaffected — it does not go through this builder. The `finally` block had the same defect and was worse: `_ev.close()` on the module sat inside `except Exception: pass`, so it failed silently. Subscribers never received `done` and never disconnected. The crash was masking it. Fix follows the singleton's own contract — "callers reach it through get_stream() and use the GraphEventStream API directly", with activity_stream as the worked example — rather than restoring the forwarders, which would re-open the weaker door 3.1.0 closed on purpose. make_merge's `events` parameter is annotated GraphEventStream so the next caller that passes the module fails at type-check instead of halfway through a build. Verified end to end, not just by unit test: the standalone server on the fixed tree advances past `starting` — baseline_ready, 85 hooks, 49 discussion_agents, 36752 memories, then `layout bake (DrL)` — with zero `background build error` in the log. Co-Authored-By: Claude Opus 5 (1M context) --- cortex_viz/server/graph_build_merge.py | 16 +- cortex_viz/server/graph_build_run.py | 14 +- .../test_graph_build_event_stream_contract.py | 148 ++++++++++++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 tests/test_graph_build_event_stream_contract.py diff --git a/cortex_viz/server/graph_build_merge.py b/cortex_viz/server/graph_build_merge.py index 72947ff..7ce24ec 100644 --- a/cortex_viz/server/graph_build_merge.py +++ b/cortex_viz/server/graph_build_merge.py @@ -4,8 +4,8 @@ (behaviour-preserving split, 2026-06-14). ``make_merge`` reconstructs the exact same closure: the per-build dedup sets (``seen_n``/``seen_e``) and kind tallies (``kind_counts``) are captured locally — one fresh set per build, identical to -the in-line original — and ``domain_filter`` + the ``graph_event_stream`` -module are bound from the caller. +the in-line original — and ``domain_filter`` + the process-wide +``GraphEventStream`` are bound from the caller. Shared cache state lives in ``graph_cache_state`` (the single owner): the merge mutates it via ``state.X = ...`` direct attribute assignment. @@ -17,15 +17,19 @@ from cortex_viz.server import graph_cache_state as state from cortex_viz.server.graph_build_helpers import _set_progress +from cortex_viz.server.graph_event_stream import GraphEventStream from cortex_viz.server.graph_wire import _slim_node -def make_merge(domain_filter: str | None, events): +def make_merge(domain_filter: str | None, events: GraphEventStream): """Return the build's ``_merge`` callback with fresh dedup state. - ``events`` is the ``graph_event_stream`` module (the live SSE delivery - path). The returned closure has the SAME signature and behaviour as the - in-line ``_run._merge``. + ``events`` is the process-wide ``GraphEventStream`` from ``get_stream()`` + (the live SSE delivery path) — the object, not the module: the module-level + forwarders were removed deliberately, and annotating the parameter is what + makes a caller that passes the module fail at type-check rather than at the + first emit, halfway through a build. The returned closure has the SAME + signature and behaviour as the in-line ``_run._merge``. """ # ── Incremental merge state ── # Dedup sets + kind tallies persist across _merge calls instead diff --git a/cortex_viz/server/graph_build_run.py b/cortex_viz/server/graph_build_run.py index 7fbb757..4b8b619 100644 --- a/cortex_viz/server/graph_build_run.py +++ b/cortex_viz/server/graph_build_run.py @@ -150,13 +150,19 @@ def run_build(store, domain_filter: str | None) -> None: # graph grow instead of waiting for the full ingest to # finish. RESET on every kicked build so a previous build's # tail events don't leak into this run's subscribers. - from cortex_viz.server import graph_event_stream as _events + # Reached through get_stream() and used through the GraphEventStream + # API directly, as the singleton's contract requires: the module-level + # emit/close/reset forwarders were removed deliberately, because one of + # them silently dropped `event_meta` once emit grew it. + from cortex_viz.server.graph_event_stream import get_stream + + _events = get_stream() _events.reset() state._source_totals.clear() # Construct the cumulative-cache merge closure now that the SSE - # event stream module is available. Fresh dedup state per build. + # event stream is available. Fresh dedup state per build. _merge = make_merge(domain_filter, _events) def _resolve_wiki_source_edges() -> None: @@ -544,9 +550,9 @@ def _on_batch(label: str, nodes_objs, edges_objs) -> None: # idempotent; the buffer survives for late-subscriber # replay until the next build's reset(). try: - from cortex_viz.server import graph_event_stream as _ev + from cortex_viz.server.graph_event_stream import get_stream as _get_stream - _ev.close() + _get_stream().close() except Exception: # close() is idempotent and the buffer survives for late-subscriber replay; # a failure here must not mask the build outcome being unwound. diff --git a/tests/test_graph_build_event_stream_contract.py b/tests/test_graph_build_event_stream_contract.py new file mode 100644 index 0000000..b12d7ae --- /dev/null +++ b/tests/test_graph_build_event_stream_contract.py @@ -0,0 +1,148 @@ +"""The background build must reach the event stream through ``get_stream()``. + +`graph_event_stream` deliberately carries no module-level ``emit``/``close``/ +``reset`` forwarders: one of them silently dropped ``event_meta`` once ``emit`` +grew it, so the singleton has exactly one door. When those forwarders were +removed, `graph_build_run` was still calling all three **on the module**, and +nothing here noticed: + +- ``_events.reset()`` raised ``AttributeError`` on the first line of every + build, so the graph never built at all — ``/api/graph/progress`` sat at + ``phase: "starting", pct: 0.0`` forever; +- ``_ev.close()`` in the ``finally`` block sat inside ``except Exception: pass``, + so it failed **silently** — subscribers never received ``done`` and never + disconnected. + +The unit tests around the merge closure all pass a fake stream, so they could +not catch this: the defect was in how the production caller *obtains* the +stream, not in what it does with it. These tests assert that binding, which is +why they are static — reproducing the crash behaviourally would mean standing up +a build, and a build needs PostgreSQL. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from cortex_viz.server import graph_event_stream as module_under_contract +from cortex_viz.server.graph_event_stream import GraphEventStream + +STREAM_ONLY_METHODS = ("emit", "close", "reset") + +_BUILD_RUN = Path(module_under_contract.__file__).parent / "graph_build_run.py" + + +def _module_alias_names(tree: ast.AST) -> set[str]: + """Names bound to the `graph_event_stream` MODULE, however imported.""" + aliases: set[str] = set() + for node in ast.walk(tree): + # `from cortex_viz.server import graph_event_stream as _events` + if isinstance(node, ast.ImportFrom) and node.module in { + "cortex_viz.server", + "cortex_viz", + }: + for name in node.names: + if name.name.endswith("graph_event_stream"): + aliases.add(name.asname or name.name) + # `import cortex_viz.server.graph_event_stream as _events` + elif isinstance(node, ast.Import): + for name in node.names: + if name.name.endswith("graph_event_stream") and name.asname: + aliases.add(name.asname) + return aliases + + +def _attributes_called_on(tree: ast.AST, names: set[str]) -> set[str]: + return { + node.attr + for node in ast.walk(tree) + if isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in names + } + + +@pytest.fixture(scope="module") +def build_run_tree() -> ast.AST: + return ast.parse(_BUILD_RUN.read_text(encoding="utf-8")) + + +def test_the_singleton_keeps_exactly_one_door(): + """Pins the deliberate removal: re-adding a forwarder re-opens the weaker + path that dropped `event_meta`.""" + for name in STREAM_ONLY_METHODS: + assert not hasattr(module_under_contract, name), ( + f"graph_event_stream regained a module-level {name}() forwarder; " + "callers must use get_stream()" + ) + assert hasattr(GraphEventStream, name), ( + f"GraphEventStream lost {name}() — the build calls it" + ) + + +def test_the_build_never_calls_stream_methods_on_the_module(build_run_tree): + """The regression itself. Fails on the pre-fix source, where + `_events` was the module and `_events.reset()` was an AttributeError.""" + aliases = _module_alias_names(build_run_tree) + offending = _attributes_called_on(build_run_tree, aliases) & set( + STREAM_ONLY_METHODS + ) + assert not offending, ( + f"graph_build_run calls {sorted(offending)} on the graph_event_stream " + f"module (bound as {sorted(aliases)}); those live on GraphEventStream. " + "Obtain the stream with get_stream() instead." + ) + + +def test_make_merge_is_annotated_against_the_stream_not_the_module(): + """A caller that passes the module must fail at type-check, not at the + first emit halfway through a build. + + Resolved with ``get_type_hints`` rather than read off ``__annotations__``: + the module uses ``from __future__ import annotations``, so the raw value is + the *string* ``"GraphEventStream"`` and an identity check against the class + would pass vacuously for any annotation at all. + """ + from typing import get_type_hints + + from cortex_viz.server.graph_build_merge import make_merge + + assert get_type_hints(make_merge).get("events") is GraphEventStream, ( + "make_merge's `events` parameter must be typed GraphEventStream" + ) + + +def _names_bound_to_get_stream(tree: ast.AST) -> set[str]: + bound: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + call = node.value + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)): + continue + if not call.func.id.endswith("get_stream"): + continue + bound.update(t.id for t in node.targets if isinstance(t, ast.Name)) + return bound + + +def test_a_real_stream_satisfies_every_call_the_build_makes(build_run_tree): + """Whatever the build calls on its stream must exist on the real class — + so moving a method on GraphEventStream breaks here, not in production.""" + stream_names = _names_bound_to_get_stream(build_run_tree) + assert stream_names, "graph_build_run no longer binds a stream via get_stream()" + + live = GraphEventStream() + try: + called = _attributes_called_on(build_run_tree, stream_names) + assert called, "the build binds a stream but never calls anything on it" + for attr in called: + assert hasattr(live, attr), ( + f"the build calls .{attr}() on the stream, " + "which GraphEventStream does not provide" + ) + finally: + live.reset() From 7dbb934ea53933e96eaa22cb61acad870fa8de8b Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 17:20:34 +0200 Subject: [PATCH 2/2] chore(release): 3.1.1 Patch release carrying the graph-build fix. 3.1.0 builds no graph at all, so every install of it should move. Version bumped across identity.py (the source of truth), pyproject, server.json, the Claude/Codex/Gemini manifests and the README badges; CHANGELOG entry states the upgrade urgency and what was wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++ README.md | 2 +- cortex_viz/identity.py | 2 +- gemini-extension.json | 2 +- pyproject.toml | 2 +- server.json | 4 ++-- 9 files changed, 47 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2a6da8c..1b0bd1c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Hypermnesia MCP Viz — the read-only visualization companion for Cortex", - "version": "3.1.0" + "version": "3.1.1" }, "plugins": [ { "name": "hypermnesia-mcp-viz", "source": "./", "description": "Live memory galaxy, methodology map, workflow graph, wiki browser, and execution trace over the shared Cortex store.", - "version": "3.1.0", + "version": "3.1.1", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8638c18..0676792 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hypermnesia-mcp-viz", "description": "Standalone visualization MCP for Cortex — a live neural-graph galaxy of every project, file, symbol, memory, discussion and wiki page, plus a per-session execution trace. Read-only bridge over Cortex's shared PostgreSQL and the ~/.claude artifacts.", - "version": "3.1.0", + "version": "3.1.1", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c9d709f..b81053b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hypermnesia-mcp-viz", - "version": "3.1.0", + "version": "3.1.1", "description": "Read-only visualization and graph MCP for Cortex, packaged for Codex.", "author": { "name": "Clement Deust", diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c4b2a..d59ff5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,43 @@ Releases before 2.7.0 were recorded as `chore(release)` / `release:` commits in `workflow_dispatch` recovery path repairs a stale registry entry for an already-tagged release without re-publishing the package. +## [3.1.1] - 2026-08-10 + +**Upgrade from 3.1.0 immediately: on 3.1.0 the graph never builds.** The Graph, +Brain, Knowledge, Board and Wiki views stay empty forever; `/api/graph/progress` +reports `phase: "starting", pct: 0.0` and never advances. The Trace view is +unaffected. No data was lost or corrupted — the build died before reading +anything. + +### Fixed +- **The background graph build crashed on its first statement.** 3.1.0 removed + the module-level `emit`/`close`/`reset` forwarders from `graph_event_stream` + — deliberately, because one of them silently dropped `event_meta` once `emit` + grew it — on the stated grounds that they "had never had a caller in this + repository's history". They had four: `graph_build_run` called `reset`, + `emit` and `close` on the module, and `graph_build_merge` called `emit` on + what it was handed. `run_build` therefore raised + `AttributeError: module 'cortex_viz.server.graph_event_stream' has no + attribute 'reset'` at `graph_build_run.py:155`, before any source loaded. + The callers now reach the singleton through `get_stream()` and use the + `GraphEventStream` API directly, which is what the singleton's own contract + asks for and what `activity_stream` already did. The forwarders stay removed. +- **The end-of-build stream terminator failed silently.** The same defect in + the `finally` block (`_ev.close()` on the module) sat inside + `except Exception: pass`, so subscribers never received `done` and never + disconnected — a second failure the crash was masking. +- `make_merge`'s `events` parameter is now annotated `GraphEventStream`, so a + caller that passes the module fails at type-check rather than at the first + emit, halfway through a build. + +### Added +- `tests/test_graph_build_event_stream_contract.py`: pins that the module keeps + exactly one door (re-adding a forwarder re-opens the weaker `event_meta`- + dropping path), and that the build reaches the stream through `get_stream()`. + Three of its four tests fail on 3.1.0. The existing merge tests could not + catch this — they all inject a fake stream, so the defect lived in how the + production caller *obtains* the stream, not in what it does with it. + ## [3.1.0] - 2026-08-10 **Upgrading from 2.8.0:** this release carries a breaking distribution-identity diff --git a/README.md b/README.md index 411ea1b..33f4a01 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Cross-platform MCP for Codex, Gemini CLI, and Claude Code MIT License Python 3.10+ - Version 3.1.0 + Version 3.1.1 OpenSSF Best Practices

diff --git a/cortex_viz/identity.py b/cortex_viz/identity.py index 94f25aa..b1deaa2 100644 --- a/cortex_viz/identity.py +++ b/cortex_viz/identity.py @@ -7,4 +7,4 @@ DISTRIBUTION_NAME = "hypermnesia-mcp-viz" MCP_REGISTRY_ID = "io.github.cdeust/hypermnesia-mcp-viz" -VERSION = "3.1.0" +VERSION = "3.1.1" diff --git a/gemini-extension.json b/gemini-extension.json index 8d57c7a..c78c490 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "hypermnesia-mcp-viz", - "version": "3.1.0", + "version": "3.1.1", "description": "Read-only visualization and graph MCP for Cortex, packaged for Gemini CLI.", "mcpServers": { "hypermnesia-mcp-viz": { diff --git a/pyproject.toml b/pyproject.toml index 2ee63d0..4c3584f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hypermnesia-mcp-viz" -version = "3.1.0" +version = "3.1.1" description = "Visualization and graph MCP server for Cortex — neural graph, methodology map, workflow graph, and trace UI extracted from the Cortex memory engine" readme = "README.md" license = "MIT" diff --git a/server.json b/server.json index 149c4eb..8fdcc11 100644 --- a/server.json +++ b/server.json @@ -6,13 +6,13 @@ "url": "https://github.com/cdeust/cortex-viz", "source": "github" }, - "version": "3.1.0", + "version": "3.1.1", "websiteUrl": "https://ai-architect.tools/cortex", "packages": [ { "registryType": "pypi", "identifier": "hypermnesia-mcp-viz", - "version": "3.1.0", + "version": "3.1.1", "runtimeHint": "python", "transport": { "type": "stdio"