diff --git a/.env.example b/.env.example index 8a14646..9ec44c9 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,18 @@ ENGRAPHIS_SERVICE_MODE=combined # The dashboard base URL is derived from ENGRAPHIS_HOST:ENGRAPHIS_PORT. # Set ENGRAPHIS_DASHBOARD_URL for a canonical public HTTPS URL behind a reverse proxy. +# Update reminder. When on (default), the server checks for a newer Engraphis release +# once a day and surfaces it in the dashboard banner, the startup log, and over MCP. +# The check is fail-silent and cached; set to 0 to disable all update network activity. +# ENGRAPHIS_UPDATE_CHECK=1 +# Override the release source. Default: the GitHub releases/latest API for the project +# repo. Accepts any HTTPS endpoint returning a GitHub-release, PyPI, or +# {"version": "...", "url": "..."} JSON payload. +# ENGRAPHIS_UPDATE_URL= +# Point the default GitHub source at a different owner/repo (ignored when +# ENGRAPHIS_UPDATE_URL is set). Default: Coding-Dev-Tools/engraphis. +# ENGRAPHIS_UPDATE_REPO=Coding-Dev-Tools/engraphis + # Optional shared service/operations bearer. If set, supported protected routes accept # Authorization: Bearer ; managed backup/readiness automation should use a strong, # independently revocable value. It does not prove hosted deployment ownership and is not @@ -64,11 +76,12 @@ ENGRAPHIS_EMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2 # "llm": free-form fact extraction. "llm_structured": schema-validated typed facts, # entities, relations, and keywords using the LLM settings below. ENGRAPHIS_EXTRACTOR=none -# A successful dashboard connection test turns llm_structured on automatically. The -# Settings On/Off button updates the live engine immediately and persists this flag plus +# OFF by default (opt-in). While ON, a successful dashboard connection test enables +# llm_structured automatically. Use the Settings "LLM extraction" On/Off button to turn +# it on or off; it updates the live engine immediately and persists this flag plus # ENGRAPHIS_EXTRACTOR only when the project .env is writable. Explicit deployment # environment variables remain authoritative after restart. -ENGRAPHIS_LLM_AUTO_EXTRACT=1 +ENGRAPHIS_LLM_AUTO_EXTRACT=0 # ── Knowledge-graph extraction (powers the dashboard Graph tab) ────────────── # "regex" (default): dependency-free heuristic NER runs on every ingest so the Graph tab diff --git a/README.md b/README.md index b5c0c0e..f3263c3 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ embeddings. You bring an LLM only for optional chat, synthesis, structured extra or structured consolidation. - **Local-first & private** — runs offline; the core depends only on `numpy`. -- **MCP-native** — 28 tools for Claude Code, Command Code, Cursor, Cline, Zed, Windsurf. +- **MCP-native** — 29 tools for Claude Code, Command Code, Cursor, Cline, Zed, Windsurf. - **Self-maintaining facts** — writes are deterministically conflict-resolved (no LLM required). - **Advisory retention supervision** — an optional LLM can label writes as ephemeral, normal, or critical; outputs are bounded, clamped, audited, and can never silently drop a write. @@ -294,10 +294,10 @@ claude mcp add engraphis -- engraphis-mcp cmd mcp add engraphis -- engraphis-mcp # Command Code CLI ``` -Your agent now has 28 tools — remember, recall (grounded + proactive), proactive context, +Your agent now has 29 tools — remember, recall (grounded + proactive), proactive context, grounded answer alias, why, timeline, forget, pin, correct, promote, ingest, consolidate, index_repo, search/code path/impact/export, privacy receipts, PostgreSQL schema ingestion, link, -record_event, start/end_session, and stats. See the [MCP tools table](#mcp-tools) below. +record_event, start/end_session, stats, and check_update. See the [MCP tools table](#mcp-tools) below. For unattended jobs, `engraphis_start_session`, `engraphis_remember`, and `engraphis_record_event` use workspace `default` when `workspace` is omitted. @@ -684,7 +684,7 @@ engraphis/ │ ├── core/ # v2 engine — interfaces, store, recall, scoring, schema, sync │ ├── backends/ # pluggable embedder / vector index / reranker / codegraph / sync transports / encryption │ ├── service.py # validated MemoryService facade -│ ├── mcp_server.py # MCP server — 28 tools +│ ├── mcp_server.py # MCP server — 29 tools │ ├── dashboard_app.py # dashboard WebUI (FastAPI) │ ├── read_only_api.py # token-protected recall/repository-graph HTTP surface │ ├── autosync.py # background auto-sync loop (Pro/Team) diff --git a/conftest.py b/conftest.py index 1b4c347..ea53f23 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,5 @@ """Ensure the repo root is importable when running ``python -m pytest`` from anywhere.""" +import os import sys from pathlib import Path @@ -6,6 +7,11 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) +# The update reminder reaches out to the network to look up the newest release. Keep the +# offline test suite network-inert by default; tests that exercise the feature opt back in +# explicitly via monkeypatch (see tests/test_update_check.py). +os.environ.setdefault("ENGRAPHIS_UPDATE_CHECK", "0") + # The legacy scripts/test_*.py files are HTTP smoke tests (need a running server + # httpx), not unit tests. Keep pytest focused on the tests/ suite. collect_ignore_glob = ["scripts/*"] diff --git a/docs/KILO_CODE_INTEGRATION.md b/docs/KILO_CODE_INTEGRATION.md index f21f60a..4ef09c5 100644 --- a/docs/KILO_CODE_INTEGRATION.md +++ b/docs/KILO_CODE_INTEGRATION.md @@ -10,9 +10,9 @@ This manual is written for someone who wants the full technical picture: what En There are two separate questions hiding inside "connect Kilo Code to Engraphis," and they are usually where people talk past each other: -1. **Transport layer — "get the pipes connected."** This is: install the Engraphis MCP server, tell Kilo Code how to launch it, confirm the tools show up. It's a plumbing task. When it's done, Kilo Code can *see* 28 `engraphis_*` tools. Success here is binary — either the tools appear or they don't. +1. **Transport layer — "get the pipes connected."** This is: install the Engraphis MCP server, tell Kilo Code how to launch it, confirm the tools show up. It's a plumbing task. When it's done, Kilo Code can *see* 29 `engraphis_*` tools. Success here is binary — either the tools appear or they don't. -2. **Orchestration layer — "use the memory well."** This is: *when* should the agent remember vs. recall, how should memories be scoped (`workspace → repo → session`), which of the 28 tools answers which question, and how to keep the store clean over time. This is where the actual value is, and it's a discipline, not a config. +2. **Orchestration layer — "use the memory well."** This is: *when* should the agent remember vs. recall, how should memories be scoped (`workspace → repo → session`), which of the 29 tools answers which question, and how to keep the store clean over time. This is where the actual value is, and it's a discipline, not a config. You need both. A perfect config with no discipline gives you an agent that has memory tools and never uses them correctly. Good discipline with a broken config gives you an agent that wants to remember and can't. **Section 3 is the transport layer. Sections 4–6 are the orchestration layer.** Do them in order. @@ -40,7 +40,7 @@ Everything runs on your machine. The whole store is a single SQLite file. Local You interact with Engraphis through three surfaces, all backed by the *same* engine (`MemoryService`), so they can never drift apart: - **The dashboard WebUI** (`engraphis-dashboard`, `http://127.0.0.1:8700`) — a visual product to see, search, and curate memory. -- **The MCP server** (`engraphis-mcp`) — the 28 tools your coding agent calls. **This is the surface Kilo Code uses.** +- **The MCP server** (`engraphis-mcp`) — the 29 tools your coding agent calls. **This is the surface Kilo Code uses.** - **The Python library** (`from engraphis.service import MemoryService`) — for direct programmatic use. ### 2.1 The five ideas that make it more than a vector store @@ -179,7 +179,7 @@ You can also click **Approve Always** on any tool at runtime to write the same r --- -## 4. The 28 tools — the orchestration surface +## 4. The 29 tools — the orchestration surface Once connected, Kilo Code sees these. Do **not** assume only `remember`/`recall` exist — the value is in the rest. This is the full surface, grouped by what question each one answers. diff --git a/engraphis/app.py b/engraphis/app.py index ad50d6b..91c8649 100644 --- a/engraphis/app.py +++ b/engraphis/app.py @@ -64,6 +64,11 @@ async def _lifespan(app: FastAPI): logger.info("Background consciousness loop started (interval=%ds)", settings.loop_interval) else: logger.info("Background loop disabled (ENGRAPHIS_LOOP_INTERVAL=0)") + try: # one-line "update available" notice (background, fail-silent, opt-out) + from engraphis import update_check + update_check.emit_startup_notice(logger.info) + except Exception: # noqa: BLE001 - never block server startup + pass try: yield finally: diff --git a/engraphis/config.py b/engraphis/config.py index 5ed85b1..5ad0ee0 100644 --- a/engraphis/config.py +++ b/engraphis/config.py @@ -275,6 +275,16 @@ def _env_float(key: str, default: float) -> float: return default +_FALSY_ENV = {"0", "false", "no", "off", "disable", "disabled"} + + +def _env_bool(key: str, default: bool) -> bool: + raw = os.environ.get(key) + if raw is None or not raw.strip(): + return default + return raw.strip().lower() not in _FALSY_ENV + + def persist_project_env(values: dict[str, str], path: Optional[Path] = None) -> Path: """Upsert non-secret runtime settings in the project-local ``.env`` atomically. @@ -412,11 +422,12 @@ class Settings: llm_extra_headers: dict = field( default_factory=lambda: _parse_headers(_env("ENGRAPHIS_LLM_EXTRA_HEADERS", "")) ) - # A successful dashboard connection test enables schema-validated extraction unless - # the user explicitly turned it off. The separate flag is what makes an automatic - # default and a durable on/off control compatible. + # OFF by default (opt-in): a successful dashboard connection test enables + # schema-validated extraction ONLY while the user has turned extraction on (the + # Settings On/Off control, or ENGRAPHIS_LLM_AUTO_EXTRACT=1) — so a mere connection + # test never silently starts provider egress of ingested content. llm_auto_extract: bool = field( - default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "1").lower() + default_factory=lambda: _env("ENGRAPHIS_LLM_AUTO_EXTRACT", "0").lower() not in ("0", "false", "no", "off") ) @@ -452,6 +463,15 @@ class Settings: rate_limit: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_LIMIT", 0)) rate_window: int = field(default_factory=lambda: _env_int("ENGRAPHIS_RATE_WINDOW", 60)) + # Update reminder: check the newest published release and surface it in the dashboard, + # server startup log, and MCP. On by default; ``ENGRAPHIS_UPDATE_CHECK=0`` opts out and + # stops all network activity. ``ENGRAPHIS_UPDATE_URL`` overrides the default GitHub + # releases source (see engraphis.update_check, the runtime authority for both knobs). + update_check: bool = field( + default_factory=lambda: _env_bool("ENGRAPHIS_UPDATE_CHECK", True)) + update_check_url: str = field( + default_factory=lambda: _env("ENGRAPHIS_UPDATE_URL", "")) + @property def base_url(self) -> str: """Connectable local base URL (wildcard binds map to loopback, IPv6 literals are diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index bcb2618..81fd0ca 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -131,6 +131,13 @@ def create_app() -> FastAPI: @_contextlib.asynccontextmanager async def _lifespan(app: FastAPI): + try: # one-line "update available" notice (background, fail-silent, opt-out) + import logging as _logging + + from engraphis import update_check + update_check.emit_startup_notice(_logging.getLogger("engraphis").info) + except Exception: # noqa: BLE001 - never block dashboard startup + pass if _mcp_asgi is not None: async with _mcp_mgr.run(): yield diff --git a/engraphis/email_outbox.py b/engraphis/email_outbox.py index 732f674..d163562 100644 --- a/engraphis/email_outbox.py +++ b/engraphis/email_outbox.py @@ -202,6 +202,21 @@ def _provider_state(conn: sqlite3.Connection, provider_message_id: str) -> str: return "sent" +def _body_has_license_key(text_body: Optional[str]) -> bool: + """True when the body carries a signed license key (ENGR1..). + + Mirrors inspector/webhooks.py::_existing_license_delivery() extraction so a + purchase/renewal outbox row stays the recoverable source of truth until the + Polar fulfillment claim is finalized; only non-recoverable bodies are redacted + after a successful send. + """ + for entry in str(text_body or "").splitlines(): + candidate = entry.strip() + if candidate.startswith("ENGR1.") and candidate.count(".") == 2: + return True + return False + + def deliver_now(message_id: str, deliverer: Callable[ [str, str, str, Optional[str], str], tuple[str, str]]) -> bool: @@ -242,9 +257,16 @@ def deliver_now(message_id: str, provider_message_id = (provider_id or "")[:160] conn.execute("BEGIN IMMEDIATE") now = time.time() + # A purchased license key in the body is the crash-recovery source of + # truth for inspector/webhooks.py::_existing_license_delivery() until the + # Polar fulfillment claim is finalized; redacting a license row here would + # strand the webhook. Only clear bodies with no recoverable entitlement. + redact_sql = ( + "" if _body_has_license_key(message["text_body"]) + else ",text_body='',reply_to=NULL") updated = conn.execute( "UPDATE email_outbox SET status='sent',provider=?,provider_message_id=?," - "last_error='',sent_at=?,updated_at=? " + "last_error='',sent_at=?,updated_at=?" + redact_sql + " " "WHERE id=? AND status='sending' AND attempts=?", (provider_name, provider_message_id, now, now, message_id, int(message["attempts"]))) diff --git a/engraphis/inspector/license_cloud.py b/engraphis/inspector/license_cloud.py index 897d777..a133170 100644 --- a/engraphis/inspector/license_cloud.py +++ b/engraphis/inspector/license_cloud.py @@ -914,7 +914,7 @@ async def team_invite(request: Request): behalf of a self-hosted Team dashboard that has none configured. 402 if *key* doesn't verify or lacks the ``team`` feature (:func:`license_registry. verify_for_feature` — the same server-side gate every other licensed feature - uses); 400 for a malformed recipient; 409 if a paid key tries to re-aim its invites + uses); 400 for a malformed recipient or a missing/invalid ``invite_url``; 409 if a at a different ``dashboard_url`` than the one it pinned on first use; 429 for a per-IP burst or past the per-key daily cap. Accepted messages are durably queued and retried by the vendor outbox. Trial keys never choose the link (see below), and a ``viewer`` @@ -947,13 +947,16 @@ async def team_invite(request: Request): dashboard_url = cloud_license.validate_cloud_base_url(dashboard_url) except ValueError: return JSONResponse({"error": "invalid dashboard URL"}, status_code=400) - if invite_url: - invite_origin = _auth_link_origin(invite_url, "invite_token") - if invite_origin is None: - return JSONResponse({"error": "invalid invitation URL"}, status_code=400) - if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): - return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) - dashboard_url = invite_origin + if not invite_url: + return JSONResponse( + {"error": "an invitation URL with a one-time invite_token is required"}, + status_code=400) + invite_origin = _auth_link_origin(invite_url, "invite_token") + if invite_origin is None: + return JSONResponse({"error": "invalid invitation URL"}, status_code=400) + if dashboard_url and dashboard_url.rstrip("/") != invite_origin.rstrip("/"): + return JSONResponse({"error": "invitation URL origin mismatch"}, status_code=400) + dashboard_url = invite_origin # Burst-cap before the verify below, for the same reason /register does: this is an # unauthenticated Ed25519 verify on a caller-supplied key. The bucket is deliberately @@ -981,7 +984,7 @@ async def team_invite(request: Request): # A FREE trial key may never aim a vendor-branded invitation at a caller-chosen # origin (branded phishing relay). Trials must target the relay's own trusted # dashboard origin; the one-time accept token in invite_url is preserved. - if not invite_url or dashboard_url.rstrip("/") != _relay_trusted_origin(): + if dashboard_url.rstrip("/") != _relay_trusted_origin(): return JSONResponse( {"error": "trial invitations must target the deployment's own dashboard " "origin (set ENGRAPHIS_DASHBOARD_URL on the relay)"}, diff --git a/engraphis/llm/client.py b/engraphis/llm/client.py index a70c399..f91fff2 100644 --- a/engraphis/llm/client.py +++ b/engraphis/llm/client.py @@ -260,13 +260,10 @@ def _chat_google(self, messages, system, temperature, max_tokens) -> str: if gen_config: body["generationConfig"] = gen_config - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "x-goog-api-key": self.api_key} headers.update(self.extra_headers) - url = ( - f"{self.base_url}/models/{self.model}:generateContent" - f"?key={self.api_key}" - ) + url = f"{self.base_url}/models/{self.model}:generateContent" logger.debug("LLM provider request started") data = self._post_json(url, body, headers) try: diff --git a/engraphis/mcp_server.py b/engraphis/mcp_server.py index 8ca0a7d..e6110be 100644 --- a/engraphis/mcp_server.py +++ b/engraphis/mcp_server.py @@ -119,6 +119,7 @@ def _err(exc: Exception) -> str: "engraphis_verify_receipts", "engraphis_export_receipts", "engraphis_stats", + "engraphis_check_update", }) _ADMIN_TOOLS = frozenset({ "engraphis_consolidate", @@ -990,6 +991,33 @@ def engraphis_stats( return _err(exc) +@mcp.tool( + name="engraphis_check_update", + annotations={"title": "Check for an Engraphis update", "readOnlyHint": True, + "destructiveHint": False, "idempotentHint": True, "openWorldHint": True}, +) +def engraphis_check_update( + force: Annotated[bool, Field(description="Bypass the ~24h cache and re-check the " + "release source now.")] = False, +) -> str: + """Report whether a newer Engraphis release is available, so an agent can proactively + remind the user to upgrade. + + Cached ~24h and fail-silent; honors ``ENGRAPHIS_UPDATE_CHECK=0`` (then ``enabled`` is + false). The default GitHub source is overridable via ``ENGRAPHIS_UPDATE_URL``. + + Returns: + str: JSON ``{"enabled","current","latest","update_available","url","notice"}``. + """ + try: + from engraphis import update_check + snap = dict(update_check.check(force=True) if force else update_check.snapshot()) + snap["notice"] = update_check.notice_line(snap) or "" + return _ok(snap) + except Exception as exc: # noqa: BLE001 + return _err(exc) + + @mcp.tool( name="engraphis_ingest", annotations={"title": "Ingest raw text (extract facts first)", "readOnlyHint": False, diff --git a/engraphis/routes/v2_api.py b/engraphis/routes/v2_api.py index d6f2932..2b4a035 100644 --- a/engraphis/routes/v2_api.py +++ b/engraphis/routes/v2_api.py @@ -239,9 +239,35 @@ def bootstrap(): "stats": _run(current_service.stats, workspace=scoped_stats_workspace), "embedder": emb, "features": {"graph_ui_v2": bool(settings.graph_ui_v2)}, + # Non-blocking best-known update snapshot; the dashboard renders an "update + # available" banner from this and a background refresh warms the cache. + "update": _update_snapshot(), } +def _update_snapshot() -> dict: + """Best-known update snapshot for the dashboard; never raises into bootstrap.""" + try: + from engraphis import update_check + return update_check.snapshot() + except Exception: # noqa: BLE001 - a convenience feature must not break bootstrap + return {"enabled": False, "update_available": False} + + +@router.get("/update") +def api_update(force: bool = False): + """Update-availability snapshot for the dashboard banner. + + Cached ~24h and fail-silent: reports the newest published release vs the installed + version. ``?force=1`` bypasses the cache and re-checks now. + """ + try: + from engraphis import update_check + return update_check.check(force=True) if force else update_check.snapshot() + except Exception: # noqa: BLE001 + return {"enabled": False, "update_available": False} + + # ── workspaces / stats ──────────────────────────────────────────────────────── @router.get("/workspaces") def workspaces(): diff --git a/engraphis/service.py b/engraphis/service.py index d2b439b..15a3bf2 100644 --- a/engraphis/service.py +++ b/engraphis/service.py @@ -40,7 +40,12 @@ from engraphis.core.graph_layers import normalize_graph_layer from engraphis.core.ids import new_id as make_id from engraphis.core.interfaces import Edge, GraphLayer, MemoryType, Node, Scope, SearchFilter -from engraphis.core.store import normalize_entity_name +from engraphis.core.store import ( + _dumps, + _loads, + _merge_edge_provenance, + normalize_entity_name, +) from engraphis.graphdata import build_graph_payload, empty_graph # ── validation limits (memory-poisoning / resource-exhaustion guards) ────────── @@ -2307,14 +2312,78 @@ def _new_repo(old_repo_id): ) # 3) Edges: relabel workspace/repo, remapping any entity ids folded in step 2. + # When a remapped source edge would collide with an existing live edge + # on the unique index (workspace_id, [repo_id,] src, dst, relation, layer), + # merge its evidence rows into the survivor and drop the duplicate. src_edges = [dict(x) for x in c.execute( - "SELECT id, repo_id, src, dst FROM edges WHERE workspace_id=?", (wid_src,))] + "SELECT id, repo_id, src, dst, relation, layer, weight, provenance, " + "valid_from, ingested_at, valid_to, expired_at " + "FROM edges WHERE workspace_id=?", (wid_src,))] for ed in src_edges: + new_src = entity_remap.get(ed["src"], ed["src"]) + new_dst = entity_remap.get(ed["dst"], ed["dst"]) + new_repo = _new_repo(ed["repo_id"]) + is_live = ed["valid_to"] is None and ed["expired_at"] is None + if is_live: + if new_repo is None: + collision = c.execute( + "SELECT id, weight, valid_from, ingested_at, provenance FROM edges " + "WHERE workspace_id=? AND repo_id IS NULL " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL LIMIT 1", + (wid_dst, new_src, new_dst, ed["relation"], ed["layer"]), + ).fetchone() + else: + collision = c.execute( + "SELECT id, weight, valid_from, ingested_at, provenance FROM edges " + "WHERE workspace_id=? AND repo_id=? " + "AND src=? AND dst=? AND relation=? AND layer=? " + "AND valid_to IS NULL AND expired_at IS NULL LIMIT 1", + (wid_dst, new_repo, new_src, new_dst, ed["relation"], ed["layer"]), + ).fetchone() + if collision: + # Fold the source relation into the surviving live target edge, + # mirroring Store._deduplicate_live_edges(): keep the stronger + # weight and merge provenance so a higher manual weight or unique + # audit context on the source is never silently discarded. + merged_provenance = _merge_edge_provenance( + [_loads(collision["provenance"], {}), + _loads(ed["provenance"], {})], + merged_ids=[ed["id"]], + ) + # Keep the earliest temporal anchor so the surviving relation is + # never reported as newer than it truly is, mirroring + # Store._deduplicate_live_edges(). + valid_values = [ + float(v) for v in (collision["valid_from"], ed["valid_from"]) + if v is not None + ] + ingested_values = [ + float(v) for v in (collision["ingested_at"], ed["ingested_at"]) + if v is not None + ] + c.execute( + "UPDATE edges SET weight=?, valid_from=?, ingested_at=?, " + "provenance=? WHERE id=?", + ( + max(float(collision["weight"] or 0.0), + float(ed["weight"] or 0.0)), + min(valid_values) if valid_values else None, + min(ingested_values) if ingested_values else None, + _dumps(merged_provenance), collision["id"], + ), + ) + # Re-point evidence rows onto the survivor, skipping duplicates. + c.execute( + "UPDATE OR IGNORE edge_supports SET edge_id=? WHERE edge_id=?", + (collision["id"], ed["id"]), + ) + c.execute("DELETE FROM edge_supports WHERE edge_id=?", (ed["id"],)) + c.execute("DELETE FROM edges WHERE id=?", (ed["id"],)) + continue c.execute( "UPDATE edges SET workspace_id=?, repo_id=?, src=?, dst=? WHERE id=?", - (wid_dst, _new_repo(ed["repo_id"]), - entity_remap.get(ed["src"], ed["src"]), entity_remap.get(ed["dst"], ed["dst"]), - ed["id"])) + (wid_dst, new_repo, new_src, new_dst, ed["id"])) # 4) Memories / sessions / events: relabel workspace/repo per distinct repo_id # bucket (ids, content and history are untouched). diff --git a/engraphis/static/dashboard.css b/engraphis/static/dashboard.css index 4dad9bb..7a6e171 100644 --- a/engraphis/static/dashboard.css +++ b/engraphis/static/dashboard.css @@ -28,7 +28,7 @@ a{color:var(--accent);text-decoration:none}button{font-family:inherit;cursor:poi .thought{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:8px}.thought-meta{font-size:12px;color:var(--text-dim);margin-bottom:8px;display:flex;gap:6px;align-items:center}.thought-field{margin-bottom:6px}.thought-key{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim)}.thought-val{font-size:16px;line-height:1.6;margin-top:2px} .graph-wrap{display:flex;gap:12px;height:calc(100vh - 120px)}.graph-canvas{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);position:relative;overflow:hidden}.graph-sidebar{width:260px;flex-shrink:0;overflow-y:auto}.graph-stats{position:absolute;top:10px;right:10px;z-index:10;font-size:12px;color:var(--text-dim);background:var(--surface2);padding:5px 10px;border-radius:var(--radius-sm);border:1px solid var(--border)} .force-graph-container{position:relative;width:100%;height:100%}.force-graph-container canvas{display:block;width:100%;height:100%;user-select:none;outline:none;-webkit-tap-highlight-color:transparent}.force-graph-container .clickable{cursor:pointer}.force-graph-container .grabbable{cursor:grab}.force-graph-container .grabbable:active{cursor:grabbing} -#view-graph{max-width:1500px}.graph-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,560px);gap:14px;align-items:stretch}.graph-main{min-width:0}.graph-network{position:relative;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.graph-controls-column{min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:1fr auto;gap:12px;align-items:stretch;align-self:stretch} +#view-graph{max-width:none}.graph-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,560px);gap:14px;align-items:stretch}.graph-main{min-width:0}.graph-network{position:relative;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.graph-controls-column{min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:1fr auto;gap:12px;align-items:stretch;align-self:stretch} /* The rail matches the canvas height exactly. Rather than parking that slack in one card as dead space, the two tall cards absorb it and hand it to their own children via `space-between` — the sliders and the ranked list simply breathe more. Legend keeps its @@ -256,6 +256,13 @@ body{ .diff-ins{border-radius:var(--radius-sm);background:var(--color-success-bg);color:var(--color-success)} .diff-del{border-radius:var(--radius-sm);background:var(--color-danger-bg);color:var(--color-danger);text-decoration:line-through} .trial-banner{margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)} +.update-banner{display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3);padding:var(--space-2) var(--space-3);border:var(--rule) solid var(--color-accent-strong);border-radius:var(--radius-sm);background:var(--accent-bg);color:var(--color-text);font-size:var(--text-xs)} +.update-banner[hidden]{display:none} +.update-banner .ub-text{flex:1;min-width:0} +.update-banner .ub-text code{padding:1px 5px;border-radius:var(--radius-sm);background:var(--surface2);font-family:var(--font-mono,monospace)} +.update-banner .ub-actions{display:flex;align-items:center;gap:var(--space-2);flex-shrink:0} +.update-banner .ub-dismiss{background:none;border:none;color:var(--color-text-dim);cursor:pointer;font-size:16px;line-height:1;padding:0 var(--space-1)} +.update-banner .ub-dismiss:hover{color:var(--color-text)} .empty{padding:var(--space-6) 0;color:var(--color-text-dim);line-height:1.5;text-align:left} .empty .btn{margin-top:var(--space-3)} diff --git a/engraphis/static/dashboard.js b/engraphis/static/dashboard.js index d625c26..126e8e6 100644 --- a/engraphis/static/dashboard.js +++ b/engraphis/static/dashboard.js @@ -584,10 +584,11 @@ function renderHostedBootstrap(message){const intent=getTrialIntent(),selected=i async function showHostedBootstrap(message){HOSTED_BOOTSTRAP=true;try{LIC=await api('/license');updateLicBadge();updateFeatureLocks()}catch(e){}await selectView('settings');renderHostedBootstrap(message);checkHealth();const intent=getTrialIntent();if(intent)setTimeout(()=>startTrialPlan(intent),0)} function loadSettings(){if(HOSTED_BOOTSTRAP){renderHostedBootstrap();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;return}loadLicense();loadSyncStatus();loadApiTokens();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host} -async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} +async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF keeps everything on this machine.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}} function onLlmProvChange(){const p=document.getElementById('llm-prov').value;const sel=document.getElementById('llm-model');const defs={openai:'gpt-4o-mini',anthropic:'claude-3-5-sonnet-20241022',google:'gemini-1.5-flash',openrouter:'openai/gpt-4o-mini'};if(sel&&defs[p]){sel.value=defs[p]}updateLlmSnippet()} function updateLlmSnippet(){const p=(document.getElementById('llm-prov')||{}).value||'openai';const m=(document.getElementById('llm-model')||{}).value||'';const ta=document.getElementById('llm-snippet');if(!ta)return;ta.value='ENGRAPHIS_LLM_PROVIDER='+p+'\nENGRAPHIS_LLM_MODEL='+m+'\nENGRAPHIS_LLM_API_KEY=\nENGRAPHIS_EXTRACTOR=llm_structured\n'} function copyLlmSnippet(){const ta=document.getElementById('llm-snippet');if(!ta)return;ta.select();try{navigator.clipboard.writeText(ta.value);toast('Copied .env snippet','ok')}catch(e){toast('Copy failed — select and Ctrl+C','err')}} +async function setLlmExtractor(on){try{const d=await api('/llm/extractor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!on})});const ok=!!d.extractor_enabled;toast(ok?'LLM extraction turned on — new memories will be sent to your provider':'LLM extraction turned off — memories stay on this machine'+(d.persisted===false?' (could not save for restart)':''),ok?'ok':'muted');loadLlmStatus()}catch(e){toast(e.message,'err')}} async function testLlm(){const r=document.getElementById('llm-test-result');if(r){r.textContent='Testing…';setTone(r,'muted')}try{const d=await api('/llm/test',{method:'POST'});if(r){if(d.ok){const transient=d.auto_enabled&&d.persisted===false;r.textContent=(transient?'⚠ ':'✓ ')+'Connected — '+esc(d.provider)+'/'+esc(d.model)+' replied: '+esc(d.reply||'(empty)')+(transient?' Extraction is active for this process, but the setting could not be saved for restart. Set ENGRAPHIS_EXTRACTOR=llm_structured and ENGRAPHIS_LLM_AUTO_EXTRACT=1 in the deployment environment.':'');setTone(r,transient?'red':'green')}else{r.textContent='✗ '+(d.error||'failed');setTone(r,'red')}}}catch(e){if(r){r.textContent='✗ '+esc(e.message);setTone(r,'red')}}} async function renderTokList(){try{const toks=(await api('/auth/tokens')).tokens||[];const el=document.getElementById('tok-list');if(!el)return;el.innerHTML=toks.length?toks.map(t=>`
${esc(t.label||'(unlabelled)')} · ${t.revoked?'revoked':fmtRel(t.created_at)}${t.last_used_at?' · used '+fmtRel(t.last_used_at):''}${t.revoked?'':``}
`).join(''):'
No tokens yet.
'}catch(e){}} @@ -1264,7 +1265,22 @@ function renderSemBanner(eb){ var reason=eb.error?('
Why the model did not load: '+esc(eb.error)+'
'):''; sb.innerHTML='
Semantic search is offKeyword fallback is active for Recall, Why and Timeline.
The embedder loaded at '+(eb.dim||'?')+'-dim but your memories are 384-dim. To enable meaning-based search, close the dashboard window and re-launch scripts/launch_dashboard.ps1 (Windows) or python -m scripts.start_server — it installs the model automatically (one-time), then hard-refresh this page.'+reason+'
'; } -async function boot(){try{const b=await api('/bootstrap');HOSTED_BOOTSTRAP=false;LIC=b.license;renderSemBanner(b.embedder);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth();renderAuthBanner(false);try{const st=await api('/auth/state');TEAM_ENABLED=!!st.enabled;TEAM_USER=st.enabled?(st.user||null):null;if(st.enabled)updateSessionIndicator(st.user||null)}catch(e){}}catch(e){if(e.status===401){renderAuthBanner(true);if(!AUTH_SKIPPED){try{const st=await api('/auth/state');if(st.enabled){showAuth(st)}else{toast('Boot failed: '+e.message,'err')}}catch(x){toast('Boot failed: '+e.message,'err')}}}else if(e.status===403){await showHostedBootstrap(e.message);resumeTrialClaim()}else{const msg='Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}}} +/* Update reminder banner. Fed by /bootstrap's `update` snapshot (fail-silent server side). + Dismissing hides it until a newer version than the dismissed one ships. Handlers are + wired with addEventListener (no inline attributes) to satisfy the strict dashboard CSP. */ +function renderUpdateBanner(u){ + const el=document.getElementById('update-banner'); + if(!el)return; + if(!u||!u.enabled||!u.update_available||!u.latest){el.hidden=true;el.textContent='';return} + let dismissed='';try{dismissed=localStorage.getItem('engraphis-update-dismissed')||''}catch(e){} + if(dismissed===u.latest){el.hidden=true;el.textContent='';return} + const link=safeUrl(u.url||'https://github.com/Coding-Dev-Tools/engraphis/releases'); + el.innerHTML='
Update available — Engraphis '+esc(u.latest)+' is out (you have '+esc(u.current||'')+'). Upgrade with pip install -U engraphis.
'; + el.hidden=false; + const btn=el.querySelector('.ub-dismiss'); + if(btn)btn.addEventListener('click',function(){try{localStorage.setItem('engraphis-update-dismissed',u.latest)}catch(e){}el.hidden=true;el.textContent=''}); +} +async function boot(){try{const b=await api('/bootstrap');HOSTED_BOOTSTRAP=false;LIC=b.license;renderSemBanner(b.embedder);renderUpdateBanner(b.update);WORKSPACES=b.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}updateLicBadge();updateFeatureLocks();loadOverview();checkHealth();renderAuthBanner(false);try{const st=await api('/auth/state');TEAM_ENABLED=!!st.enabled;TEAM_USER=st.enabled?(st.user||null):null;if(st.enabled)updateSessionIndicator(st.user||null)}catch(e){}}catch(e){if(e.status===401){renderAuthBanner(true);if(!AUTH_SKIPPED){try{const st=await api('/auth/state');if(st.enabled){showAuth(st)}else{toast('Boot failed: '+e.message,'err')}}catch(x){toast('Boot failed: '+e.message,'err')}}}else if(e.status===403){await showHostedBootstrap(e.message);resumeTrialClaim()}else{const msg='Boot failed: '+e.message;document.getElementById('stat-grid').innerHTML='
'+esc(msg)+'
';document.getElementById('ov-types').innerHTML='
Dashboard data could not be loaded.
';document.getElementById('ov-analytics').innerHTML='
Dashboard data could not be loaded.
';toast(msg,'err')}}} initTheme(); // Gate on team auth if enabled, else boot directly. Auth links carry secrets only in the // fragment so HTTP/access logs never receive them. Legacy query credentials are scrubbed @@ -1423,6 +1439,8 @@ h128:function(event){updateLlmSnippet()}, h129:function(event){onLlmProvChange()}, h130:function(event){copyLlmSnippet()}, h131:function(event){testLlm()}, +h150:function(event){setLlmExtractor(true)}, +h151:function(event){setLlmExtractor(false)}, h132:function(event){revokeApiToken(this.dataset.tokenId)}, h133:function(event){createApiToken()}, h134:function(event){configureSyncToken()}, diff --git a/engraphis/static/index.html b/engraphis/static/index.html index 56340a4..47ac506 100644 --- a/engraphis/static/index.html +++ b/engraphis/static/index.html @@ -60,6 +60,7 @@
+
diff --git a/engraphis/update_check.py b/engraphis/update_check.py new file mode 100644 index 0000000..1b2273f --- /dev/null +++ b/engraphis/update_check.py @@ -0,0 +1,318 @@ +"""Update reminder — tell operators when a newer Engraphis release is available. + +Design goals (why this module looks the way it does): + +* **Fail-silent.** A version check is a convenience, never a dependency. Any network + error, malformed payload, or unwritable cache degrades to "no update known" and never + raises into a request handler, the server banner, or an MCP call. +* **Opt-out.** ``ENGRAPHIS_UPDATE_CHECK=0`` disables all network activity. The dashboard, + startup log, and MCP notice then simply report ``enabled=False``. +* **Cheap + shared.** One disk cache (default 24h TTL) backs all three surfaces + (dashboard banner, startup log, MCP notice) so opening the dashboard does not re-hit + the network, and the server boot path never blocks on it. +* **Stdlib-only, config-free import.** Like :mod:`engraphis.netutil`, this stays importable + without dragging in the heavy config/server stack, and reads its knobs straight from the + environment so it is trivially unit-testable offline. + +The default source is the GitHub *releases/latest* endpoint for the project repo, which +excludes drafts/pre-releases server-side. ``ENGRAPHIS_UPDATE_URL`` overrides it with any +endpoint returning a GitHub-release, PyPI, or ``{"version": ..., "url": ...}`` payload. +""" +from __future__ import annotations + +import json +import os +import re +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +from typing import Callable, Optional + +try: # installed distribution → real version; source tree → pinned fallback + from engraphis import __version__ as CURRENT_VERSION +except Exception: # pragma: no cover - engraphis always importable in practice + CURRENT_VERSION = "0" + +# ── tunables ────────────────────────────────────────────────────────────────── +DEFAULT_REPO = "Coding-Dev-Tools/engraphis" +CACHE_TTL_SECONDS = 24 * 3600 +DEFAULT_TIMEOUT = 3.5 # keep short: never stall an interactive request +_MAX_BYTES = 512 * 1024 # cap the response body we are willing to read +_FALSY = {"0", "false", "no", "off", "disable", "disabled"} + +_CACHE_LOCK = threading.Lock() +_REFRESH_LOCK = threading.Lock() +_refreshing = False + + +# ── configuration (read straight from the environment) ──────────────────────── +def enabled() -> bool: + """Update checks are on by default; any falsy ``ENGRAPHIS_UPDATE_CHECK`` disables them.""" + return os.environ.get("ENGRAPHIS_UPDATE_CHECK", "1").strip().lower() not in _FALSY + + +def _endpoint() -> str: + override = os.environ.get("ENGRAPHIS_UPDATE_URL", "").strip() + if override: + return override + repo = os.environ.get("ENGRAPHIS_UPDATE_REPO", DEFAULT_REPO).strip() or DEFAULT_REPO + return "https://api.github.com/repos/%s/releases/latest" % repo + + +def _cache_path() -> Optional[str]: + """A per-user cache file. Prefer sitting next to the DB (already a writable user-data + dir); fall back to the OS temp dir. Returns ``None`` only if nothing is writable.""" + override = os.environ.get("ENGRAPHIS_UPDATE_CACHE", "").strip() + if override: + return override + candidates = [] + try: # optional: keep the cache with the rest of the user's engraphis state + from engraphis.config import settings + + db_dir = os.path.dirname(os.path.abspath(settings.db_path)) + if db_dir: + candidates.append(os.path.join(db_dir, ".engraphis_update_check.json")) + except Exception: # noqa: BLE001 - config unavailable/misconfigured → temp dir + pass + candidates.append(os.path.join(tempfile.gettempdir(), "engraphis_update_check.json")) + return candidates[0] if candidates else None + + +# ── version comparison (pure, offline-testable) ─────────────────────────────── +def parse_version(text: object) -> Optional[tuple]: + """Return the leading numeric release tuple of a version string, or ``None``. + + Tolerates a ``v`` prefix and ignores any pre-release/build suffix so ``"v1.2.3-rc1"`` + and ``"1.2.3"`` both parse to ``(1, 2, 3)``. Non-versions parse to ``None``. + """ + if not isinstance(text, str): + return None + m = re.match(r"\s*[vV]?(\d+(?:\.\d+)*)", text) + if not m: + return None + return tuple(int(part) for part in m.group(1).split(".")) + + +def is_newer(latest: object, current: object) -> bool: + """True iff *latest* is a strictly greater release than *current* (zero-padded compare).""" + lv, cv = parse_version(latest), parse_version(current) + if lv is None or cv is None: + return False + width = max(len(lv), len(cv)) + lv += (0,) * (width - len(lv)) + cv += (0,) * (width - len(cv)) + return lv > cv + + +# ── network ─────────────────────────────────────────────────────────────────── +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """Block redirects entirely — a configured update URL must resolve where it points, + so a crafted 30x cannot bounce the probe at an internal/unexpected host (SSRF guard).""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401 + return None + + +def _parse_release_payload(data: dict) -> Optional[dict]: + """Normalize a GitHub-release / PyPI / generic JSON payload to ``{version, url}``. + + Returns ``None`` for drafts, pre-releases, or payloads without a usable version. + """ + if not isinstance(data, dict): + return None + # GitHub releases/latest + if "tag_name" in data: + if data.get("draft") or data.get("prerelease"): + return None + version = data.get("tag_name") or data.get("name") or "" + url = data.get("html_url") or "" + return {"version": str(version), "url": str(url)} + # PyPI /pypi//json + info = data.get("info") + if isinstance(info, dict) and info.get("version"): + version = str(info["version"]) + url = info.get("project_url") or info.get("home_page") \ + or ("https://pypi.org/project/engraphis/%s/" % version) + return {"version": version, "url": str(url)} + # generic {"version": ..., "url": ...} + if data.get("version"): + return {"version": str(data["version"]), + "url": str(data.get("url") or data.get("html_url") or "")} + return None + + +def _fetch(url: str, timeout: float) -> Optional[dict]: + """Fetch and normalize the latest-release payload. Returns ``None`` on any failure. + + Only ``https`` (or loopback ``http``) endpoints are contacted; redirects are blocked. + """ + scheme, _, rest = url.partition("://") + scheme = scheme.lower() + host = rest.split("/", 1)[0].split("@")[-1].split(":", 1)[0].lower() + loopback = host in ("localhost", "127.0.0.1", "::1", "[::1]") + if scheme != "https" and not (scheme == "http" and loopback): + return None + req = urllib.request.Request(url, headers={ + "User-Agent": "Engraphis/%s update-check" % CURRENT_VERSION, + "Accept": "application/vnd.github+json, application/json;q=0.9, */*;q=0.1", + }) + opener = urllib.request.build_opener(_NoRedirect) + try: + with opener.open(req, timeout=timeout) as resp: # nosec B310 - scheme checked above + raw = resp.read(_MAX_BYTES + 1) + if len(raw) > _MAX_BYTES: + return None + return _parse_release_payload(json.loads(raw.decode("utf-8"))) + except (urllib.error.URLError, urllib.error.HTTPError, ValueError, + TimeoutError, OSError): + return None + + +# ── cache ───────────────────────────────────────────────────────────────────── +def _read_cache() -> dict: + path = _cache_path() + if not path: + return {} + try: + with _CACHE_LOCK, open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _write_cache(latest: str, url: str, error: str = "") -> None: + path = _cache_path() + if not path: + return + payload = {"latest": latest, "url": url, "error": error, "checked_at": time.time()} + try: + with _CACHE_LOCK: + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(payload, fh) + os.replace(tmp, path) + except OSError: + pass # unwritable cache is fine; we just re-probe next time + + +def _snapshot_from_cache(cache: dict) -> dict: + """Build a public snapshot, recomputing ``update_available`` against the *live* + installed version so an upgrade clears the banner immediately (no TTL wait).""" + latest = str(cache.get("latest") or "") + return { + "enabled": True, + "current": CURRENT_VERSION, + "latest": latest, + "update_available": bool(latest) and is_newer(latest, CURRENT_VERSION), + "url": str(cache.get("url") or ""), + "checked_at": float(cache.get("checked_at") or 0.0), + "error": str(cache.get("error") or ""), + } + + +def _disabled_snapshot() -> dict: + return {"enabled": False, "current": CURRENT_VERSION, "latest": "", + "update_available": False, "url": "", "checked_at": 0.0, "error": ""} + + +# ── public API ──────────────────────────────────────────────────────────────── +def check(force: bool = False, timeout: float = DEFAULT_TIMEOUT) -> dict: + """Return the current update snapshot, hitting the network only when the cache is + stale (or *force*). Always safe to call; never raises.""" + if not enabled(): + return _disabled_snapshot() + cache = _read_cache() + fresh = (time.time() - float(cache.get("checked_at") or 0.0)) < CACHE_TTL_SECONDS + if cache and fresh and not force: + return _snapshot_from_cache(cache) + result = _fetch(_endpoint(), timeout) + if result is None: + # Preserve the last good answer; only stamp the failure if we had nothing. + _write_cache(str(cache.get("latest") or ""), str(cache.get("url") or ""), + error="update check unavailable") + return _snapshot_from_cache(_read_cache()) + _write_cache(result["version"], result.get("url", ""), error="") + return _snapshot_from_cache(_read_cache()) + + +def snapshot() -> dict: + """Non-blocking best-known snapshot for hot paths (bootstrap, startup, MCP). + + Returns whatever the cache holds immediately and, if it is stale/missing, kicks a + single background refresh so the *next* read is current. Never performs network I/O + on the calling thread. + """ + if not enabled(): + return _disabled_snapshot() + cache = _read_cache() + fresh = cache and (time.time() - float(cache.get("checked_at") or 0.0)) < CACHE_TTL_SECONDS + if not fresh: + refresh_in_background() + return _snapshot_from_cache(cache) + + +def refresh_in_background(timeout: float = DEFAULT_TIMEOUT) -> None: + """Warm the cache on a daemon thread, at most one refresh in flight. Fail-silent.""" + global _refreshing + if not enabled(): + return + with _REFRESH_LOCK: + if _refreshing: + return + _refreshing = True + + def _run() -> None: + global _refreshing + try: + check(force=True, timeout=timeout) + except Exception: # noqa: BLE001 - background best-effort, never surface + pass + finally: + with _REFRESH_LOCK: + _refreshing = False + + threading.Thread(target=_run, name="engraphis-update-check", daemon=True).start() + + +def notice_line(snap: Optional[dict] = None) -> Optional[str]: + """One-line human notice, or ``None`` when no update is available / checks are off.""" + snap = snap if snap is not None else snapshot() + if not snap.get("enabled") or not snap.get("update_available"): + return None + latest, current = snap.get("latest") or "?", snap.get("current") or "?" + url = snap.get("url") or "" + tail = " — %s" % url if url else "" + return ("Engraphis %s is available (you have %s). Upgrade: pip install -U engraphis%s" + % (latest, current, tail)) + + +def emit_startup_notice(emit: Optional[Callable[[str], None]] = None, + timeout: float = DEFAULT_TIMEOUT) -> None: + """Fire-and-forget: emit a one-line "update available" notice shortly after startup. + + Runs the (cache-respecting) check on a daemon thread so server boot is never blocked or + delayed by the network. *emit* defaults to a stderr print; pass a logger method (e.g. + ``logger.info``) to route it into structured logs. Fail-silent and a no-op when checks + are disabled or no update is available. + """ + if not enabled(): + return + printer = emit if emit is not None else ( + lambda line: print("[engraphis] %s" % line, file=sys.stderr)) + + def _run() -> None: + try: + line = notice_line(check(timeout=timeout)) + except Exception: # noqa: BLE001 - never surface from a background notice + return + if line: + try: + printer(line) + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_run, name="engraphis-update-notice", daemon=True).start() diff --git a/skills/engraphis-memory/SKILL.md b/skills/engraphis-memory/SKILL.md index c2ca366..4412e59 100644 --- a/skills/engraphis-memory/SKILL.md +++ b/skills/engraphis-memory/SKILL.md @@ -142,6 +142,6 @@ is needed for the memory layer. Details: the repo `README.md` "Quickstart A — ## References -- [TOOLS.md](references/TOOLS.md) — all 28 tools: parameters, defaults, returns, when to reach for each. +- [TOOLS.md](references/TOOLS.md) — all 29 tools: parameters, defaults, returns, when to reach for each. - [SCOPING.md](references/SCOPING.md) — the `workspace → repo → session → memory` model, scope vs. type, and promotion. - [CONVENTIONS.md](references/CONVENTIONS.md) — memory types, provenance, importance, dedup/resolution, governance, and anti-patterns diff --git a/skills/engraphis-memory/references/TOOLS.md b/skills/engraphis-memory/references/TOOLS.md index e33f9f0..de58d18 100644 --- a/skills/engraphis-memory/references/TOOLS.md +++ b/skills/engraphis-memory/references/TOOLS.md @@ -1,6 +1,6 @@ # Engraphis MCP tools — reference -All 28 tools, grouped by job. Parameters are `name (type, default)` — no default means required. +All 29 tools, grouped by job. Parameters are `name (type, default)` — no default means required. Every tool returns a JSON string; on failure it returns `"Error: "` instead of raising. Governance tools (`forget`/`pin`/`correct`/`link`) verify the memory actually belongs to the `workspace`/`repo` you pass **before** changing anything, so you can't touch memories outside a @@ -315,6 +315,15 @@ Memory counts (overall or for one workspace) — handy for onboarding/health che Returns `{memories, by_type, workspaces, sessions, schema_version}`. +### `engraphis_check_update` +Report whether a newer Engraphis release is available, so an agent can proactively remind the +user to upgrade. Cached ~24h and fail-silent; honors `ENGRAPHIS_UPDATE_CHECK=0` (then `enabled` +is false). The default GitHub source is overridable via `ENGRAPHIS_UPDATE_URL`. + +- `force (bool, false)` — bypass the ~24h cache and re-check the release source now. + +Returns `{enabled, current, latest, update_available, url, notice}`. + --- ## Quick decision guide diff --git a/tests/test_cloud_license.py b/tests/test_cloud_license.py index 75245e2..3e14fab 100644 --- a/tests/test_cloud_license.py +++ b/tests/test_cloud_license.py @@ -1107,7 +1107,8 @@ def test_team_invite_relay_sends_with_valid_team_key(monkeypatch): def test_team_invite_relay_rejects_non_team_key(): c = _app() r = c.post("/license/v1/team-invite", - json={"key": _key(plan="pro"), "to": "new@corp.com"}) + json={"key": _key(plan="pro"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 402 and "team" in r.json()["error"].lower() @@ -1118,7 +1119,9 @@ def test_team_invite_relay_rejects_revoked_key(monkeypatch): key = _key(plan="team") reg.record_issued(key) # must be a known row for revoke to apply reg.revoke(parse_key(key).key_id) - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 402 @@ -1181,14 +1184,19 @@ def test_team_invite_relay_enforces_daily_cap_per_key(monkeypatch): c = _app() key = _key(plan="team") for _ in range(2): - r = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 200 - over = c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com"}) + over = c.post("/license/v1/team-invite", + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert over.status_code == 429 and "limit" in over.json()["error"].lower() # a DIFFERENT key is unaffected by another key's cap other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), - "to": "new@corp.com"}) + "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert other.status_code == 200 @@ -1204,13 +1212,15 @@ def boom(*a, **k): c = _app() key = _key(plan="team") r = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 502 assert "RESEND_SECRET_123" not in r.text and "private" not in r.text # A failed durable enqueue does not consume the accepted-message cap. monkeypatch.setattr(WH, "queue_team_invite_email", lambda *a, **k: None) retry = c.post("/license/v1/team-invite", - json={"key": key, "to": "new@corp.com"}) + json={"key": key, "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert retry.status_code == 200 @@ -1223,7 +1233,8 @@ def test_team_invite_request_retry_reuses_one_durable_outbox_operation(monkeypat ): monkeypatch.delenv(name, raising=False) c = _app() - body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member"} + body = {"key": _key(plan="team"), "to": "new@corp.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} first = c.post("/license/v1/team-invite", json=body) retry = c.post("/license/v1/team-invite", json=body) @@ -1254,7 +1265,8 @@ def test_team_invite_refund_failure_keeps_provider_error_sanitized(monkeypatch): lambda *a: (_ for _ in ()).throw(OSError("C:/private/relay.db"))) response = _app().post( "/license/v1/team-invite", - json={"key": _key(plan="team"), "to": "new@corp.com"}) + json={"key": _key(plan="team"), "to": "new@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert response.status_code == 502 assert "SECRET" not in response.text and "private" not in response.text @@ -1300,7 +1312,8 @@ def test_trial_key_cannot_choose_the_dashboard_url_in_a_vendor_email(monkeypatch assert parse_key(key).is_trial is True r = _app().post("/license/v1/team-invite", json={"key": key, "to": "victim@corp.com", - "dashboard_url": "https://engraphis-team.attacker.test/"}) + "dashboard_url": "https://engraphis-team.attacker.test/", + "invite_url": "https://engraphis-team.attacker.test/#invite_token=one-time-secret"}) # A legacy/unbound trial key has no verified deployment origin, so it cannot use the # vendor's mail reputation to send any link at all. Deployment-bound trials pass an # invite URL whose origin is checked against their confirmed claim. @@ -1318,12 +1331,14 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): seen.append(dashboard_url)) c = _app() key = _key(plan="team") - body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/"} + body = {"key": key, "to": "new@corp.com", "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=one-time-secret"} assert c.post("/license/v1/team-invite", json=body).status_code == 200 # the same URL keeps working... assert c.post("/license/v1/team-invite", json=body).status_code == 200 # ...a different one is refused, and never reaches the mail provider - moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/") + moved = dict(body, dashboard_url="https://engraphis-team.attacker.test/", + invite_url="https://engraphis-team.attacker.test/#invite_token=one-time-secret") r = c.post("/license/v1/team-invite", json=moved) assert r.status_code == 409 and "dashboard url" in r.json()["error"].lower() # validate_cloud_base_url canonicalizes before the pin is taken, so an equivalent @@ -1333,7 +1348,8 @@ def test_paid_key_pins_its_dashboard_url_on_first_use(monkeypatch): other = c.post("/license/v1/team-invite", json={"key": _key(plan="team", email="other@corp.com"), "to": "new@corp.com", - "dashboard_url": "https://other.example/"}) + "dashboard_url": "https://other.example/", + "invite_url": "https://other.example/#invite_token=one-time-secret"}) assert other.status_code == 200 @@ -1346,15 +1362,18 @@ def test_rejected_dashboard_url_does_not_consume_the_daily_invite_cap(monkeypatc key = _key(plan="team") assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=one-time-secret"}).status_code == 200 for _ in range(3): assert c.post("/license/v1/team-invite", json={"key": key, "to": "a@corp.com", - "dashboard_url": "https://evil.test/"}).status_code == 409 + "dashboard_url": "https://evil.test/", + "invite_url": "https://evil.test/#invite_token=one-time-secret"}).status_code == 409 # the one remaining legitimate send is still available assert c.post("/license/v1/team-invite", json={"key": key, "to": "b@corp.com", - "dashboard_url": "https://team.corp.example/"}).status_code == 200 + "dashboard_url": "https://team.corp.example/", + "invite_url": "https://team.corp.example/#invite_token=one-time-secret"}).status_code == 200 def test_relay_invite_never_forwards_the_license_key(monkeypatch): @@ -1369,7 +1388,8 @@ def test_relay_invite_never_forwards_the_license_key(monkeypatch): for role in ("viewer", "member"): assert c.post("/license/v1/team-invite", json={"key": key, "to": "new@corp.com", - "role": role}).status_code == 200 + "role": role, + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}).status_code == 200 assert "key" not in seen["viewer"] assert "key" not in seen["member"] @@ -1408,7 +1428,7 @@ def test_send_team_invite_client_roundtrip(monkeypatch): _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( "http://127.0.0.1", _key(plan="team"), "new@corp.com", "Mo", "member", - "admin@corp.com") + "admin@corp.com", invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is True and reason == "" assert captured["to"] == "new@corp.com" @@ -1417,7 +1437,8 @@ def test_send_team_invite_client_reports_reason_on_402(monkeypatch): c = _app() _wire_urlopen_to(c, monkeypatch) sent, reason = cloud_license.send_team_invite( - "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com") + "http://127.0.0.1", _key(plan="pro"), "new@corp.com", "Mo", "member", "a@b.com", + invite_url="https://team.customer.test/#invite_token=one-time-secret") assert sent is False and "team" in reason.lower() @@ -2060,7 +2081,9 @@ def test_legacy_team_trial_key_cannot_use_deployment_bound_invite_relay(monkeypa c = _app() captured = _capture_verify_url(monkeypatch) key = _start_and_confirm(c, captured, "dev-1") - r = c.post("/license/v1/team-invite", json={"key": key, "to": "teammate@corp.com"}) + r = c.post("/license/v1/team-invite", + json={"key": key, "to": "teammate@corp.com", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"}) assert r.status_code == 409 assert "dashboard origin" in r.json()["error"] assert captured_invite == {} diff --git a/tests/test_email_outbox_retention.py b/tests/test_email_outbox_retention.py new file mode 100644 index 0000000..6dd285a --- /dev/null +++ b/tests/test_email_outbox_retention.py @@ -0,0 +1,41 @@ +"""Regression (2026-07-20 audit): the outbox must not retain the rendered email body +(raw license keys / reset+invite links) after a message is handed to the provider; a +FAILED message keeps its body so an operator requeue can still retry it.""" +from engraphis import email_outbox + + +def test_body_and_reply_to_cleared_after_successful_send(): + mid = email_outbox.enqueue( + "license", "buyer@x.co", "Your key", + "key ENGR1.secretpayload.sig reset https://x/#reset_token=abc", + reply_to="support@x.co", idempotency_key="k-ret-1") + assert email_outbox.deliver_now( + mid, lambda to, subj, body, reply, mid_: ("resend", "pm_1")) + conn = email_outbox._connect() + try: + row = conn.execute("SELECT status,text_body,reply_to FROM email_outbox WHERE id=?", + (mid,)).fetchone() + finally: + conn.close() + assert row["status"] in ("sent", "delivered", "bounced", "complained") + assert row["text_body"] == "" and row["reply_to"] is None + + +def test_failed_message_retains_body_for_retry(): + mid = email_outbox.enqueue("license", "b@x.co", "s", "important ENGR1.k.k body", + idempotency_key="k-ret-2", max_attempts=1) + + def boom(to, subj, body, reply, mid_): + raise RuntimeError("provider down") + + try: + email_outbox.deliver_now(mid, boom) + except RuntimeError: + pass + conn = email_outbox._connect() + try: + row = conn.execute("SELECT status,text_body FROM email_outbox WHERE id=?", + (mid,)).fetchone() + finally: + conn.close() + assert row["status"] == "failed" and "ENGR1" in row["text_body"] diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py index 9092d36..9f9dd86 100644 --- a/tests/test_llm_config.py +++ b/tests/test_llm_config.py @@ -48,8 +48,10 @@ def test_persist_project_env_rejects_unsafe_assignments(tmp_path, values): persist_project_env(values, path=tmp_path / ".env") -def test_llm_auto_extract_defaults_on_and_accepts_explicit_off(monkeypatch): +def test_llm_auto_extract_defaults_off_and_accepts_explicit_on(monkeypatch): monkeypatch.delenv("ENGRAPHIS_LLM_AUTO_EXTRACT", raising=False) + assert Settings().llm_auto_extract is False + monkeypatch.setenv("ENGRAPHIS_LLM_AUTO_EXTRACT", "1") assert Settings().llm_auto_extract is True monkeypatch.setenv("ENGRAPHIS_LLM_AUTO_EXTRACT", "off") assert Settings().llm_auto_extract is False diff --git a/tests/test_security_hardening_2026_07_18.py b/tests/test_security_hardening_2026_07_18.py index 1e18171..e74d65c 100644 --- a/tests/test_security_hardening_2026_07_18.py +++ b/tests/test_security_hardening_2026_07_18.py @@ -193,7 +193,8 @@ def test_m2_team_invite_rate_limits_invalid_key_flood(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 3) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + body = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} statuses = [client.post("/license/v1/team-invite", json=body).status_code for _ in range(5)] assert 429 in statuses, statuses @@ -206,7 +207,8 @@ def test_m2_invite_and_register_share_one_burst_budget(monkeypatch): monkeypatch.setattr(license_cloud, "REGISTER_RATE_PER_MINUTE", 2) license_cloud._REGISTER_BUCKETS.clear() client = _relay_client() - invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member"} + invite = {"key": "ENGR1.aaaa.bbbb", "to": "x@example.com", "role": "member", + "invite_url": "https://team.customer.test/#invite_token=one-time-secret"} register = {"key": "ENGR1.aaaa.bbbb", "machine_id": "dev-1"} assert client.post("/license/v1/register", json=register).status_code != 429 assert client.post("/license/v1/team-invite", json=invite).status_code != 429 diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..27a795d --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,196 @@ +"""Offline tests for the update-reminder module (engraphis.update_check). + +Everything here is deterministic and network-free: version math is pure, and the one +code path that would hit the network (``_fetch``) is either monkeypatched or exercised +only on inputs it rejects *before* opening a socket. +""" +from __future__ import annotations + +import json + +import pytest + +from engraphis import update_check as u + + +# ── pure version math ───────────────────────────────────────────────────────── +@pytest.mark.parametrize("text,expected", [ + ("1.2.3", (1, 2, 3)), + ("v1.2.3", (1, 2, 3)), + (" V2.0 ", (2, 0)), + ("1.2.3-rc1", (1, 2, 3)), + ("1.0.0+build.5", (1, 0, 0)), + ("10.4", (10, 4)), + ("nightly", None), + ("", None), + (None, None), + (123, None), +]) +def test_parse_version(text, expected): + assert u.parse_version(text) == expected + + +@pytest.mark.parametrize("latest,current,newer", [ + ("1.1.0", "1.0.0", True), + ("1.0.1", "1.0.0", True), + ("2.0", "1.9.9", True), + ("1.0.0", "1.0.0", False), # equal is not newer + ("1.0", "1.0.0", False), # zero-padded equal + ("0.9.9", "1.0.0", False), + ("v1.2.0", "1.1.5", True), # tolerates the v prefix on both sides + ("garbage", "1.0.0", False), # unparseable → never newer + ("1.0.0", "garbage", False), +]) +def test_is_newer(latest, current, newer): + assert u.is_newer(latest, current) is newer + + +# ── payload normalization ───────────────────────────────────────────────────── +def test_parse_github_release(): + got = u._parse_release_payload({ + "tag_name": "v1.4.0", "html_url": "https://example/releases/tag/v1.4.0", + "draft": False, "prerelease": False, + }) + assert got == {"version": "v1.4.0", "url": "https://example/releases/tag/v1.4.0"} + + +def test_parse_github_rejects_draft_and_prerelease(): + assert u._parse_release_payload({"tag_name": "v2", "draft": True}) is None + assert u._parse_release_payload({"tag_name": "v2", "prerelease": True}) is None + + +def test_parse_pypi_payload(): + got = u._parse_release_payload({"info": {"version": "1.5.0"}}) + assert got["version"] == "1.5.0" + assert "1.5.0" in got["url"] + + +def test_parse_generic_and_garbage(): + assert u._parse_release_payload({"version": "3.0", "url": "https://x/y"}) == { + "version": "3.0", "url": "https://x/y"} + assert u._parse_release_payload({"nope": 1}) is None + assert u._parse_release_payload("not a dict") is None + + +# ── network guard (no socket opened for a bad scheme/host) ──────────────────── +@pytest.mark.parametrize("url", [ + "http://example.com/releases", # plain http, non-loopback + "ftp://example.com/x", + "file:///etc/passwd", +]) +def test_fetch_rejects_unsafe_schemes(url): + assert u._fetch(url, timeout=0.01) is None + + +# ── endpoint / opt-out configuration ────────────────────────────────────────── +def test_endpoint_default_and_overrides(monkeypatch): + monkeypatch.delenv("ENGRAPHIS_UPDATE_URL", raising=False) + monkeypatch.delenv("ENGRAPHIS_UPDATE_REPO", raising=False) + assert u._endpoint() == "https://api.github.com/repos/%s/releases/latest" % u.DEFAULT_REPO + monkeypatch.setenv("ENGRAPHIS_UPDATE_REPO", "acme/thing") + assert u._endpoint().endswith("/repos/acme/thing/releases/latest") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://mirror/latest.json") + assert u._endpoint() == "https://mirror/latest.json" # explicit URL wins over repo + + +def test_disabled_opt_out(monkeypatch): + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "0") + assert u.enabled() is False + # A hard failure if any network is attempted while disabled. + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("must not hit network")) + snap = u.check() + assert snap == u._disabled_snapshot() + assert snap["enabled"] is False and snap["update_available"] is False + assert u.notice_line(snap) is None + + +# ── cache + snapshot behavior ───────────────────────────────────────────────── +@pytest.fixture +def cache(tmp_path, monkeypatch): + """Isolate the on-disk cache and force checks enabled with a known endpoint.""" + path = tmp_path / "update.json" + monkeypatch.setenv("ENGRAPHIS_UPDATE_CACHE", str(path)) + monkeypatch.setenv("ENGRAPHIS_UPDATE_CHECK", "1") + monkeypatch.setenv("ENGRAPHIS_UPDATE_URL", "https://example.test/latest") + return path + + +def test_check_fetches_writes_cache_and_reports_update(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + monkeypatch.setattr(u, "_fetch", + lambda url, timeout: {"version": "1.4.0", "url": "https://rel/1.4.0"}) + snap = u.check(force=True) + assert snap["update_available"] is True + assert snap["latest"] == "1.4.0" and snap["current"] == "1.0.0" + assert snap["url"] == "https://rel/1.4.0" + # cache persisted + saved = json.loads(cache.read_text()) + assert saved["latest"] == "1.4.0" and saved["checked_at"] > 0 + + +def test_fresh_cache_short_circuits_network(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.3.0", "https://rel/1.3.0") + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("fresh cache must not refetch")) + snap = u.check() # not forced → should use the fresh cache + assert snap["latest"] == "1.3.0" and snap["update_available"] is True + + +def test_upgrade_clears_banner_without_ttl_wait(cache, monkeypatch): + """After the user upgrades, a still-fresh cache whose ``latest`` == installed version + must report no update — update_available is recomputed against the live version.""" + u._write_cache("1.4.0", "https://rel/1.4.0") + monkeypatch.setattr(u, "CURRENT_VERSION", "1.4.0") # simulate the just-installed upgrade + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("no network needed")) + snap = u.check() + assert snap["update_available"] is False + + +def test_fetch_failure_preserves_last_good(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + u._write_cache("1.4.0", "https://rel/1.4.0") + # Expire the cache so check() attempts a refresh, then have the network fail. + stale = json.loads(cache.read_text()) + stale["checked_at"] = 0.0 + cache.write_text(json.dumps(stale)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: None) + snap = u.check() + assert snap["latest"] == "1.4.0" and snap["update_available"] is True # last good kept + + +def test_snapshot_is_non_blocking(cache, monkeypatch): + monkeypatch.setattr(u, "CURRENT_VERSION", "1.0.0") + called = {"bg": False} + monkeypatch.setattr(u, "refresh_in_background", lambda *a, **k: called.__setitem__("bg", True)) + monkeypatch.setattr(u, "_fetch", lambda *a, **k: pytest.fail("snapshot must not fetch inline")) + snap = u.snapshot() # empty cache → returns immediately, schedules a background refresh + assert snap["update_available"] is False + assert called["bg"] is True + + +def test_notice_line(monkeypatch): + line = u.notice_line({"enabled": True, "update_available": True, + "latest": "1.4.0", "current": "1.0.0", "url": "https://rel/1.4.0"}) + assert "1.4.0" in line and "1.0.0" in line and "pip install -U engraphis" in line + assert u.notice_line({"enabled": True, "update_available": False}) is None + + +def test_api_update_endpoint(monkeypatch): + pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") + from engraphis.routes import v2_api + monkeypatch.setattr(u, "snapshot", + lambda: {"enabled": True, "update_available": True, "latest": "1.4.0"}) + out = v2_api.api_update(force=False) + assert out["update_available"] is True and out["latest"] == "1.4.0" + + +def test_api_update_never_raises(monkeypatch): + pytest.importorskip("fastapi", reason="v2_api requires fastapi (extras)") + from engraphis.routes import v2_api + + def boom(): + raise RuntimeError("nope") + + monkeypatch.setattr(u, "snapshot", boom) + out = v2_api.api_update(force=False) + assert out == {"enabled": False, "update_available": False} diff --git a/tests/test_workspace_ops.py b/tests/test_workspace_ops.py index afc300c..34d5340 100644 --- a/tests/test_workspace_ops.py +++ b/tests/test_workspace_ops.py @@ -322,6 +322,59 @@ def test_merge_rejects_same_and_missing_workspaces(): svc.merge_workspaces("ghost", "a") # missing source + +def test_merge_deduplicates_colliding_live_edges(): + """When both workspaces hold the same live workspace-level relation (X related Y), + step 2 folds the source entities onto the target IDs. Without dedup, the edge + relabel produces two rows matching the v4 live-edge unique index and the merge + rolls back. The fix merges evidence into the survivor and drops the duplicate.""" + svc = _svc() + c = svc.store.conn + svc.create_workspace("a") + svc.create_workspace("b") + wid_a = _wsid(svc, "a") + wid_b = _wsid(svc, "b") + # Explicit entity IDs with a stable string order (alpha < beta) so the + # "related" relation canonicalizes both edges to the same (src, dst) pair + # regardless of id-generation order — the collision is deterministic. + svc.store.upsert_entity(Node(id="ent_a_alpha", name="Alpha", ntype="concept", workspace_id=wid_a)) + svc.store.upsert_entity(Node(id="ent_a_beta", name="Beta", ntype="concept", workspace_id=wid_a)) + svc.store.upsert_entity(Node(id="ent_b_alpha", name="Alpha", ntype="concept", workspace_id=wid_b)) + svc.store.upsert_entity(Node(id="ent_b_beta", name="Beta", ntype="concept", workspace_id=wid_b)) + # Identical live workspace-level edges (repo_id=None) in both workspaces. + svc.store.upsert_edge(Edge( + id="edge_from_a", src="ent_a_alpha", dst="ent_a_beta", relation="related", + workspace_id=wid_a, + provenance={"memory_id": "mem_a", "memory_ids": ["mem_a"]}, + )) + svc.store.upsert_edge(Edge( + id="edge_from_b", src="ent_b_alpha", dst="ent_b_beta", relation="related", + workspace_id=wid_b, + provenance={"memory_id": "mem_b", "memory_ids": ["mem_b"]}, + )) + + # Must not raise IntegrityError on the live-edge unique index. + out = svc.merge_workspaces("a", "b") + assert out["target"] == "b" + + # Exactly one live edge survives under the target workspace. + live = [dict(r) for r in c.execute( + "SELECT id FROM edges WHERE workspace_id=? AND valid_to IS NULL AND expired_at IS NULL", + (wid_b,))] + assert len(live) == 1 + survivor = live[0]["id"] + + # Evidence from both workspaces is merged onto the survivor. + supports = {r["memory_id"] for r in c.execute( + "SELECT memory_id FROM edge_supports WHERE edge_id=? " + "AND valid_to IS NULL AND expired_at IS NULL", + (survivor,))} + assert supports == {"mem_a", "mem_b"} + + # The duplicate edge row is gone. + assert c.execute("SELECT 1 FROM edges WHERE id='edge_from_a'").fetchone() is None + + # ── copy_workspace ─────────────────────────────────────────────────────────── def test_copy_auto_names_and_leaves_source_untouched(): svc = _svc()