diff --git a/Makefile.cbm b/Makefile.cbm index 1c261f66e..f1430d943 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -413,6 +413,8 @@ CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/ UI_SRCS = \ src/ui/config.c \ src/ui/http_server.c \ + src/ui/activity.c \ + src/ui/atlas_repository.c \ src/ui/layout3d.c \ src/ui/layout_regions.c \ src/ui/atlas_api.c \ @@ -420,6 +422,7 @@ UI_SRCS = \ src/ui/atlas_handout.c \ src/ui/atlas_why.c \ src/ui/atlas_metrics.c \ + src/ui/atlas_impact.c \ src/ui/httpd.c \ src/ui/embedded_stub.c @@ -774,7 +777,7 @@ $(BUILD_DIR)/test-foundation: $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(PROJE $(CC) $(CFLAGS_TEST) -o $@ $(TEST_FOUNDATION_SRCS) $(FOUNDATION_SRCS) $(LDFLAGS_TEST) test-foundation: $(BUILD_DIR)/test-foundation - cd $(CURDIR) && $(BUILD_DIR)/test-foundation + cd "$(CURDIR)" && $(BUILD_DIR)/test-foundation # ── Grammar/TS/LSP object files (compiled with relaxed warnings) ─ @@ -914,13 +917,13 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_ $(LDFLAGS_TEST) test: $(BUILD_DIR)/test-runner - cd $(CURDIR) && $(BUILD_DIR)/test-runner + cd "$(CURDIR)" && $(BUILD_DIR)/test-runner # Parallel variant: every suite as its own process; identical gate quality # (see the ZERO-LOSS CONTRACT in scripts/run-tests-parallel.sh — union guard # against --list-suites + aggregated pass/fail/skip totals). test-par: $(BUILD_DIR)/test-runner - cd $(CURDIR) && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner + cd "$(CURDIR)" && bash scripts/run-tests-parallel.sh $(BUILD_DIR)/test-runner # Focused native development is intentionally a separate, explicit target so # an inherited TEST_SUITES value cannot silently narrow the gating test target. @@ -928,7 +931,7 @@ TEST_SUITES ?= test-focused: $(BUILD_DIR)/test-runner @test -n "$(strip $(TEST_SUITES))" || \ (echo "TEST_SUITES is required for test-focused"; exit 2) - cd $(CURDIR) && $(BUILD_DIR)/test-runner $(TEST_SUITES) + cd "$(CURDIR)" && $(BUILD_DIR)/test-runner $(TEST_SUITES) # ── Cumulative bug-reproduction runner (RED board by design, non-gating) ── # Mirrors test-runner's link line but uses repro_main.c (own main + counters) diff --git a/graph-ui/README.md b/graph-ui/README.md index c5cd67d4e..67d604691 100644 --- a/graph-ui/README.md +++ b/graph-ui/README.md @@ -12,9 +12,8 @@ What the maintainers added on top: embeds `dist/` into the binary. The dev server (`npm run dev`) listens on 5173 and proxies `/rpc` and `/api` to a server on 9749, which is the contract `tests/test_ui_dev_proxy_security.sh` pins. -- **CSP.** The served page may reach the server itself plus two loopback - services the reader can start: the local-model sidecar on 127.0.0.1:4141 - and the agent bridge on 127.0.0.1:4142 (`src/ui/http_server.h`, +- **CSP.** The served page may reach the server itself plus the loopback + service explicitly allowed for optional local inference (`src/ui/http_server.h`, `CBM_UI_CSP_VALUE`). Nothing else, and a test holds that. - **The `[p]rojects` panel** (alt+p, `src/projects/`): index a repository, check or remove an index, edit the decision record, read the server's @@ -106,90 +105,98 @@ Agent ein kleiner leuchtender Koerper, der den Symbolknoten umkreist, an dem er gerade arbeitet, und unten rechts ein kompaktes Instrument, das erklaert, was man sieht. Der Weg dorthin hat drei Stationen. -### 1. Die Quelle: eine Ereignisdatei +### Recorded tool activity through the daemon -Ein Werkzeug-Hook schreibt eine JSON-Zeile je Werkzeugaufruf, angehaengt an eine -Datei (JSONL). Die Hook-Skripte dieses Repositories liegen unter -`agents/hooks/`: +`agents/hooks/atlas-trace.py` is the supported PostToolUse producer. Configure +`ATLAS_PROJECT` to the exact indexed project name, `ATLAS_AGENT_NAME` for its +label, and (only for a custom port) `ATLAS_DAEMON_URL=http://127.0.0.1:PORT`. +The **Connect your agent** disclosure in Agent activity downloads this exact +hook and generates a project-specific install command using `/api/repo-info`. +On macOS/Linux with Python 3, run the reviewed command: -- `agents/hooks/atlas-trace.py` als PostToolUse-Hook. Er endet unter allen - Umstaenden mit 0: ein Protokoll, das die Arbeit aufhalten kann, waere - schlimmer als kein Protokoll. -- `agents/hooks/atlas-trace-watch.mjs` als zweite Quelle. Sie beobachtet - Schreibvorgaenge auf der Platte, sieht also **nur** Schreiben und weder Lesen - noch Suchen noch Testlaeufe, und kennzeichnet jedes Ereignis entsprechend - (`source: "fs"`). - -Wohin geschrieben wird, sagt `ATLAS_TRACE_FILE`; ohne die Variable ist es -`~/.atlas-trace/events.jsonl`. Wie der Agent heisst, sagt `ATLAS_AGENT_NAME`. - -Einrichtung in der Hook-Konfiguration des Agenten-Werkzeugs (bei Werkzeugen mit -einem `PostToolUse`-Hook ist das eine `settings.json` in deren -Heimatverzeichnis): - -```json -{ - "hooks": { - "PostToolUse": [ - { "matcher": "*", - "hooks": [{ "type": "command", - "command": "python3 /agents/hooks/atlas-trace.py" }] } - ] - } -} +```sh +python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root /absolute/repository --project INDEXED_NAME --daemon-url http://127.0.0.1:9749 ``` -**Das Format**, eine Zeile je Ereignis: +Installation is explicit and local: it preserves unrelated Claude settings, +adds a PostToolUse entry to `.claude/settings.local.json`, and copies the hook +into `.claude/hooks/`. It refuses conflicting hooks, symlinks and malformed +configuration. It makes no network request or account change. Start a new Claude +Code session after installation, then load recorded activity in the browser. +Other coding clients require their own event adapter; they are not automatically +observed. For manual configuration, run `python3 /atlas-trace.py` in the +client's PostToolUse hook. Its JSON tool event arrives on stdin. During normal +hook execution it always exits zero and does not change the tool call's result. -| Feld | Bedeutung | -| --- | --- | -| `ts` | Millisekunden seit 1970 | -| `agent` | Anzeigename des Agenten | -| `run` | Kennung eines Laufs | -| `seq` | fortlaufend je Lauf | -| `phase` | `start` oder `end` | -| `tool` | Werkzeugname | -| `path` | optional, repo-relativer Pfad | -| `lines` | optional, `[von, bis]` | -| `detail` | optional, Befehl oder Suchmuster | -| `intent` | optional, Selbstauskunft des Agenten | +The hook records timestamp, tool name, run ID, sequence number, path and optional +line span/command. The hook records no file contents and no tool output. Commands +and paths may still contain sensitive information: the entire data path stays +on this workstation. The hook refuses external destinations, proxies and redirects. -Unbekannte Felder werden durchgereicht und nicht als Fehler behandelt: jede -andere Quelle, die dasselbe Format schreibt, wird gelesen. +A transactional SQLite outbox at `~/.atlas-trace/events.jsonl.outbox.sqlite3` +allocates sequence numbers safely across concurrent hooks. `ATLAS_TRACE_FILE` +changes this path prefix. Up to 10,000 pending events survive daemon downtime; +old pending entries expire at that limit. Delivery retries on the next hook, +or explicitly with `python3 /graph-ui/agents/hooks/atlas-trace.py --flush`. +A flush delivers up to 100 pending events from one project. No background bridge +or additional listener is required. The old JSONL watcher and replay bridge remain +historical fixture tools; they are not connected to the application by default. -**Die Ereignisdatei enthaelt Pfade, Zeilenbereiche und Werkzeugnamen und -keine Dateiinhalte.** Kein Quelltext, kein Ausgabetext, kein Diff. Das ist die -Grenze des Formats und keine Einstellung daran: wer die Datei liest, liest, WO -gearbeitet wurde, und nicht, WAS dort steht. Sie kann trotzdem verraten, an -welchen Dateien jemand arbeitet und welche Befehle er faehrt; sie gehoert -deshalb nicht in ein Repository und steht in `.gitignore`. +Daemon log provenance is explicit. `GET /api/logs?project=NAME&min_level=warn` +returns only records attributed to that exact indexed project; the filter applies +before pagination and counts. Omitting `project` keeps the daemon-wide history; +`scope=unattributed` shows records without known project ownership. Migration v3 +adds a nullable project column without assigning old records to the current +repository. Paths, page URLs and message text are never used to guess ownership. +Each record exposes `project: string | null`; responses expose `scope` and the +selected `project`. A failed scoped database read returns an error instead of +substituting unrelated memory-ring logs. New UI entries capture optional project +ownership when recorded, before buffering; an explicit empty/null entry project +stays unattributed even if a batch carries a project. -### 2. Die Bruecke +`POST /api/agent-events` accepts `{project, events: [...]}` with at most 100 events. +Required event fields are `ts`, `agent`, `run`, integer `seq`, `phase: start|end` +and `tool`. Optional fields include `path`, `lines`, `detail`, `source`, `intent` +(self-report), and explicit `replay`. Unknown fields are discarded. -``` -node tools/agent-bridge.mjs # Vorgabe: ~/.atlas-trace/events.jsonl, Port 4142 -node tools/agent-bridge.mjs --file --port -node tools/agent-bridge.mjs --replay fixtures/agent-events/w11a-replay.jsonl --port -``` +The daemon owns `/activity.db`, independent of replaceable project indexes. +Its additive migrations use SQLite WAL and a busy timeout. Events have monotonic +daemon cursors and unique `(project, run, seq)` identities. A small per-run retired sequence floor prevents expired events from reappearing after a delayed retry. +10,000 events are retained globally; expired history is explicitly incomplete. +A database generation distinguishes a restart (same state) from a replaced database. -Sie verfolgt die Datei und reicht sie als Server-Sent-Events weiter. Sie **liest -nur**: es gibt keine Route, die etwas hinzufuegt, sie legt die Datei nicht an -und sie loescht sie nicht. Sie spricht ausschliesslich Loopback und weist jede -Verbindung ab, die nicht von 127.0.0.1 kommt. +### The view and logs -Der Wiedergabemodus (`--replay`) liest die Datei einmal und schweigt danach, bis -jemand `POST /replay/advance?count=N` ruft. Das ist die Naht, mit der der -Beweislauf den Takt setzt, statt auf eine Wanduhr zu warten. +Agent activity is off until enabled by the reader. It then polls +`GET /api/agent-events?project=NAME&after=CURSOR&limit=200` over the same origin and +port as the graph and MCP HTTP API. Failed requests retain the last accepted +cursor; reconnects do not duplicate retained rows. Changing repositories resets +the browser's activity state. `?agents=PORT` no longer selects another server. -### 3. Die Ansicht +Daemon log callbacks and every accepted frontend log level persist in the same SQLite +journal. `GET /api/logs` preserves its `lines`/`total` response and adds structured +`records`, receipt timestamps, severity, source, durable IDs and a database +generation. `min_level=warn` selects warnings and errors, `min_level=error` only errors. +`q=TEXT` applies a literal, ASCII case-insensitive substring search to timestamps, +sources, messages and explicit project names. Scope, severity, search, counts and +cursors all apply before the display limit. System Logs uses these same filters; 2,000 warning/error records +are reserved independently of 3,000 routine records. `after=CURSOR` reads forward, +while requests without a cursor return a recent snapshot. A failed SQLite store +is reported as `persistent:false`, with the bounded in-memory fallback available. +`GET /api/ui-log` now reads frontend JSON lines from that same journal. Its +`path` names `activity.db`, `export_path` names the compatibility JSONL file, and +`source: "sqlite"` / `persistent: true` identify the source. Version 4 adds explicit +frontend origin and retains full bounded JSON evidence. On first open, at most the +last 256 KiB of each old `ui.log.1` and `ui.log` are imported in one transaction; +original receipt timestamps are preserved, project ownership remains unknown, +and a partial import is disclosed. No log is reassigned to the selected project. -Der Live-Modus ist **aus**, bis der Leser ihn einschaltet: im Menue mit -`[g] live agents` (alt+g) oder mit der Zeile `live agents` in der -Kommandozeile. Solange er aus ist, geht keine einzige Anfrage an die Bruecke. -Eingeschaltet ohne laufende Bruecke sagt das Instrument den Zustand und nennt -den Befehl, der sie startet. - -Einen anderen Port als 4142 nennt die Adresszeile: `?agents=`. +UI posts retain the existing `accepted`, `dropped`, `path` and `file_error` fields, +adding `duplicates`, `persistent` and `storage_error`; a failed journal write +returns HTTP 503 for retry. A retired per-session sequence floor prevents expired +UI events from returning on an old retry. JSONL remains a rotating local export +for existing `tail` workflows; changing it does not change the HTTP history. +No local report or event is published externally by these routes. Was die Ansicht zeigt und was sie ausdruecklich nicht zeigt: diff --git a/graph-ui/agents/hooks/atlas-trace.py b/graph-ui/agents/hooks/atlas-trace.py index 308d6e3d8..347f1ffac 100644 --- a/graph-ui/agents/hooks/atlas-trace.py +++ b/graph-ui/agents/hooks/atlas-trace.py @@ -1,64 +1,23 @@ #!/usr/bin/env python3 -"""Eine JSON-Zeile je Werkzeugaufruf, im Format, das die Bruecke liest. +"""Record tool metadata in a local outbox and POST it to the existing daemon. -Aufruf: als PostToolUse-Hook. Das Werkzeug reicht sein Ereignis als JSON auf -der Standardeingabe herein; dieses Skript haengt eine Zeile an die -Ereignisdatei und endet. - -## Drei Regeln, und keine davon ist Geschmack - -1. **Es endet immer mit 0, unter allen Umstaenden.** Ein Protokoll, das die - Arbeit aufhalten kann, ist schlimmer als kein Protokoll: ein Hook, der einen - Fehler wirft, bricht den Werkzeugaufruf ab, den er nur beobachten sollte. - Jeder Zweig hier faellt darum still durch. - -2. **Keine Dateiinhalte, nie.** Was in eine Zeile kommt, ist: wann, wer, welcher - Lauf, welche Nummer, welches Werkzeug, welcher Pfad, welcher Zeilenbereich - und der Befehl beziehungsweise das Suchmuster. Kein Quelltext, kein - Ausgabetext, kein Diff. Das ist die Grenze des Formats und nicht eine - Einstellung daran: wer die Ereignisdatei liest, liest, WO gearbeitet wurde, - und nicht, WAS dort steht. - -3. **Der Zeilenbereich wird gemessen, nicht geraten.** Bei `Read` steht er im - Aufruf (`offset`, `limit`). Bei `Edit` steht er nirgends, also wird er - gesucht: die Stelle des ersetzten Textes in der Datei, umgerechnet in - Zeilennummern. Dafuer wird die Datei gelesen und sofort wieder vergessen; - in die Zeile kommen zwei Zahlen und kein Zeichen des Inhalts. Findet sich - die Stelle nicht, fehlt das Feld, statt eine Zahl zu erfinden. - -## Warum `seq` fortlaufend ist und nicht aus der Uhr kommt - -Weil die Oberflaeche daran erkennt, dass ihr etwas fehlt. Bei einem Abriss -nimmt sie mit der zuletzt gesehenen Nummer je Lauf wieder auf, und aus der -Differenz wird die Meldung "2 Ereignisse verpasst". Eine Nummer aus der Uhr -(etwa Millisekunden modulo einer Million) sieht fortlaufend aus, laeuft aber -ueber und springt zurueck; die Oberflaeche muesste dann raten, ob eine Luecke -eine Luecke ist. Der Zaehler steht deshalb neben der Ereignisdatei in -`.seq` und gilt je Lauf. - -## Einrichtung - -In der Hook-Konfiguration des Agenten-Werkzeugs (eine `settings.json` in -dessen Heimatverzeichnis): - - { - "hooks": { - "PostToolUse": [ - { "matcher": "*", - "hooks": [{ "type": "command", - "command": "python3 /agents/hooks/atlas-trace.py" }] } - ] - } - } - -Wohin geschrieben wird, sagt `ATLAS_TRACE_FILE`; ohne die Variable ist es -`~/.atlas-trace/events.jsonl`. Wie der Agent heisst, sagt `ATLAS_AGENT_NAME`; -ohne sie steht dort `agent`. +Configure ATLAS_PROJECT to the indexed project name and ATLAS_DAEMON_URL when +using a nondefault daemon port. No bridge, listener or external transmission. +Pending events survive downtime and retry on the next hook or --flush. +The hook always returns zero so observing work cannot break the tool call. """ import json import os import sys import time +import sqlite3 +import uuid +import urllib.request +import urllib.parse +import argparse +import pathlib +import shlex +import tempfile DEFAULT_FILE = os.path.join(os.path.expanduser('~'), '.atlas-trace', 'events.jsonl') @@ -72,64 +31,188 @@ def trace_file(): return os.environ.get('ATLAS_TRACE_FILE') or DEFAULT_FILE -def next_seq(path, run): - """Die naechste Nummer dieses Laufs, fortlaufend und ohne Ueberlauf. +def open_outbox(): + path = trace_file() + '.outbox.sqlite3' + os.makedirs(os.path.dirname(os.path.abspath(path)), mode=0o700, exist_ok=True) + db = sqlite3.connect(path, timeout=0.5) + os.chmod(path, 0o600) + db.executescript(""" + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS counters(run TEXT PRIMARY KEY, seq INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, project TEXT NOT NULL, + payload TEXT NOT NULL); + """) + return db - Der Zaehler liegt als kleines JSON neben der Ereignisdatei. Zwei Agenten, - die gleichzeitig schreiben, koennen sich hier ueberholen; das ist genau der - Fall, den die Oberflaeche als Luecke meldet, und eine Sperre dafuer waere - ein Protokoll, das die Arbeit anhaelt. - """ - counter_path = path + '.seq' - counters = {} - try: - with open(counter_path, 'r', encoding='utf-8') as handle: - loaded = json.load(handle) - if isinstance(loaded, dict): - counters = loaded - except Exception: - counters = {} - value = counters.get(run) - number = (value if isinstance(value, int) and value > 0 else 0) + 1 - counters[run] = number - try: - with open(counter_path, 'w', encoding='utf-8') as handle: - json.dump(counters, handle) - except Exception: - pass - return number + +def enqueue(db, project, row): + # Counter allocation and enqueue are one SQLite transaction: parallel hooks + # cannot assign the same run/sequence pair and silently discard an event. + with db: + db.execute('BEGIN IMMEDIATE') + db.execute('INSERT INTO counters VALUES(?,1) ON CONFLICT(run) DO UPDATE SET seq=seq+1', + (row['run'],)) + row['seq'] = db.execute('SELECT seq FROM counters WHERE run=?', (row['run'],)).fetchone()[0] + db.execute('INSERT INTO pending(project,payload) VALUES(?,?)', + (project, json.dumps(row, ensure_ascii=False))) + # A prolonged daemon outage cannot fill the disk without bound. Sequence + # gaps remain visible when older undelivered metadata expires. + db.execute('DELETE FROM pending WHERE id IN (SELECT id FROM pending ORDER BY id DESC LIMIT -1 OFFSET 10000)') + + +def flush(db): + origin = os.environ.get('ATLAS_DAEMON_URL', 'http://127.0.0.1:9749') + parsed = urllib.parse.urlsplit(origin) + if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') + or parsed.username or parsed.password or parsed.path not in ('', '/') + or parsed.query or parsed.fragment): + return # Never forward local metadata to an external endpoint. + first = db.execute('SELECT project FROM pending ORDER BY id LIMIT 1').fetchone() + if not first: + return + rows = db.execute('SELECT id,payload FROM pending WHERE project=? ORDER BY id LIMIT 100', + first).fetchall() + data = json.dumps({'project': first[0], 'events': [json.loads(row[1]) for row in rows]}).encode() + request = urllib.request.Request(origin.rstrip('/') + '/api/agent-events', data=data, + headers={'Content-Type': 'application/json'}) + # No environment proxy, redirects, or second server; post only to loopback. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(request, timeout=0.75) as response: + result = json.load(response) + if response.status != 200 or result.get('accepted', 0) + result.get('duplicates', 0) != len(rows): + return + with db: + db.executemany('DELETE FROM pending WHERE id=?', [(row[0],) for row in rows]) def line_span(file_path, args): - """Der beruehrte Zeilenbereich, gemessen oder gar nicht. + """Use an explicit requested source range, never infer it after an edit. - `Read` nennt ihn selbst. `Edit` und `Write` nennen ihn nicht, also wird die - Stelle des alten Textes in der Datei gesucht und in Zeilennummern - umgerechnet. Nichts davon kommt in die Zeile ausser den zwei Zahlen. + PostToolUse runs after mutation. Searching old_string could locate a different, + unchanged occurrence and falsely attribute that line to the observed edit. """ offset = args.get('offset') limit = args.get('limit') - if isinstance(offset, int) and offset > 0: - if isinstance(limit, int) and limit > 0: + if isinstance(offset, int) and not isinstance(offset, bool) and offset > 0: + if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0: return [offset, offset + limit - 1] return [offset, offset] - needle = args.get('old_string') - if not isinstance(needle, str) or len(needle) == 0 or not file_path: - return None + return None + + +def daemon_origin(value): + parsed = urllib.parse.urlsplit(value) + if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') + or parsed.username or parsed.password or parsed.path not in ('', '/') + or parsed.query or parsed.fragment): + raise ValueError('The daemon URL must be a loopback HTTP origin.') + return value.rstrip('/') + + +def install_claude(root, project, origin): + """Explicit, project-local install; preserve unrelated settings and hooks. + + No package downloads or network requests. Refuse symlinks, malformed settings, + and conflicting older installations rather than replace user configuration. + """ + root = pathlib.Path(root).resolve(strict=True) + if not root.is_dir() or not project or len(project.encode()) > 255: + raise ValueError('Choose the indexed repository root and exact project name.') + origin = daemon_origin(origin) + directory = root / '.claude' + hook_dir = directory / 'hooks' + settings = directory / 'settings.local.json' + target = hook_dir / 'cbm-atlas-trace.py' + if any(path.is_symlink() for path in (directory, hook_dir, settings, target)): + raise ValueError('Refusing symlinked hook or settings paths.') + original = settings.read_bytes() if settings.exists() else None + config = json.loads(original) if original is not None else {} + if not isinstance(config, dict): + raise ValueError('Existing settings must be a JSON object.') + hooks = config.setdefault('hooks', {}) + if not isinstance(hooks, dict): + raise ValueError('Existing hooks must be an object.') + entries = hooks.setdefault('PostToolUse', []) + if not isinstance(entries, list): + raise ValueError('Existing PostToolUse hooks must be an array.') + command = ('ATLAS_PROJECT=' + shlex.quote(project) + ' ATLAS_AGENT_NAME=' + shlex.quote('Claude Code') + + ' ATLAS_DAEMON_URL=' + shlex.quote(origin) + + ' python3 ' + shlex.quote(str(target))) + proposed = {'matcher': '', 'hooks': [{'type': 'command', 'command': command, 'timeout': 3}]} + installed = proposed in entries + for entry in entries: + for handler in entry.get('hooks', []) if isinstance(entry, dict) else []: + if not isinstance(handler, dict): + continue + try: + words = shlex.split(str(handler.get('command', ''))) + except ValueError: + words = [] + references_target = any(word in (str(target), '.claude/hooks/cbm-atlas-trace.py', + './.claude/hooks/cbm-atlas-trace.py') for word in words) + if references_target and entry != proposed: + raise ValueError('An existing Atlas hook differs. Review its matcher, command and timeout before changing it.') + source = pathlib.Path(__file__).read_bytes() + if target.exists() and target.read_bytes() != source: + raise ValueError('An existing Atlas hook file differs. Review it before replacing it.') + if not installed: + entries.append(proposed) + encoded = (json.dumps(config, indent=2, ensure_ascii=False) + '\n').encode() + # A changed file is never overwritten after our read. Directory operations + # happen only after the complete, reviewable configuration was validated. + directory.mkdir(mode=0o700, exist_ok=True) + hook_dir.mkdir(mode=0o700, exist_ok=True) + if (settings.read_bytes() if settings.exists() else None) != original: + raise ValueError('Settings changed during setup; retry after reviewing them.') + created_hook = not target.exists() + if created_hook: + with target.open('xb') as handle: + handle.write(source) + target.chmod(0o600) + if not installed: + descriptor, temporary = tempfile.mkstemp(prefix='.cbm-atlas-settings-', dir=directory) + try: + with os.fdopen(descriptor, 'wb') as handle: + handle.write(encoded) + if (settings.read_bytes() if settings.exists() else None) != original: + raise ValueError('Settings changed during setup; nothing was replaced.') + os.replace(temporary, settings) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return {'installed': True, 'changed': not installed or created_hook, + 'settings': str(settings), 'hook': str(target), 'project': project, + 'daemon': origin, 'next': 'Start a new Claude Code session in this repository, perform a tool call, then load activity.'} + + +def setup_main(): + parser = argparse.ArgumentParser(description='Install the local Code Atlas Claude Code tool hook.') + parser.add_argument('--install-claude', action='store_true', required=True) + parser.add_argument('--root', required=True, help='Exact indexed repository root') + parser.add_argument('--project', required=True, help='Exact indexed project name') + parser.add_argument('--daemon-url', default='http://127.0.0.1:9749') + args = parser.parse_args() try: - with open(file_path, 'r', encoding='utf-8') as handle: - body = handle.read() - except Exception: - return None - at = body.find(needle) - if at < 0: - return None - start = body.count('\n', 0, at) + 1 - return [start, start + needle.count('\n')] + result = install_claude(args.root, args.project, args.daemon_url) + except Exception as error: + print('Atlas setup: ' + str(error), file=sys.stderr) + return 1 + print(json.dumps(result, ensure_ascii=False)) + return 0 def main(): + if '--flush' in sys.argv: + try: + with open_outbox() as db: + flush(db) + except Exception: + pass + return 0 try: payload = json.load(sys.stdin) except Exception: @@ -151,28 +234,34 @@ def main(): or args.get('description') or '' ) - run = str(payload.get('session_id') or 'session')[:8] + run = str(payload.get('session_id') or uuid.uuid4()) + root = str(payload.get('cwd') or os.getcwd()) + project = os.environ.get('ATLAS_PROJECT', os.path.basename(root.rstrip(os.sep))) row = { 'ts': int(time.time() * 1000), 'agent': os.environ.get('ATLAS_AGENT_NAME', 'agent'), 'run': run, - 'seq': next_seq(path, run), + 'seq': 0, 'phase': 'end', 'tool': tool, 'path': str(file_path or args.get('path') or ''), 'detail': str(detail)[:DETAIL_CAP], + 'source': 'tool-hook', } - span = line_span(file_path, args) + span = line_span(file_path, args) if tool == 'Read' else None if span is not None: row['lines'] = span - with open(path, 'a', encoding='utf-8') as handle: - handle.write(json.dumps(row, ensure_ascii=False) + '\n') + if os.path.isabs(row['path']): + row['path'] = os.path.relpath(row['path'], root) + with open_outbox() as db: + enqueue(db, project, row) + flush(db) except Exception: pass return 0 if __name__ == '__main__': - sys.exit(main()) + sys.exit(setup_main() if '--install-claude' in sys.argv else main()) diff --git a/graph-ui/agents/hooks/test_atlas_trace.py b/graph-ui/agents/hooks/test_atlas_trace.py new file mode 100644 index 000000000..19b84441e --- /dev/null +++ b/graph-ui/agents/hooks/test_atlas_trace.py @@ -0,0 +1,157 @@ +"""Reproducible local producer tests; no external network or agent accounts.""" +import concurrent.futures +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('atlas_trace', Path(__file__).with_name('atlas-trace.py')) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + + +class TraceTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.environment = patch.dict(os.environ, { + 'ATLAS_TRACE_FILE': os.path.join(self.directory.name, 'events'), + 'ATLAS_DAEMON_URL': 'http://127.0.0.1:9749', + }) + self.environment.start() + + def tearDown(self): + self.environment.stop() + self.directory.cleanup() + + def record(self, number): + with hook.open_outbox() as db: + hook.enqueue(db, 'fixture', {'run': 'concurrent-run', 'seq': 0, 'ts': number, + 'agent': 'fixture', 'tool': 'Read', 'phase': 'end'}) + + def test_concurrent_hooks_allocate_unique_sequences_and_keep_pending_after_failure(self): + with hook.open_outbox(): + pass + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(self.record, range(20))) + with hook.open_outbox() as db: + rows = db.execute('SELECT payload FROM pending').fetchall() + self.assertEqual(sorted(json.loads(row[0])['seq'] for row in rows), list(range(1, 21))) + with patch.object(hook.urllib.request, 'build_opener', side_effect=OSError('offline')): + with self.assertRaises(OSError): + hook.flush(db) + self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 20) + + def test_external_destinations_and_credentials_are_never_contacted(self): + self.record(1) + for origin in ('https://example.com', 'http://example.com', + 'http://127.0.0.1.evil.test', 'http://secret@localhost:9749', + 'http://localhost:9749/redirect', 'http://localhost:9749?secret=1'): + with patch.dict(os.environ, {'ATLAS_DAEMON_URL': origin}), \ + patch.object(hook.urllib.request, 'build_opener') as network, \ + hook.open_outbox() as db: + hook.flush(db) + network.assert_not_called() + self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 1) + + def test_explicit_setup_preserves_settings_is_idempotent_and_escapes_names(self): + root = Path(self.directory.name) / "repo with 'quote'" + (root / '.claude').mkdir(parents=True) + settings = root / '.claude/settings.local.json' + original = {'permissions': {'allow': ['Read']}, 'hooks': {'PostToolUse': [ + {'matcher': 'Read', 'hooks': [{'type': 'command', 'command': 'existing-hook'}]}]}} + settings.write_text(json.dumps(original)) + with patch.object(hook.urllib.request, 'build_opener') as network: + result = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749') + again = hook.install_claude(root, "project'; echo bad", 'http://127.0.0.1:9749') + network.assert_not_called() + self.assertTrue(result['changed']) + self.assertFalse(again['changed']) + installed = json.loads(settings.read_text()) + self.assertEqual(installed['permissions'], original['permissions']) + self.assertEqual(installed['hooks']['PostToolUse'][0], original['hooks']['PostToolUse'][0]) + self.assertEqual(len(installed['hooks']['PostToolUse']), 2) + command = installed['hooks']['PostToolUse'][1]['hooks'][0]['command'] + self.assertEqual(hook.shlex.split(command)[0], "ATLAS_PROJECT=project'; echo bad") + self.assertEqual(hook.shlex.split(command)[-1], str(root.resolve() / '.claude/hooks/cbm-atlas-trace.py')) + before = settings.read_bytes() + with self.assertRaises(ValueError): + hook.install_claude(root, 'another-project', 'http://127.0.0.1:9749') + self.assertEqual(settings.read_bytes(), before) + + def test_setup_refuses_symlinks_malformed_settings_and_external_daemons_before_writes(self): + root = Path(self.directory.name) / 'repo' + root.mkdir() + with self.assertRaises(ValueError): + hook.install_claude(root, 'fixture', 'https://example.com') + self.assertFalse((root / '.claude').exists()) + (root / '.claude').mkdir() + settings = root / '.claude/settings.local.json' + settings.write_text('malformed user data') + with self.assertRaises(ValueError): + hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749') + self.assertEqual(settings.read_text(), 'malformed user data') + settings.unlink() + target = root / 'external-settings.json' + target.write_text('{}') + settings.symlink_to(target) + with self.assertRaises(ValueError): + hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749') + self.assertEqual(target.read_text(), '{}') + self.assertFalse((root / '.claude/hooks').exists()) + + def test_setup_refuses_a_second_execution_with_a_different_matcher_or_timeout(self): + root = Path(self.directory.name) / 'TEST fixture' + root.mkdir() + hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749') + settings = root / '.claude/settings.local.json' + pristine = json.loads(settings.read_text()) + for change in ('matcher', 'timeout', 'relative-command'): + config = json.loads(json.dumps(pristine)) + entry = config['hooks']['PostToolUse'][0] + if change == 'matcher': + entry['matcher'] = 'Read' + elif change == 'timeout': + entry['hooks'][0]['timeout'] = 5 + else: + entry['hooks'][0]['command'] = 'python3 .claude/hooks/cbm-atlas-trace.py' + settings.write_text(json.dumps(config)) + before = settings.read_bytes() + with self.assertRaisesRegex(ValueError, 'existing Atlas hook differs'): + hook.install_claude(root, 'fixture', 'http://127.0.0.1:9749') + self.assertEqual(settings.read_bytes(), before) + self.assertEqual(len(json.loads(settings.read_text())['hooks']['PostToolUse']), 1) + + def test_post_edit_range_is_unknown_instead_of_an_unmodified_duplicate_occurrence(self): + source = Path(self.directory.name) / 'edited.txt' + source.write_text('new value\nold value\n') + with patch('builtins.open', side_effect=AssertionError('Metadata hook must not read source')): + self.assertIsNone(hook.line_span(str(source), {'old_string': 'old value', 'new_string': 'new value'})) + self.assertEqual(hook.line_span(str(source), {'offset': 8, 'limit': 3}), [8, 10]) + self.assertIsNone(hook.line_span(str(source), {'offset': True})) + + def test_acknowledged_duplicate_retry_removes_only_acknowledged_rows(self): + self.record(1) + self.record(2) + class Response: + status = 200 + def __enter__(self): return self + def __exit__(self, *args): return None + def read(self): return b'{"accepted":0,"duplicates":2}' + class Opener: + def open(self, request, timeout): + self_body = json.loads(request.data) + assert request.full_url == 'http://127.0.0.1:9749/api/agent-events' + assert self_body['project'] == 'fixture' + assert len(self_body['events']) == 2 + return Response() + with patch.object(hook.urllib.request, 'build_opener', return_value=Opener()), \ + hook.open_outbox() as db: + hook.flush(db) + self.assertEqual(db.execute('SELECT count(*) FROM pending').fetchone()[0], 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 5e7818c4a..a7acd22e7 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -119,6 +119,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import AtlasChrome, { CLOSE_COMMAND_SEARCH_EVENT, OPEN_COMMAND_SEARCH_EVENT } from './app/AtlasChrome'; import ArchitecturePanel from './architecture/ArchitecturePanel'; +import SelectionContextPanel from './why/SelectionContext'; +import ChangeAnalysisWorkspace from './impact/ChangeAnalysisWorkspace'; +import type { SelectionImpactTarget } from './impact/selection-impact'; +import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './architecture/SourceEvidenceDrawer'; +import { setUiLogProject } from './app/ui-log-install'; +import DaemonAlerts from './diagnostics/DaemonAlerts'; +import DiagnosticsPanel from './diagnostics/DiagnosticsPanel'; +import { useDaemonLogFeed } from './diagnostics/useDaemonLogFeed'; +import { resolveRepositorySelection, useRepositorySnapshot } from './architecture/repository-source'; import ActivityPanel from './agents/ActivityPanel'; import WelcomePanel from './app/WelcomePanel'; import BrowserChatDock from './browser-ai/BrowserChatDock'; @@ -192,12 +201,11 @@ import { searchByMeaning, } from './search/find-by-meaning'; import type { RankedHit } from './search/semantic-search'; -import { rankHits } from './search/semantic-search'; import { commandExamplesFor, exampleSymbolOf, } from './search/command-examples'; -import { EMPTY_LOCAL_INDEX, localCandidates, settledSearchHits } from './search/local-suggestions'; +import { EMPTY_LOCAL_INDEX, localCandidates, rankLocalCandidates, settledSearchHits } from './search/local-suggestions'; import type { LocalIndex } from './search/local-suggestions'; import { isSearchable, @@ -259,12 +267,7 @@ import type { BugWizardStatus } from './traces/BugWizard'; import { bugPaths, resolveHop } from './traces/bug-paths'; import type { BugPathNode, BugPathsDto } from './traces/bug-paths'; import { BUG_WIZARD_MENU_LABEL } from './traces/bug-wizard-strings'; -import ImpactPanel from './impact/ImpactPanel'; -import type { ImpactMode, ImpactStatus } from './impact/ImpactPanel'; -import { readImpact } from './impact/impact-source'; -import { refRejection } from './impact/impact-model'; -import type { ImpactModel, ImpactTarget } from './impact/impact-model'; -import { IMPACT_MENU_LABEL, impactRefRejected } from './impact/impact-strings'; +import { IMPACT_MENU_LABEL } from './impact/impact-strings'; import { SIDECAR_ORIGIN } from './llm/sidecar'; import type { CacheModel, SidecarReading, SidecarState } from './llm/sidecar'; import AtlasChatPanel from './chat/AtlasChatPanel'; @@ -744,12 +747,17 @@ declare global { export default function App(): JSX.Element { const [workspace, setWorkspace] = useState(() => { try { + const requested = new URLSearchParams(window.location.search).get('workspace'); + if (requested === 'explore' || requested === 'galaxy' || requested === 'architecture' || requested === 'agents' || requested === 'system') return requested; const saved = localStorage.getItem('cbm.workspace'); return saved === 'explore' || saved === 'galaxy' || saved === 'agents' || saved === 'system' ? saved : 'architecture'; } catch { return 'architecture'; } }); const changeWorkspace = (value: Workspace): void => { setWorkspace(value); + const url = new URL(window.location.href); + url.searchParams.set('workspace', value); + window.history.replaceState(window.history.state, '', url); try { localStorage.setItem('cbm.workspace', value); } catch { /* Session-only preference. */ } }; const [welcomeOpen, setWelcomeOpen] = useState(() => { @@ -778,6 +786,7 @@ export default function App(): JSX.Element { }; const client = useMemo(() => new RpcIntelligenceClient({}), []); const api = useMemo(() => new AtlasApi({}), []); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); /* * What the projects panel asks the server. The list comes over /rpc like @@ -807,6 +816,9 @@ export default function App(): JSX.Element { }, []); const [project, setProject] = useState(''); + const [logScopeChoice, setLogScopeChoice] = useState<{ project: string; scope: 'project' | 'daemon' }>(); + const logScope = logScopeChoice?.project === project ? logScopeChoice.scope : 'project'; + const daemonLogFeed = useDaemonLogFeed(api, Boolean(project), 3000, logScope === 'project' ? project : undefined); const [projectDetail, setProjectDetail] = useState('resolving project ...'); const [serverOk, setServerOk] = useState(undefined); const [counts, setCounts] = useState<{ nodes?: number; edges?: number }>({}); @@ -951,8 +963,8 @@ export default function App(): JSX.Element { * Der Live-Modus der Agenten (W11a). * * AUS per Vorgabe, und er wird auch nicht gespeichert. Dieselbe Regel wie - * beim lokalen Modell: solange er aus ist, geht keine einzige Anfrage an die - * Bruecke, und eine Seite, die nach dem naechsten Laden von selbst wieder zu + * beim lokalen Modell: solange er aus ist, geht keine einzige Agentenabfrage an den + * Daemon, und eine Seite, die nach dem naechsten Laden von selbst wieder zu * reden anfaengt, waere ein Schalter, ueber den der Leser nur einmal * entschieden hat. Er steht hier und nicht im Graph-Panel, weil ihn zwei * Wege umlegen: der Menuepunkt und die Kommandozeile. @@ -967,19 +979,12 @@ export default function App(): JSX.Element { * das ganze Fenster fuellt. */ const [fullscreenToggle, setFullscreenToggle] = useState(0); - /* - * Der Port der Bruecke, aus der Adresszeile. - * - * `?agents=` und sonst 4142. Aus der Adresse und nicht aus einer - * Einstellung, weil es keine Wahl des Lesers ist, sondern die Lage seiner - * Maschine: wer eine zweite Bruecke auf einem anderen Port faehrt, sagt es - * beim Aufrufen, und ein Beweislauf tut dasselbe. - */ + // Compatibility prop for existing HUD consumers; the stream uses this daemon origin. const bridgePort = useMemo( () => bridgePortFromSearch(typeof location === 'undefined' ? '' : location.search), [], ); - const agentStream = useAgentStream({ on: liveAgentsOn, port: bridgePort }); + const agentStream = useAgentStream({ on: liveAgentsOn, project }); const agents = useMemo( () => ({ ...agentStream, port: bridgePort, on: liveAgentsOn }), [agentStream, bridgePort, liveAgentsOn], @@ -1108,6 +1113,8 @@ export default function App(): JSX.Element { const [galaxyOn, setGalaxyOn] = useState(true); const [layout, setLayout] = useState(undefined); + const [analysisSelection, setAnalysisSelection] = useState<{ project: string; target?: SelectionImpactTarget }>(); + const [sourceEvidence, setSourceEvidence] = useState<{ project: string; target: SourceEvidenceTarget }>(); const [hits, setHits] = useState(noHits); const [selectedHit, setSelectedHit] = useState(0); const [searchStatus, setSearchStatus] = useState('ready'); @@ -1358,6 +1365,7 @@ export default function App(): JSX.Element { const [overviewLoading, setOverviewLoading] = useState(false); const [overviewError, setOverviewError] = useState(); const [overviewRevision, setOverviewRevision] = useState(0); + const repositoryReading = useRepositorySnapshot(project, overviewRevision); const [entryQuery, setEntryQuery] = useState(''); const [entryHits, setEntryHits] = useState(noHits); const [entryStatus, setEntryStatus] = useState('idle'); @@ -1384,24 +1392,6 @@ export default function App(): JSX.Element { const bugTicket = useRef(0); const impactOpen = explainOpen && explainTab === 'change'; - const [impactMode, setImpactMode] = useState('worktree'); - const [impactModel, setImpactModel] = useState(undefined); - const [impactStatus, setImpactStatus] = useState('idle'); - const [impactMessage, setImpactMessage] = useState(''); - const [impactRouteNote, setImpactRouteNote] = useState(''); - const [refDraft, setRefDraft] = useState(''); - const [refError, setRefError] = useState(''); - /** - * Der Vergleichspunkt, gegen den die angezeigte Lesung wirklich gefahren - * wurde. - * - * Getrennt vom Feldinhalt, und das ist der Punkt: wer tippt, aendert nicht - * die Frage, die auf dem Bildschirm beantwortet ist. Erst der Knopf setzt - * ihn, und nur ein Ref, dessen Form geprueft wurde, kommt hier an. - */ - const [appliedRef, setAppliedRef] = useState(undefined); - const impactTicket = useRef(0); - // ------------------------------------------ Das lokale Modell (W5a) ---- /* @@ -1518,7 +1508,11 @@ export default function App(): JSX.Element { const readerFocusRange = useMemo(() => readerSelection?.path === activePath ? { startLine: readerSelection.startLine, endLine: readerSelection.endLine > readerSelection.startLine && readerSelection.endColumn === 1 ? readerSelection.endLine - 1 : readerSelection.endLine } : caretLine !== undefined && caretLine > 0 ? { startLine: caretLine, endLine: caretLine } : undefined, [readerSelection, activePath, caretLine]); - const activeGalaxySelection = galaxySelectionProject === project ? galaxySelection : undefined; + const activeGalaxySelection = useMemo(() => resolveRepositorySelection( + galaxySelectionProject === project ? galaxySelection : undefined, repositoryReading.snapshot, + ), [galaxySelectionProject, project, galaxySelection, repositoryReading.snapshot]); + const selectionUnavailable = Boolean(galaxySelectionProject === project && galaxySelection && repositoryReading.snapshot && !activeGalaxySelection); + const selectionUnavailableText = 'The previous selection is absent from this bounded index snapshot. Choose a source element again to inspect its current evidence.'; const selectedGraphSymbol = useMemo(() => hierarchySymbolOf(activeGalaxySelection, project), [activeGalaxySelection, project]); const readerFocusSymbol = (twinSymbol?.projectName === undefined || twinSymbol.projectName === project) && symbolMatchesReader(twinSymbol, activePath, readerFocusRange) ? twinSymbol : symbolMatchesReader(selectedGraphSymbol, activePath, readerFocusRange) ? selectedGraphSymbol : undefined; @@ -1532,6 +1526,7 @@ export default function App(): JSX.Element { useEffect(() => { const fromUrl = new URLSearchParams(window.location.search).get('project') ?? ''; if (fromUrl.length > 0) { + setUiLogProject(fromUrl); setProject(fromUrl); setProjectDetail(''); return; @@ -1548,7 +1543,8 @@ export default function App(): JSX.Element { setProjectDetail(messages.statusbar.noProjectIndexed); return; } - setProject(first.name); + setUiLogProject(first.name); + setProject(first.name); setProjectDetail(''); }) .catch((error: unknown) => { @@ -1594,7 +1590,7 @@ export default function App(): JSX.Element { return () => { cancelled = true; }; - }, [client, project]); + }, [client, project, overviewRevision]); // ------------------------------------------------------- Baum ---------- @@ -1640,7 +1636,7 @@ export default function App(): JSX.Element { return () => { cancelled = true; }; - }, [api, project]); + }, [api, project, overviewRevision]); /* * Die zweite Baumquelle: der Coverage-Store. @@ -1683,7 +1679,7 @@ export default function App(): JSX.Element { return () => { cancelled = true; }; - }, [client, project]); + }, [client, project, overviewRevision]); /** * Die Vereinigung, aus der der Explorer zeichnet. @@ -2305,7 +2301,7 @@ export default function App(): JSX.Element { const onLayout = useCallback((loaded: GraphData) => { setLayout(loaded); - }, []); + }, [project]); /** * Ein Klick in die Galaxie. @@ -2429,7 +2425,7 @@ export default function App(): JSX.Element { : localPool; return pool.length === 0 ? noHits - : rankHits(pool, query, fanInOf).slice(0, MAX_SEARCH_ROWS); + : rankLocalCandidates(pool, query, fanInOf, MAX_SEARCH_ROWS); }, [fanInOf, localPool], ); @@ -2471,9 +2467,12 @@ export default function App(): JSX.Element { */ useEffect(() => { const query = command.trim(); + // Retire the previous response before the next debounce window begins. + searchTicket.current += 1; + const ticket = searchTicket.current; + searchAbort.current?.abort(); + searchAbort.current = undefined; if (project.length === 0 || !isSearchable(query, SEARCH_MIN_QUERY)) { - searchAbort.current?.abort(); - searchAbort.current = undefined; setHits(noHits); setAnsweredQuery(''); setHitSource('index'); @@ -2499,8 +2498,6 @@ export default function App(): JSX.Element { searchAbort.current?.abort(); const controller = new AbortController(); searchAbort.current = controller; - searchTicket.current += 1; - const ticket = searchTicket.current; searchStats.current.serverRequests += 1; const started = performance.now(); searchByMeaning( @@ -2561,11 +2558,16 @@ export default function App(): JSX.Element { ); }); }, SEARCH_DEBOUNCE_MS); - return () => window.clearTimeout(timer); + return () => { + window.clearTimeout(timer); + searchTicket.current += 1; + searchAbort.current?.abort(); + }; }, [command, project, provider, fanInOf]); const closeSearch = useCallback(() => { window.dispatchEvent(new Event(CLOSE_COMMAND_SEARCH_EVENT)); + searchTicket.current += 1; searchAbort.current?.abort(); searchAbort.current = undefined; setCommand(''); @@ -2763,7 +2765,7 @@ export default function App(): JSX.Element { setWhyAnswer(recordWhyAnswer(store, project, intent)); setWhyReopened(false); if (intent === 'understand') { - void startProjectTour(); + changeWorkspace('architecture'); } else if (intent === 'entry') { setEntryOpen(true); } else if (intent === 'bug') { @@ -2772,7 +2774,7 @@ export default function App(): JSX.Element { openExplain('change'); } }, - [openExplain, project, startProjectTour, store], + [openExplain, project, changeWorkspace, store], ); const declineWhy = useCallback(() => { @@ -3044,95 +3046,6 @@ export default function App(): JSX.Element { [project, provider], ); - // ----------------------------- Die Aenderungsansicht: Modus, Ref, Lesung - - - const chooseImpactMode = useCallback((mode: ImpactMode) => { - setImpactMode(mode); - setRefError(''); - if (mode === 'worktree') { - setAppliedRef(undefined); - } - }, []); - - /** - * Der Knopf neben dem Ref-Feld. - * - * Die Form wird hier geprueft und der Aufruf unterbleibt, wenn sie nicht - * stimmt. Warum nicht die Engine gefragt wird, steht bei `refRejection`: sie - * nimmt ein unbekanntes `since` kommentarlos an und antwortet ueber den - * Arbeitsbaum, also waere ein Tippfehler eine plausible Antwort auf eine - * andere Frage. - */ - const applyRef = useCallback(() => { - const value = refDraft.trim(); - const rejection = refRejection(value); - if (rejection !== undefined) { - setRefError(impactRefRejected(value, rejection)); - return; - } - setRefError(''); - setAppliedRef(value); - }, [refDraft]); - - useEffect(() => { - if (!impactOpen || project.length === 0) { - return; - } - impactTicket.current += 1; - const ticket = impactTicket.current; - setImpactStatus('loading'); - setImpactMessage(''); - readImpact(provider, ATLAS_WORKSPACE_ROOT, { - projectName: project, - generation: 1, - ...(appliedRef === undefined ? {} : { sinceRef: appliedRef }), - // Der Routen-Scan liest den Quelltext ueber denselben Weg wie der - // Reader. Einen zweiten Weg zu einer Datei gaebe es hier nicht, und - // ein zweiter waere eine zweite Stelle, an der eine Kappung - // interpretiert wird. - readSource: async (filePath) => { - const loaded = await loadFileDocument(client, project, filePath).catch(() => undefined); - return loaded === undefined - ? undefined - : { source: loaded.source, truncated: loaded.truncated }; - }, - }) - .then((reading) => { - if (ticket !== impactTicket.current) { - return; - } - setImpactModel(reading.model); - setImpactRouteNote(reading.routeNote); - setImpactStatus('ready'); - }) - .catch((error: unknown) => { - if (ticket !== impactTicket.current) { - return; - } - setImpactModel(undefined); - setImpactStatus('failed'); - setImpactMessage(error instanceof Error ? error.message : String(error)); - }); - }, [appliedRef, client, impactOpen, project, provider]); - - /** Eine Zeile der Aenderungsansicht oeffnen: derselbe Weg wie jeder Klick. */ - const openImpactRow = useCallback( - (target: ImpactTarget) => { - const ref = twinTargetOf({ - name: target.name, - qualifiedName: target.qualifiedName, - kind: 'unknown', - filePath: target.filePath, - startLine: target.line, - }); - if (ref === undefined) { - return; - } - followRef.current(ref); - }, - [], - ); - // ------------------------------------------ Das lokale Modell (W5a) ---- /* @@ -4570,9 +4483,109 @@ export default function App(): JSX.Element { const walkStepQualifiedName = walkStep !== undefined && walkStep.kind === 'symbol' ? walkStep.qualifiedName : undefined; + const liveIr = matchingInspectorIr(twinIr, readerFocusSymbol, project, activePath); + const liveOutline = useMemo(() => liveIr ? buildPseudocode( + { kind: 'symbol', label: liveIr.symbol.name }, + { irs: [liveIr], ...(layout === undefined ? {} : { graph: layout }) }, + ) : undefined, [liveIr, layout]); + const liveSelection = readerSelection?.path === activePath && document?.path === activePath + ? readerSelection : undefined; + const symbolList = fileSymbols.project === project && fileSymbols.path === activePath ? fileSymbols : undefined; + const liveCode: SelectedCodeSnapshot = { + project, filePath: activePath, symbol: readerFocusSymbol, ir: liveIr, + selection: liveSelection, document: document?.path === activePath ? document : undefined, + imports: activePath ? imports : undefined, + pseudocode: liveOutline, + status: liveIr ? 'ready' : readerStatus === 'loading' ? 'loading' + : twinStatus === 'failed' ? 'failed' : twinStatus === 'not-indexed' ? 'not-indexed' + : readerFocusSymbol ? 'loading' : 'empty', + message: twinHint || twinMessage, + coverageNote: coverageNote?.text, + fileSymbols: symbolList?.symbols, + fileSymbolsStatus: symbolList?.status ?? (activePath ? 'loading' : 'ready'), + fileSymbolsMessage: symbolList?.message, + }; + const inspector = pinnedCode?.project === project ? pinnedCode : liveCode; + const evidenceGraph = repositoryReading.snapshot; + const inspectorNode = evidenceGraph?.nodes.find(node => inspector.symbol?.qualifiedName + ? node.qualified_name === inspector.symbol.qualifiedName + : node.file_path === inspector.filePath && (node.label === 'File' || node.label === 'Module')); + const exploreEvidence = ({ filePath, line, name }: SourceEvidenceTarget): void => { + setSourceEvidence(undefined); + setAnalysisSelection(undefined); + if (impactOpen) collapseExplain(); + changeWorkspace('explore'); + setPinnedCode(undefined); + const target = twinTargetOf({ filePath, startLine: line, name: name ?? filePath, kind: 'unknown' }); + if (target) followTarget(target); + }; + const navigateEvidence = (filePath: string, line?: number, name?: string): void => { + setSourceEvidence({ project, target: { filePath, line, name } }); + }; + const readMapSource = useCallback(async (path: string, startLine = 1, maxLines = 65) => { + const qualifiedName = fileQualifiedName(repositoryReading.snapshot, path); + if (!qualifiedName) throw new Error(workspaceText.sourceOutsideSnapshot); + return client.getCodeSnippet(project, qualifiedName, { startLine, maxLines }); + }, [client, project, repositoryReading.snapshot]); + const inspectorImpactTarget = inspector.filePath ? { filePath: inspector.filePath, + qualifiedName: inspector.symbol?.qualifiedName, id: inspector.symbol ? inspectorNode?.id : undefined, + name: inspector.symbol?.name, line: inspectorNode?.start_line } : undefined; + const mapImpactTarget = activeGalaxySelection?.file_path ? { filePath: activeGalaxySelection.file_path, + qualifiedName: ['File', 'Module'].includes(activeGalaxySelection.label) ? undefined : activeGalaxySelection.qualified_name, + id: ['File', 'Module'].includes(activeGalaxySelection.label) ? undefined : activeGalaxySelection.id, + name: activeGalaxySelection.name, line: activeGalaxySelection.start_line } : undefined; + const openSelectionImpact = (target?: SelectionImpactTarget) => setAnalysisSelection({ project, target }); + const selectMapNode = (node: GraphNode): void => { + setGalaxySelection(node); + setGalaxySelectionProject(project); + }; + const mapSelectionPanel = selectionUnavailable ?

{selectionUnavailableText}

: activeGalaxySelection && (workspace === 'architecture' || workspace === 'galaxy') && <> + openSelectionImpact(mapImpactTarget)} /> + ; + const followInspectorTarget = (target: SymbolRef): void => { + setPinnedCode(undefined); + followTarget(target); + }; + const askInspector = (): void => { + if (!inspector.filePath) return; + setChatAttachment(inspector.selection ? { ...inspector.selection, project: inspector.project, id: crypto.randomUUID() } : undefined); + setChatGraphSelection(inspectorChatContext(inspector, crypto.randomUUID())); + setBrowserAiOpen(true); + }; + const showInspectorGraph = (): void => { + const node = layout?.nodes.find(entry => inspector.symbol?.qualifiedName + ? entry.qualified_name === inspector.symbol.qualifiedName + : entry.file_path === inspector.filePath && (entry.label === 'File' || entry.label === 'Module')); + if (node) { + setGalaxySelection(node); + setGalaxySelectionProject(project); + changeWorkspace('galaxy'); + } else { + setGalaxyOn(true); + if (inspector.symbol) followTarget(inspector.symbol); + else if (inspector.filePath) openFile(inspector.filePath); + } + }; + const selectedCode = } + impact={} + pinned={pinnedCode?.project === project} + onTogglePin={() => setPinnedCode(current => current?.project === project ? undefined : { ...liveCode, + selection: liveCode.selection ? { ...liveCode.selection } : undefined })} + onAsk={askInspector} onShowGraph={showInspectorGraph} onFollow={followInspectorTarget} + onPointRow={inspector.filePath === activePath ? pointRow : undefined} + onOpenFlow={inspector.symbol && inspector.symbol.qualifiedName === twinSymbol?.qualifiedName ? toggleFlow : undefined} + callOutline={inspector.pseudocode && inspector.symbol ? : undefined} />; + const galaxy = ( { setGalaxySelection(undefined); @@ -4623,63 +4635,6 @@ export default function App(): JSX.Element { /> ); - const liveIr = matchingInspectorIr(twinIr, readerFocusSymbol, project, activePath); - const liveOutline = useMemo(() => liveIr ? buildPseudocode( - { kind: 'symbol', label: liveIr.symbol.name }, - { irs: [liveIr], ...(layout === undefined ? {} : { graph: layout }) }, - ) : undefined, [liveIr, layout]); - const liveSelection = readerSelection?.path === activePath && document?.path === activePath - ? readerSelection : undefined; - const symbolList = fileSymbols.project === project && fileSymbols.path === activePath ? fileSymbols : undefined; - const liveCode: SelectedCodeSnapshot = { - project, filePath: activePath, symbol: readerFocusSymbol, ir: liveIr, - selection: liveSelection, document: document?.path === activePath ? document : undefined, - imports: activePath ? imports : undefined, - pseudocode: liveOutline, - status: liveIr ? 'ready' : readerStatus === 'loading' ? 'loading' - : twinStatus === 'failed' ? 'failed' : twinStatus === 'not-indexed' ? 'not-indexed' - : readerFocusSymbol ? 'loading' : 'empty', - message: twinHint || twinMessage, - coverageNote: coverageNote?.text, - fileSymbols: symbolList?.symbols, - fileSymbolsStatus: symbolList?.status ?? (activePath ? 'loading' : 'ready'), - fileSymbolsMessage: symbolList?.message, - }; - const inspector = pinnedCode?.project === project ? pinnedCode : liveCode; - const followInspectorTarget = (target: SymbolRef): void => { - setPinnedCode(undefined); - followTarget(target); - }; - const askInspector = (): void => { - if (!inspector.filePath) return; - setChatAttachment(inspector.selection ? { ...inspector.selection, project: inspector.project, id: crypto.randomUUID() } : undefined); - setChatGraphSelection(inspectorChatContext(inspector, crypto.randomUUID())); - setBrowserAiOpen(true); - }; - const showInspectorGraph = (): void => { - const node = layout?.nodes.find(entry => inspector.symbol?.qualifiedName - ? entry.qualified_name === inspector.symbol.qualifiedName - : entry.file_path === inspector.filePath && (entry.label === 'File' || entry.label === 'Module')); - if (node) { - setGalaxySelection(node); - setGalaxySelectionProject(project); - changeWorkspace('galaxy'); - } else { - setGalaxyOn(true); - if (inspector.symbol) followTarget(inspector.symbol); - else if (inspector.filePath) openFile(inspector.filePath); - } - }; - const selectedCode = setPinnedCode(current => current?.project === project ? undefined : { ...liveCode, - selection: liveCode.selection ? { ...liveCode.selection } : undefined })} - onAsk={askInspector} onShowGraph={showInspectorGraph} onFollow={followInspectorTarget} - onPointRow={inspector.filePath === activePath ? pointRow : undefined} - onOpenFlow={inspector.symbol && inspector.symbol.qualifiedName === twinSymbol?.qualifiedName ? toggleFlow : undefined} - callOutline={inspector.pseudocode && inspector.symbol ? : undefined} />; - /* * Die fuenf Reiter des Erklaeren-Bereichs, und was gerade dahinter liegt. * @@ -4794,25 +4749,6 @@ export default function App(): JSX.Element { }} onClose={collapseExplain} /> - ) : explainTab === 'change' && project.length > 0 ? ( - { - setRefDraft(value); - setRefError(''); - }} - onGo={applyRef} - refError={refError} - model={impactModel} - status={impactStatus} - message={impactMessage} - routeNote={impactRouteNote} - onOpen={openImpactRow} - onClose={collapseExplain} - /> ) : undefined; /** Ein Griff, mit seinen Grenzen aus dem Modell. */ @@ -4858,6 +4794,8 @@ export default function App(): JSX.Element { return ( setLogScopeChoice({ project, scope })} reading={daemonLogFeed} coverage={coverage} coverageKnown={coverageAsked} coverageError={coverageError} onDiagnose={() => setDiagnosticsOpen(true)} />} workspace={workspace} projectSwitcher={ setProjectsOpen(true)} />} @@ -4874,7 +4812,23 @@ export default function App(): JSX.Element { } - globalOverlay={<>{welcomeOpen && setDiagnosticsOpen(false)} + path={(workspace === 'architecture' || workspace === 'galaxy') ? activeGalaxySelection?.file_path : activePath || undefined} + onNavigate={path => { setDiagnosticsOpen(false); navigateEvidence(path); }} + onCoverage={reading => { setCoverage(reading.index); setCoverageMeta({ ...reading.answer.metadata }); setCoverageAsked(true); setCoverageError(''); setOverviewRevision(value => value + 1); }} /> + {(impactOpen || analysisSelection?.project === project) &&
{ + event.stopPropagation(); + if (event.key === 'Escape') { setAnalysisSelection(undefined); if (impactOpen) collapseExplain(); } + }}> navigateEvidence(target.filePath, target.line, target.name)} + onDiagnose={() => setDiagnosticsOpen(true)} onClose={() => { setAnalysisSelection(undefined); if (impactOpen) collapseExplain(); }} />
} + {sourceEvidence?.project === project && setSourceEvidence(undefined)} onExplore={exploreEvidence} />} + {welcomeOpen && { finishSetup(); setBrowserAiOpen(true); }} />} {projectsOpen && } diff --git a/graph-ui/src/agents/ActivityPanel.tsx b/graph-ui/src/agents/ActivityPanel.tsx index 3ab8aa91b..14708004e 100644 --- a/graph-ui/src/agents/ActivityPanel.tsx +++ b/graph-ui/src/agents/ActivityPanel.tsx @@ -10,9 +10,12 @@ import { activityRows } from './activity-model'; import type { ActivityFilter } from './activity-model'; import { activityStrings as s } from './activity-strings'; import './activity.css'; +import AgentSetup from './AgentSetup'; +import type { AtlasApi } from '../app/atlas-api'; interface Props { state: AgentsState; status: AgentSourceStatus; on: boolean; port: number; + project?: string; api?: Pick; graph?: GraphData; onToggle: () => void; onOpenNode: (node: GraphNode) => void; } const emptyFilter: ActivityFilter = { agent: '', run: '', kind: '', query: '' }; @@ -36,6 +39,8 @@ export default function ActivityPanel(props: Props): JSX.Element {
{props.status.state}
+ +

{props.on ? props.status.state === 'connected' ? 'History polling connected' : 'Waiting for daemon history' : 'History polling off'} · {all.length ? <>Latest recorded tool call: . This does not mean an agent is currently working. : 'No recorded tool call has been received for this project.'}

{[[s.events, props.state.actors.filter((actor) => !actor.you).reduce((sum, actor) => sum + actor.count, 0)], [s.actors, props.state.actors.filter((actor) => !actor.you).length], [s.gaps, props.state.missed], [s.dropped, props.status.drops]].map(([label, value]) => diff --git a/graph-ui/src/agents/AgentSetup.test.tsx b/graph-ui/src/agents/AgentSetup.test.tsx new file mode 100644 index 000000000..a2268864a --- /dev/null +++ b/graph-ui/src/agents/AgentSetup.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import AgentSetup, { agentInstallCommand } from './AgentSetup'; +let host: HTMLDivElement; let root: Root; +beforeEach(() => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); }); +const open = async () => { await act(async () => { const details = host.querySelector('details')!; details.open = true; details.dispatchEvent(new Event('toggle')); }); }; +it('exposes actionable setup without querying or changing anything until opened', async () => { + const api = { repoInfo: vi.fn().mockResolvedValue({ rootPath: '/repo with spaces', branch: 'main', remoteUrl: '' }) }; + await act(async () => root.render()); + expect(host.querySelector('summary')?.textContent).toBe('Connect your agent'); + expect(api.repoInfo).not.toHaveBeenCalled(); + await open(); + expect(api.repoInfo).toHaveBeenCalledWith('project-a'); + expect(host.querySelector('pre')?.textContent).toContain("--root '/repo with spaces' --project 'project-a'"); + expect(host.textContent).toContain('no file contents'); + expect(host.textContent).toContain('does not configure your coding client'); + expect(host.textContent).toContain('Other coding clients need their own event adapter'); +}); +it('never presents the previous repository command when a new root fails to load', async () => { + const api = { repoInfo: vi.fn().mockResolvedValueOnce({ rootPath: '/repo-a' }).mockRejectedValueOnce(new Error('repository unavailable')) }; + await act(async () => root.render()); await open(); + expect(host.querySelector('pre')?.textContent).toContain('/repo-a'); + await act(async () => root.render()); + expect(host.querySelector('pre')).toBeNull(); + expect(host.querySelector('[role="alert"]')?.textContent).toContain('repository unavailable'); +}); +it('quotes project and root values as literal shell words', () => { + const command = agentInstallCommand("/repo'; touch /tmp/unwanted", '$(env)', 9749); + expect(command).toContain("--root '/repo'\\''; touch /tmp/unwanted'"); + expect(command).toContain("--project '$(env)'"); +}); diff --git a/graph-ui/src/agents/AgentSetup.tsx b/graph-ui/src/agents/AgentSetup.tsx new file mode 100644 index 000000000..b97671645 --- /dev/null +++ b/graph-ui/src/agents/AgentSetup.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import type { AtlasApi } from '../app/atlas-api'; +import hookSource from '../../agents/hooks/atlas-trace.py?raw'; + +/** POSIX shell word quoting; paths and indexed names are data, never commands. */ +export const shellWord = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +export const agentInstallCommand = (root: string, project: string, port: number): string => + `python3 ~/Downloads/cbm-atlas-trace.py --install-claude --root ${shellWord(root)} --project ${shellWord(project)} --daemon-url ${shellWord(`http://127.0.0.1:${port}`)}`; + +export default function AgentSetup({ api, project, port }: { api?: Pick; project?: string; port: number }) { + const [open, setOpen] = useState(false); + const [reading, setReading] = useState<{ project: string; root: string; error: string }>(); + const [copied, setCopied] = useState(''); + useEffect(() => { + if (!open || !api || !project) return; + let disposed = false; + setReading(undefined); setCopied(''); + void api.repoInfo(project).then(info => { + if (!disposed) setReading({ project, root: info.rootPath, error: info.rootPath ? '' : 'The daemon did not report this repository root.' }); + }).catch(error => { + if (!disposed) setReading({ project, root: '', error: error instanceof Error ? error.message : String(error) }); + }); + return () => { disposed = true; }; + }, [open, api, project]); + const current = reading?.project === project ? reading : undefined; + const command = current?.root && project ? agentInstallCommand(current.root, project, port) : ''; + const download = () => { + const url = URL.createObjectURL(new Blob([hookSource], { type: 'text/x-python;charset=utf-8' })); + const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'cbm-atlas-trace.py'; + anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + return
setOpen(event.currentTarget.open)}> + Connect your agent +

Claude Code on macOS/Linux · Python 3 required. This installs a project-local PostToolUse hook. Connecting the browser only reads history; it does not configure your coding client.

+ {!project || !api ?

Select an indexed project to generate its setup command.

: <> +
    +
  1. Save cbm-atlas-trace.py in Downloads, or adjust its path in the command below.
  2. +
  3. Review and run this command in your terminal. It copies the hook into .claude/hooks/ and adds it to .claude/settings.local.json, preserving unrelated settings. Conflicting existing hooks are refused. + {current?.error ?

    Setup unavailable: {current.error}

    : command ? <> +
    {command}
    {copied} + :

    Reading the indexed repository root…

    } +
  4. +
  5. Start a new Claude Code session in that repository and perform a tool call. Then choose Load recorded activity here. A new recorded event is the evidence that the hook reached this daemon.
  6. +
+

Destination: http://127.0.0.1:{port}/api/agent-events · Project: {project}. No bridge or extra listener. Pending events retry on the next tool call.

+ } +

The hook records tool names, paths, timestamps, optional line spans and up to 180 characters of a command or search pattern. It records no file contents or tool results. Metadata stays in the local daemon; commands and paths can still contain sensitive information.

+

To remove it, delete only the entry referencing cbm-atlas-trace.py from this project's PostToolUse hooks. Other coding clients need their own event adapter; their activity is not inferred.

+
; +} diff --git a/graph-ui/src/agents/AgentsHud.test.tsx b/graph-ui/src/agents/AgentsHud.test.tsx index f9a2875a0..5e6a800bd 100644 --- a/graph-ui/src/agents/AgentsHud.test.tsx +++ b/graph-ui/src/agents/AgentsHud.test.tsx @@ -255,14 +255,14 @@ describe('die Quelle', () => { expect(one('atlas-agents-source')?.getAttribute('data-state')).toBe('off'); }); - it('nennt ohne Bruecke den Befehl, der sie startet', async () => { + it('nennt bei fehlender Verbindung den vorhandenen Daemon', async () => { await render({ status: status({ state: 'no-source', hello: undefined, error: 'refused' }), port: 4711, }); - expect(textOf('atlas-agents-reading')).toContain('no bridge is answering'); + expect(textOf('atlas-agents-reading')).toContain('no daemon is answering'); expect(one('atlas-agents-command')?.getAttribute('data-command')) - .toBe('node tools/agent-bridge.mjs --port 4711'); + .toBe('codebase-memory-mcp --ui'); }); it('gibt eine Wiedergabe als Wiedergabe aus und nicht als Gegenwart', async () => { diff --git a/graph-ui/src/agents/activity-strings.ts b/graph-ui/src/agents/activity-strings.ts index 2d61ffaaf..12a8593c8 100644 --- a/graph-ui/src/agents/activity-strings.ts +++ b/graph-ui/src/agents/activity-strings.ts @@ -1,16 +1,16 @@ export const activityStrings = { title: 'Agent activity', subtitle: 'Follow recorded work and inspect the code it touches.', - connect: 'Connect stream', disconnect: 'Disconnect', agent: 'Agent', run: 'Run', kind: 'Activity', + connect: 'Load recorded activity', disconnect: 'Disconnect', agent: 'Agent', run: 'Run', kind: 'Activity', allAgents: 'All agents', allRuns: 'All runs', allKinds: 'All activity', search: 'Filter activity', placeholder: 'Search tools, paths, or details…', events: 'Recorded events', actors: 'Agents seen', gaps: 'Missing sequence numbers', dropped: 'Connection drops', unmapped: 'Unmapped', - noSource: 'Connect the local agent bridge to start observing activity.', - noEvents: 'No recorded activity yet.', noMatch: 'No activity matches these filters.', + noSource: 'Load recorded activity from this daemon. Hooks send events to /api/agent-events on the same port.', + noEvents: 'No recorded activity for this repository. Configure the tool hook with the indexed project name; tool calls are observed, never inferred.', noMatch: 'No activity matches these filters.', clear: 'Clear filters', evidence: 'Event evidence', open: 'Open source', select: 'Select an event to inspect its evidence.', retained: 'Showing retained history: up to 80 events per agent. Counts are observed totals, not task progress.', outcome: 'An end event records the end of a tool call. Its result is not reported by this stream.', mapping: 'Mapping', timestamp: 'Recorded time', source: 'Source', phase: 'Phase', details: 'Details', - replay: 'Replay', live: 'Live', unknown: 'Not reported', reset: 'Reset', + replay: 'Replay', live: 'Recorded', unknown: 'Not reported', reset: 'Reset', disconnected: 'Stream is off. Retained events remain available.', limited: 'Graph placement uses the loaded map. Unmapped events remain visible.', noGraphMatch: 'No matching location in the loaded graph. The index may contain more nodes.', diff --git a/graph-ui/src/agents/activity.css b/graph-ui/src/agents/activity.css index ee36cdb37..2cfdf4a0e 100644 --- a/graph-ui/src/agents/activity.css +++ b/graph-ui/src/agents/activity.css @@ -37,3 +37,12 @@ .cbm-activity-inspector pre { white-space: pre-wrap; font: 11px var(--atlas-mono); } .cbm-activity-inspector > button { margin-top: 12px; } @media(max-width: 850px) { .cbm-activity { padding: 16px; } .cbm-activity-body { grid-template-columns: 1fr; } .cbm-activity-inspector { border-left: 0; border-top: 1px solid var(--atlas-line); } .cbm-activity-metrics { grid-template-columns: repeat(2, 1fr); } } + +.cbm-agent-setup { border: 1px solid #365548; border-radius: 7px; padding: 9px 12px; background: #101b16; margin: 12px 0; } +.cbm-agent-setup > summary { cursor: pointer; color: #99e7be; font-weight: 600; } +.cbm-agent-setup p, .cbm-agent-setup li { color: #bbcec3; font-size: 13px; line-height: 1.5; } +.cbm-agent-setup li { margin: 12px 0; }.cbm-agent-setup code { overflow-wrap: anywhere; } +.cbm-agent-setup pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; background: #07100b; border-radius: 5px; } +.cbm-agent-setup button { border: 1px solid #496b59; background: #182d21; color: #dbf3e4; padding: 5px 9px; border-radius: 4px; cursor: pointer; } +.cbm-agent-setup :focus-visible { outline: 2px solid #69ddbe; outline-offset: 3px; } +.cbm-activity-source-status { color: #a7bfb0; font-size: 12px; line-height: 1.5; } diff --git a/graph-ui/src/agents/agent-event.ts b/graph-ui/src/agents/agent-event.ts index a28ebf23e..edd130d22 100644 --- a/graph-ui/src/agents/agent-event.ts +++ b/graph-ui/src/agents/agent-event.ts @@ -1,8 +1,9 @@ /** * Was ein Ereignis ist, und welche Art von Arbeit es zeigt. * - * Die Ereignisse kommen von der Bruecke (tools/agent-bridge.mjs) und damit aus - * einer Datei, die ein Hook geschrieben hat. Dieses Modul ist die einzige + * Die Ereignisse kommen aus dem SQLite-Journal desselben Daemons; lokale + * Werkzeug-Hooks senden ihre Metadaten an /api/agent-events. Historische + * Fixture-Bruecken bleiben separate Testwerkzeuge. Dieses Modul ist die einzige * Stelle, die aus einer fremden JSON-Zeile ein Ereignis dieses Produkts macht, * und es hat dabei genau zwei Aufgaben. * @@ -60,7 +61,7 @@ export interface AgentEvent { intent?: string; /** Woher das Ereignis kommt, wenn die Quelle es sagt (etwa `fs`). */ source: string; - /** Ob die Bruecke dieses Ereignis als Wiedergabe gekennzeichnet hat. */ + /** Ob die Quelle dieses Ereignis als Wiedergabe gekennzeichnet hat. */ replay: boolean; /** Die aufgezeichnete Zeit, wenn die Wiedergabe sie verschoben hat. */ recordedTs?: number; diff --git a/graph-ui/src/agents/agent-source.test.tsx b/graph-ui/src/agents/agent-source.test.tsx new file mode 100644 index 000000000..67056e2f7 --- /dev/null +++ b/graph-ui/src/agents/agent-source.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { act, useLayoutEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { POLL_MS, RECONNECT_MS, useAgentStream, type AgentStream } from './agent-source'; + +let root: Root; +let container: HTMLDivElement; +let stream: AgentStream; +const event = (seq: number) => ({ ts: seq * 1000, agent: 'fixture', run: 'run', seq, + phase: 'end', tool: 'Read', path: `file${seq}.ts` }); +const page = (events: unknown[], cursor: number, generation = 'first') => ({ + ok: true, json: async () => ({ events, cursor, generation, reset: false, + retained: events.length, has_more: false, truncated: false }), +}); + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +function Consumer(props: { on: boolean; project?: string; fetch: typeof globalThis.fetch }): null { + stream = useAgentStream(props); + return null; +} + +it('does not connect when off and always polls the current daemon, never a bridge', async () => { + const fetch = vi.fn().mockResolvedValue(page([], 0)); + await act(async () => root.render()); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(fetch).not.toHaveBeenCalled(); + await act(async () => root.render()); + expect(fetch.mock.calls[0]?.[0]).toBe('/api/agent-events?project=repo%20%2F%20test&after=0&limit=200'); + expect(stream.status.state).toBe('connected'); + await act(async () => root.render()); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('resumes after a connection failure and accepts late events without duplicate counts', async () => { + const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 1)) + .mockRejectedValueOnce(new Error('connection lost')) + .mockResolvedValueOnce(page([event(1), event(2)], 2)); + await act(async () => root.render()); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS); }); + expect(stream.status.state).toBe('no-source'); + await act(async () => { await vi.advanceTimersByTimeAsync(RECONNECT_MS); }); + expect(fetch.mock.calls[1]?.[0]).toContain('after=1'); + expect(fetch.mock.calls[2]?.[0]).toContain('after=1'); + expect(stream.state.events).toBe(2); + expect(stream.state.actors[0]?.last.seq).toBe(2); + expect(stream.status.drops).toBe(1); +}); + +it('clears another project or replaced database before accepting its history', async () => { + const fetch = vi.fn().mockResolvedValueOnce(page([event(2)], 5)) + .mockResolvedValueOnce(page([event(1)], 1, 'replacement')) + .mockResolvedValueOnce(page([event(1)], 1, 'replacement')) + .mockResolvedValueOnce(page([], 0, 'replacement')); + await act(async () => root.render()); + await act(async () => { await vi.advanceTimersByTimeAsync(POLL_MS + 1); }); + expect(fetch.mock.calls[2]?.[0]).toContain('after=0'); + expect(stream.state.events).toBe(1); + expect(stream.state.actors[0]?.last.seq).toBe(1); + await act(async () => root.render()); + expect(fetch.mock.calls[3]?.[0]).toContain('project=two&after=0'); + expect(stream.state.events).toBe(0); +}); + + +it('hides prior project activity and provenance before passive effects on project change', async () => { + const fetch = vi.fn().mockResolvedValueOnce(page([event(1)], 1)) + .mockImplementation(() => new Promise(() => undefined)); + const observed: { project: string; events: number; hello: boolean; status: string }[] = []; + function Observe({ project }: { project: string }): null { + const current = useAgentStream({ on: true, project, fetch }); + useLayoutEffect(() => { + observed.push({ project, events: current.state.events, + hello: current.status.hello !== undefined, status: current.status.state }); + }); + return null; + } + await act(async () => root.render()); + expect(observed.at(-1)).toMatchObject({ project: 'one', events: 1, hello: true, status: 'connected' }); + await act(async () => root.render()); + const switched = observed.filter(value => value.project === 'two'); + expect(switched.length).toBeGreaterThan(0); + expect(switched.every(value => value.events === 0 && !value.hello && value.status === 'connecting')).toBe(true); + expect(fetch.mock.calls[1]?.[0]).toContain('project=two&after=0'); +}); diff --git a/graph-ui/src/agents/agent-source.ts b/graph-ui/src/agents/agent-source.ts index 658eb9dd6..cf34cecff 100644 --- a/graph-ui/src/agents/agent-source.ts +++ b/graph-ui/src/agents/agent-source.ts @@ -1,58 +1,20 @@ -/** - * Die Verbindung zur Bruecke, und der Zustand, wenn es keine gibt. - * - * ## Aus heisst aus - * - * Solange der Live-Modus aus ist, geht von hier KEINE Anfrage hinaus. Nicht - * eine Probe, nicht ein `HEAD`, nicht ein Versuch herauszufinden, ob eine - * Bruecke laeuft. Dasselbe Versprechen wie beim lokalen Modell (src/llm), und - * derselbe Grund: ein Schalter, der im Hintergrund weiter fragt, ist kein - * Schalter, sondern eine Farbe. Der Zaehler {@link AgentSourceStatus.requests} - * steht im Testgriff, damit die Null gemessen werden kann und nicht geglaubt - * werden muss. - * - * ## Und ohne Bruecke heisst ohne Bruecke - * - * Ist der Modus an und antwortet niemand, sagt der Zustand `no-source`, und das - * Instrument zeigt den Befehl, der die Bruecke startet. Ein leerer Graph waere - * an dieser Stelle die Behauptung, es arbeite gerade niemand, und das ist eine - * Aussage, die dieses Fenster nicht treffen kann. - * - * ## Warum `fetch` und nicht `EventSource` - * - * Der Draht ist Server-Sent-Events, das Lesen ist ein `fetch` mit einem - * Stroem-Leser. Drei Gruende, in dieser Reihenfolge: - * - * 1. **Wiederaufnahme.** Die Bruecke nimmt die zuletzt gesehene Nummer je Lauf - * als Abfrageparameter. `EventSource` schickt beim Wiederverbinden seine - * eigene `Last-Event-ID` und sonst nichts; die Nummern MEHRERER Laeufe - * passen dort nicht hinein. - * 2. **Abbrechen.** Ein `AbortController` beendet die Verbindung in dem - * Augenblick, in dem der Schalter faellt. `EventSource.close()` tut das auch, - * verbindet aber vorher von selbst neu, und "von selbst" ist genau das, was - * ein ausgeschalteter Modus nicht tun darf. - * 3. **Messbarkeit.** Ein `fetch` ist eine Anfrage, die man zaehlen kann, und - * die Zusicherung dieses Moduls ist eine Zahl. - */ +/** Same-origin daemon polling over the existing sequential HTTP transport. + * Off means zero requests. Cursors are committed only after a validated page; + * reconnects retain state, and a replaced SQLite database resets its generation. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AgentEvent } from './agent-event'; -import { readAgentEvent, sinceParameter } from './agent-event'; +import { readAgentEvent } from './agent-event'; import { emptyAgentsState, withEvent } from './agent-store'; import type { AgentsState } from './agent-store'; -/** Der Vorgabeport der Bruecke. Derselbe wie in tools/agent-bridge.mjs. */ -export const DEFAULT_BRIDGE_PORT = 4142; - -/** Wie lange nach einem Abriss gewartet wird, bevor neu verbunden wird. */ +/** Kept as a source compatibility name; the current daemon port is used. */ +export const DEFAULT_BRIDGE_PORT = 9749; export const RECONNECT_MS = 3000; - -/** Was der Zustand `no-source` als Weg nach vorn nennt. */ -export function bridgeCommand(port: number): string { - return port === DEFAULT_BRIDGE_PORT - ? 'node tools/agent-bridge.mjs' - : `node tools/agent-bridge.mjs --port ${port}`; +export const POLL_MS = 1000; +export function bridgeCommand(_port: number): string { + return 'codebase-memory-mcp --ui'; } /** Die Lage der Quelle. */ @@ -61,20 +23,20 @@ export type SourceState = | 'off' /** Eine Verbindung laeuft gerade an. */ | 'connecting' - /** Die Bruecke antwortet und schickt Ereignisse. */ + /** Der Daemon antwortet mit gespeicherten Ereignissen. */ | 'connected' /** Der Modus ist an und niemand antwortet. */ | 'no-source'; -/** Was die Bruecke ueber sich gesagt hat. */ +/** Quelleninformation des Daemons (Feldnamen kompatibel zum bisherigen HUD). */ export interface BridgeHello { - /** `live` verfolgt eine Datei, `replay` spielt eine Aufzeichnung ab. */ + /** `live` liest die lokale SQLite-Historie, `replay` bezeichnet Testaufzeichnungen. */ mode: string; - /** Die Datei, die sie liest. */ + /** Lesbare Bezeichnung der Datenquelle. */ file: string; - /** Wie viele Zeilen sie gelesen hat. */ + /** Wie viele Ereignisse der Daemon aktuell aufbewahrt. */ events: number; - /** Wie viele Zeilen kein JSON waren. */ + /** Wie viele empfangene Ereignisse nicht lesbar waren. */ unreadable: number; } @@ -87,7 +49,7 @@ export interface AgentSourceStatus { requests: number; /** Wie oft die Verbindung abgerissen ist. */ drops: number; - /** Was die Bruecke ueber sich gesagt hat, wenn sie es gesagt hat. */ + /** Metadaten der zuletzt gelesenen Daemon-Antwort. */ hello: BridgeHello | undefined; /** Der letzte Fehler, woertlich. Leer, wenn keiner. */ error: string; @@ -124,22 +86,17 @@ export function parseSseFrame(raw: string): SseFrame | undefined { return data.length === 0 ? undefined : { event: name, data: data.join('\n') }; } -/** Der Port der Bruecke, wie die Adresszeile ihn nennt. */ -export function bridgePortFromSearch(search: string): number { - try { - const raw = new URLSearchParams(search).get('agents'); - const port = Number(raw); - return Number.isFinite(port) && port > 0 && port < 65536 ? port : DEFAULT_BRIDGE_PORT; - } catch { - return DEFAULT_BRIDGE_PORT; - } +/** Der aktuelle Daemon-Port; ein alter agents-Parameter hat keine Wirkung. */ +export function bridgePortFromSearch(_search: string): number { + return typeof location === 'undefined' ? DEFAULT_BRIDGE_PORT : Number(location.port) || 80; } export interface AgentStreamOptions { /** Ob der Live-Modus an ist. Aus heisst: keine Anfrage. */ on: boolean; - /** Der Port der Bruecke. */ + /** Kompatibilitaetsfeld fuer alte Aufrufer; der Transport verwendet same-origin. */ port?: number; + project?: string; /** Ersetzbares fetch, damit Tests ohne Netz laufen. */ fetch?: typeof globalThis.fetch | undefined; } @@ -148,13 +105,13 @@ export interface AgentStreamOptions { export interface AgentStream { state: AgentsState; status: AgentSourceStatus; - /** Ein eigenes Ereignis dazulegen, ohne die Bruecke zu fragen. */ + /** Ein eigenes Browsereignis lokal dazulegen. */ push: (event: AgentEvent, you: boolean) => void; } /** * Der Strom, wie ihn die Panels bekommen: samt Port, damit das Instrument den - * Befehl nennen kann, der die Bruecke startet. + * vorhandenen Daemon-Port anzeigen kann. */ export interface AgentsRuntime extends AgentStream { port: number; @@ -170,24 +127,29 @@ export interface AgentsRuntime extends AgentStream { * zwanzig Zustandsketten aufmachen, aus denen jede die vorige ueberschreibt. */ export function useAgentStream(options: AgentStreamOptions): AgentStream { - const port = options.port ?? DEFAULT_BRIDGE_PORT; + const project = options.project ?? ''; const fetchImpl = options.fetch; const on = options.on; const stateRef = useRef(emptyAgentsState()); - const [state, setState] = useState(stateRef.current); + const [reading, setReading] = useState({ project, value: stateRef.current }); const requests = useRef(0); const drops = useRef(0); - const [status, setStatus] = useState({ + const cursor = useRef(0); + const incomplete = useRef(false); + const generation = useRef(undefined); + const previousProject = useRef(project); + const [source, setSource] = useState<{ project: string; value: AgentSourceStatus }>({ project, value: { state: 'off', origin: '', requests: 0, drops: 0, hello: undefined, error: '', - }); + } }); const push = useCallback((event: AgentEvent, you: boolean) => { + if (previousProject.current !== project) return; const next = withEvent(stateRef.current, event); stateRef.current = you ? { @@ -196,35 +158,43 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream { (actor.id === event.agent ? { ...actor, you: true } : actor)), } : next; - setState(stateRef.current); - }, []); + setReading({ project, value: stateRef.current }); + }, [project]); useEffect(() => { + if (previousProject.current !== project) { + previousProject.current = project; + cursor.current = 0; + generation.current = undefined; + incomplete.current = false; + stateRef.current = emptyAgentsState(); + setReading({ project, value: stateRef.current }); + } if (!on) { - setStatus({ + setSource({ project, value: { state: 'off', origin: '', requests: requests.current, drops: drops.current, hello: undefined, error: '', - }); + } }); return; } - const origin = `http://127.0.0.1:${port}`; + const origin = typeof location === 'undefined' ? '' : location.origin; const doFetch = fetchImpl ?? globalThis.fetch; let stopped = false; let controller: AbortController | null = null; let timer: ReturnType | undefined; const announce = (patch: Partial): void => { - setStatus((current) => ({ - ...current, + setSource((current) => ({ project, value: { + ...(current.project === project ? current.value : { state: 'connecting', hello: undefined, error: '' }), origin, requests: requests.current, drops: drops.current, ...patch, - })); + } })); }; const connect = async (): Promise => { @@ -232,78 +202,49 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream { return; } controller = new AbortController(); - const since = sinceParameter(stateRef.current.seen); - const url = `${origin}/events${since.length > 0 ? `?since=${encodeURIComponent(since)}` : ''}`; + const url = `/api/agent-events?project=${encodeURIComponent(project)}&after=${cursor.current}&limit=200`; requests.current += 1; - announce({ state: 'connecting', error: '' }); + if (cursor.current === 0) announce({ state: 'connecting', error: '' }); try { - const response = await doFetch(url, { - headers: { Accept: 'text/event-stream' }, - signal: controller.signal, - }); - if (!response.ok || response.body === null) { - throw new Error(`the bridge answered with HTTP ${response.status}`); + const response = await doFetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`daemon activity: HTTP ${response.status}`); + const page = await response.json() as { + events: unknown[]; cursor: number; generation: string; reset: boolean; + has_more: boolean; truncated: boolean; retained: number; + }; + if (!Array.isArray(page.events) || !Number.isSafeInteger(page.cursor) + || page.cursor < 0 || typeof page.generation !== 'string') { + throw new Error('Invalid daemon activity response'); } - announce({ state: 'connected', error: '' }); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - for (;;) { - const chunk = await reader.read(); - if (chunk.done) { - break; - } - buffer += decoder.decode(chunk.value, { stream: true }); - const parts = buffer.split('\n\n'); - buffer = parts.pop() ?? ''; - let changed = false; - for (const part of parts) { - const frame = parseSseFrame(part); - if (frame === undefined) { - continue; - } - if (frame.event === 'hello') { - try { - const hello = JSON.parse(frame.data) as BridgeHello; - announce({ state: 'connected', hello }); - stateRef.current = { - ...stateRef.current, - unreadable: Number(hello.unreadable) || 0, - }; - changed = true; - } catch { - /* Ein unlesbarer Gruss aendert nichts an den Ereignissen. */ - } - continue; - } - if (frame.event !== 'trace') { - continue; - } - try { - const event = readAgentEvent(JSON.parse(frame.data)); - if (event !== undefined) { - stateRef.current = withEvent(stateRef.current, event); - changed = true; - } - } catch { - /* Eine unlesbare Zeile ist eine Zeile weniger, kein Abbruch. */ - } - } - if (changed && !stopped) { - setState(stateRef.current); - } - } - if (!stopped) { - drops.current += 1; - announce({ state: 'no-source', error: 'the bridge closed the stream' }); - timer = setTimeout(() => void connect(), RECONNECT_MS); - } - } catch (failure) { - if (stopped) { + if (stopped) return; + if (page.reset || (generation.current !== undefined && generation.current !== page.generation)) { + stateRef.current = emptyAgentsState(); + cursor.current = 0; + incomplete.current = false; + generation.current = page.generation; + setReading({ project, value: stateRef.current }); + timer = setTimeout(() => void connect(), 0); return; } - const message = failure instanceof Error ? failure.message : String(failure); - announce({ state: 'no-source', error: message }); + generation.current = page.generation; + let next = stateRef.current; + for (const raw of page.events) { + const event = readAgentEvent(raw); + if (event !== undefined) next = withEvent(next, event); + else next = { ...next, unreadable: next.unreadable + 1 }; + } + stateRef.current = next; + cursor.current = page.cursor; + incomplete.current ||= page.truncated; + setReading({ project, value: next }); + announce({ state: 'connected', error: incomplete.current + ? 'Older activity expired from local retention; this history is incomplete.' : '', + hello: { mode: 'live', file: 'daemon SQLite activity', events: page.retained, unreadable: next.unreadable } }); + timer = setTimeout(() => void connect(), page.has_more ? 0 : POLL_MS); + } catch (failure) { + if (stopped) return; + drops.current += 1; + announce({ state: 'no-source', error: failure instanceof Error ? failure.message : String(failure) }); timer = setTimeout(() => void connect(), RECONNECT_MS); } }; @@ -316,7 +257,16 @@ export function useAgentStream(options: AgentStreamOptions): AgentStream { } controller?.abort(); }; - }, [on, port, fetchImpl]); + }, [on, project, fetchImpl]); - return useMemo(() => ({ state, status, push }), [state, status, push]); + // A passive effect resets the cursor, but the first render of another + // project must already hide the previous project's events and provenance. + return useMemo(() => ({ + state: reading.project === project ? reading.value : emptyAgentsState(), + status: source.project === project ? source.value : { + state: on ? 'connecting' as const : 'off' as const, origin: '', + requests: requests.current, drops: drops.current, hello: undefined, error: '', + }, + push, + }), [reading, source, project, on, push]); } diff --git a/graph-ui/src/agents/agent-store.test.ts b/graph-ui/src/agents/agent-store.test.ts index 4154e9e3f..0bd179e6d 100644 --- a/graph-ui/src/agents/agent-store.test.ts +++ b/graph-ui/src/agents/agent-store.test.ts @@ -54,6 +54,14 @@ describe('der Zustand des Stroms', () => { expect(state.actors[0]?.missed).toBe(2); }); + it('reconciles a late missing sequence and ignores duplicate deliveries', () => { + const state = fold([event({ seq: 1 }), event({ seq: 4 }), event({ seq: 2 }), + event({ seq: 2 }), event({ seq: 3 })]); + expect(state.events).toBe(4); + expect(state.missed).toBe(0); + expect(state.actors[0]?.missed).toBe(0); + }); + it('haelt das erste Ereignis eines Laufs NIE fuer eine Luecke', () => { // Die Oberflaeche ist bei Nummer 40 eingestiegen. Das sind keine // verlorenen Nachrichten, das ist ein spaeter Zuhoerer. diff --git a/graph-ui/src/agents/agent-store.ts b/graph-ui/src/agents/agent-store.ts index 49c85f55e..4053cb680 100644 --- a/graph-ui/src/agents/agent-store.ts +++ b/graph-ui/src/agents/agent-store.ts @@ -119,7 +119,8 @@ function withRun(runs: readonly RunState[], event: AgentEvent): { missed: 0, }; } - const gap = event.seq > found.lastSeq + 1 ? event.seq - found.lastSeq - 1 : 0; + const gap = event.seq > found.lastSeq + 1 ? event.seq - found.lastSeq - 1 + : event.seq > found.joinedAt && event.seq < found.lastSeq && found.missed > 0 ? -1 : 0; return { runs: runs.map((entry) => (entry.run === event.run @@ -131,6 +132,10 @@ function withRun(runs: readonly RunState[], event: AgentEvent): { /** Ein Ereignis in den Zustand aufnehmen. Rein, damit ein Test ihn nachbauen kann. */ export function withEvent(state: AgentsState, event: AgentEvent): AgentsState { + // A retry must not increment counts or append another timeline row. Lower + // sequence numbers are still accepted when they arrive late. + if (state.actors.some((actor) => actor.events.some((seenEvent) => + seenEvent.run === event.run && seenEvent.seq === event.seq))) return state; const id = event.agent; const existing = state.actors.find((actor) => actor.id === id); const seen = new Map(state.seen); diff --git a/graph-ui/src/agents/agent-strings.ts b/graph-ui/src/agents/agent-strings.ts index 8ebf52e42..cda2011dc 100644 --- a/graph-ui/src/agents/agent-strings.ts +++ b/graph-ui/src/agents/agent-strings.ts @@ -70,14 +70,13 @@ export const agentStrings = { sourceOff: 'live mode is off, and nothing is asked', sourceOffDetail: - 'not one request goes to the bridge while this is off. Turn it on in the atlas menu with ' + 'not one request goes to the daemon while this is off. Turn it on in the atlas menu with ' + '[g] live agents, or type "live agents" in the command line.', - sourceConnecting: 'connecting to the bridge', - sourceNone: 'live mode is on and no bridge is answering', + sourceConnecting: 'connecting to the daemon', + sourceNone: 'live mode is on and no daemon is answering', sourceNoneDetail: - 'this window has no backend and cannot start one. The bridge reads the event file and hands ' - + 'it on; without it there is no source, and an empty graph here would be the claim that ' - + 'nobody is working:', + 'check that the local daemon is running. It stores tool events in SQLite and serves them ' + + 'on the same port as this map. Recorded history is retained locally while the connection retries.', sourceConnected: (file: string): string => `reading ${file}`, sourceReplay: (file: string): string => `replaying ${file}`, replayNote: diff --git a/graph-ui/src/app/AtlasChrome.tsx b/graph-ui/src/app/AtlasChrome.tsx index 435764253..856c407b1 100644 --- a/graph-ui/src/app/AtlasChrome.tsx +++ b/graph-ui/src/app/AtlasChrome.tsx @@ -151,6 +151,7 @@ export interface AtlasChromeProps { workspace?: Workspace; onWorkspaceChange?: (workspace: Workspace) => void; workspacePanel?: ReactNode; + workspaceStatus?: ReactNode; guidance?: Guidance; onGuidanceChange?: (guidance: Guidance) => void; /** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */ @@ -642,7 +643,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element { }; return ( -
+

{messages.app.brand}

@@ -789,6 +790,7 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element { {props.projectSwitcher}
+ {props.workspaceStatus}
: !data ? {text.unavailable} : null} - {ready && data && filtered &&
- {config.view === 'overview' ? - : config.view === 'dependencies' ? + {ready && data && filtered &&
+ {config.view === 'overview' ? <> +
{text.summaryDetails}
+ : config.view === 'dependencies' ? : }
} {ready &&
{text.evidenceSummary}

{text.evidenceDetail}

} diff --git a/graph-ui/src/architecture/RepositoryMap.test.tsx b/graph-ui/src/architecture/RepositoryMap.test.tsx new file mode 100644 index 000000000..46770f2aa --- /dev/null +++ b/graph-ui/src/architecture/RepositoryMap.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import RepositoryMapView from './RepositoryMap'; +import type { GraphData, GraphNode } from '../galaxy/types'; +import type { ArchitectureOverviewDto } from '../core/intelligence-provider'; + +it('walks repository to an area, relationship evidence, file, symbol and source', async () => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); const onNavigate = vi.fn(); const onSelect = vi.fn(); + const makeNode = (id: number, name: string, path: string): GraphNode => ({ id, name, file_path: path, + start_line: 12, x: 0, y: 0, z: 0, label: 'Function', size: 1, color: '#fff' }); + const graph: GraphData = { total_nodes: 2, nodes: [makeNode(1, 'serve', 'src/api/server.ts'), makeNode(2, 'save', 'src/store/db.ts')], edges: [{ source: 1, target: 2, type: 'CALLS' }] }; + const overview: ArchitectureOverviewDto = { totalSymbols: 2, totalRelations: 1, symbolKinds: [], relationKinds: [], languages: [], groups: [], entryPoints: [], routes: [], clusters: [], layers: [], boundaries: [], hotspots: [], files: [] }; + const click = async (text: string) => { const buttons = [...container.querySelectorAll('button')]; const button = buttons.find(button => button.textContent === text) ?? buttons.find(button => button.textContent?.includes(text)); expect(button, text).toBeTruthy(); await act(async () => button!.click()); }; + try { + await act(async () => root.render()); + await click('src/api'); + expect(container.textContent).toContain('How this area connects'); + await click('1 edges · inspect'); + expect(container.querySelector('[aria-label="Relationship evidence"]')?.textContent).toContain('CALLS'); + await click('save'); expect(onSelect).toHaveBeenCalledWith(graph.nodes[1]); + await click('src/api/server.ts'); + await click('Read file'); expect(onNavigate).toHaveBeenCalledWith('src/api/server.ts'); + await click('serve'); expect(onSelect).toHaveBeenCalledWith(graph.nodes[0]); + await click('Repository'); + expect(container.textContent).toContain('Components and responsibilities'); + } finally { await act(async () => root.unmount()); container.remove(); } +}); diff --git a/graph-ui/src/architecture/RepositoryMap.tsx b/graph-ui/src/architecture/RepositoryMap.tsx new file mode 100644 index 000000000..8fd7d4f47 --- /dev/null +++ b/graph-ui/src/architecture/RepositoryMap.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import type { ArchitectureOverviewDto } from '../core/intelligence-provider'; +import type { GraphData, GraphNode } from '../galaxy/types'; +import { areaConnections, areaOf, repositoryMap, type MapEvidence } from './repository-map'; +import { entryCandidates, entryRole, entryRoutes, sourceCandidates, type SourceReader, type SourceQuote } from './repository-guide'; +import { useRepositoryGuide } from './useRepositoryGuide'; + +export interface RepositoryMapProps { + graph?: GraphData; + overview: ArchitectureOverviewDto; + filter: string; + onNavigate: (path: string, line?: number, name?: string) => void; + onSelect?: (node: GraphNode) => void; + selection?: GraphNode; + selectionPanel?: ReactNode; + graphNote?: string; + readSource?: SourceReader; +} + +function Nodes({ nodes, onSelect, onNavigate }: Pick & { nodes: GraphNode[] }) { + const [limit, setLimit] = useState(24); + return <>
    {nodes.slice(0, limit).map(node =>
  • + +
  • )}
{nodes.length > limit && }; +} + +export function RelationshipEvidence({ edges, onSelect, onNavigate }: Pick & { edges: MapEvidence[] }) { + const [limit, setLimit] = useState(12); + return
+

Static index edges can include heuristic name resolution. Open the declarations to verify the relationship; a call edge does not prove runtime execution.

+ {edges.slice(0, limit).map((edge, index) =>
+ + {edge.type} →{edge.id !== undefined && Edge #{edge.id}} + {edge.line !== undefined && edge.source.file_path && } + {edge.unverifiedLine !== undefined && Site unverified
Reported :{edge.unverifiedLine}
} +
Resolution{edge.strategy ? `Index method: ${edge.strategy}` : 'Resolution method not recorded.'} + {edge.confidence !== undefined && Index confidence: {edge.confidence}. This is a resolver score, not a defect probability.}
+ +
)} +

{Math.min(limit, edges.length)} of {edges.length} retained edges

+ {limit < edges.length && } +
; +} + +function Quote({ quote, onNavigate }: { quote: SourceQuote; onNavigate: RepositoryMapProps['onNavigate'] }) { + return
{quote.kind === 'readme' ? 'Project README' : 'Source documentation'} · author description +
{quote.text}
; +} + +export default function RepositoryMapView({ graph, overview, filter, onNavigate, onSelect, selection, selectionPanel, graphNote, readSource }: RepositoryMapProps) { + const map = useMemo(() => graph ? repositoryMap(graph) : undefined, [graph]); + const [areaPath, setAreaPath] = useState(); + const [file, setFile] = useState(); + const [showAll, setShowAll] = useState(false); + const [direction, setDirection] = useState<'incoming' | 'outgoing'>('outgoing'); + const [evidence, setEvidence] = useState<{ title: string; edges: MapEvidence[] }>(); + const [entryId, setEntryId] = useState(); + const selectionRef = useRef(null); + const evidenceRef = useRef(null); + useEffect(() => { selectionRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' }); }, [selection?.id]); + useEffect(() => { if (evidence) { evidenceRef.current?.scrollIntoView?.({ block: 'nearest' }); evidenceRef.current?.focus({ preventScroll: true }); } }, [evidence]); + const area = map?.areas.find(row => row.path === areaPath); + const openArea = (path?: string) => { setAreaPath(path); setFile(undefined); setEvidence(undefined); setShowAll(false); }; + const query = filter.trim().toLowerCase(); + const entries = useMemo(() => entryCandidates(graph?.nodes ?? []), [graph]); + const entry = entries.find(node => node.id === entryId) ?? entries[0]; + const routes = useMemo(() => entry && map ? entryRoutes(entry, map.evidence) : [], [entry, map]); + const primaryAreas = useMemo(() => [...(map?.areas ?? [])].sort((a, b) => { + const support = (path: string) => /(^|\/)(tests?|tools|scripts|docs|pkg|\.github|test-infrastructure)(\/|$)/.test(path); + return Number(support(a.path)) - Number(support(b.path)); + }), [map]); + const summaryEntries = overview.entryPoints.filter(entry => entry.filePath); + const readme = overview.files.find(path => /^readme\.(md|rst|txt)$/i.test(path)); + const sourcePaths = useMemo(() => [...new Set([...(readme ? [readme] : []), ...(area ? sourceCandidates(area) : []), + ...primaryAreas.slice(0, 12).flatMap(sourceCandidates)])], [readme, area, primaryAreas]); + const guide = useRepositoryGuide(sourcePaths, readSource); + const quoteFor = (files: string[]) => sourcePaths.filter(path => files.includes(path)).map(path => guide.quotes[path]).find(Boolean); + const areas = primaryAreas.filter(row => !query || row.path.toLowerCase().includes(query) + || row.nodes.some(node => node.name.toLowerCase().includes(query)) || quoteFor(row.files)?.text.toLowerCase().includes(query)); + const scopedNodes = area?.nodes.filter(node => node.file_path === file && !['File', 'Module'].includes(node.label)) ?? []; + const connections = area ? areaConnections(area, direction) : []; + const layers = area ? overview.layers.filter(layer => layer.group && (area.path === layer.group || area.path.endsWith(`/${layer.group}`))) : []; + return
+
+

Repository map · source responsibilities and indexed connections

+
Evidence legend

CALLS: indexed invocations. IMPORTS: static imports. USAGE: references. INHERITS / IMPLEMENTS: type contracts. DATA_FLOWS appears only for recorded data-flow edges. Directory areas and layer roles are navigation heuristics, not confirmed domain boundaries. Agent activity is shown separately.

+
+ {!map && graphNote &&

{graphNote}

} + {!map ?

Repository graph not available yet. The summary tabs remain available.

: <> +
{map.fileCount.toLocaleString()} indexed files · {map.evidence.length.toLocaleString()} retained edges · snapshot and limits + {graphNote &&

{graphNote}

} +

Bounded index snapshot; missing edges do not prove independence.{map.unresolvedEdges > 0 && ` ${map.unresolvedEdges} edges have an endpoint outside these source areas.`}

+
+ + {selection &&
Selected symbol · {areaOf(selection.file_path ?? '')}

{selection.name}

+
+ {selection.documentation &&
Indexed documentation

{selection.documentation}

} + {selectionPanel} +
} + {!area ? <> +
+

What this repository does

+ {readme && guide.quotes[readme] ? + :

{readSource && !guide.finished ? 'Reading the project introduction…' : 'No project introduction was available in the inspected source. Explore the component evidence below.'}

} + {readme && } +
+

Where to start

Index entry candidates, with roles inferred from file type and location. Select one to follow its static calls.

+ {entries.length ?
{entries.slice(0, 4).map(node => )}
+ : summaryEntries.length ?
    {summaryEntries.slice(0, 4).map(entry =>
  • )}
+ :

No entry point was identified. Start with a connected area below and inspect its exported interfaces.

} + {entry &&
Follow static call paths from {entry.name} · {routes.length} paths across files

Static paths from {entry.name}

+

Each step is a recorded CALLS edge. This shows possible reachability, not an observed runtime sequence or data flow.

+ {routes.length ? routes.map((route, index) =>
{[route.edges[0].source, ...route.edges.map(edge => edge.target)].map((node, step) => + {step > 0 && →})}
+
) + :

No call path across files was found within 5 calls and 500 symbols. Open the entry source or choose another candidate.

} + {evidence &&

{evidence.title}

} +
} +
+
+

Components and responsibilities

Boundaries follow source locations and are inferred. Descriptions quote source documentation; relationships come from indexed edges. Application areas appear before tests and tools.

+
{areas.slice(0, showAll ? undefined : 12).map(row => { const quote = quoteFor(row.files); return
+ + {quote ? :

{readSource && !guide.finished ? 'Checking source documentation…' : 'No responsibility description in the inspected file headers. Inspect interfaces and callers to establish its role.'}

} + +
; })}
+ {!areas.length &&

No source areas match. Clear the filter or inspect index diagnostics.

} + {areas.length > 12 && !showAll && } +
+ : <> +

{area.path}

{area.files.length} files · {area.nodes.length} indexed nodes

+ {quoteFor(area.files) && } + {layers.map(layer =>

Inferred role: {layer.layer}. {layer.reason || 'No further reason supplied by the index.'}

)} + {!file && <>

Entry points and interfaces

!area.entryPoints.includes(node))].slice(0, 8)} onSelect={onSelect} onNavigate={onNavigate} /> + {!area.entryPoints.length && !area.interfaces.length &&

No entry or export classification was supplied. Inspect a file below.

}} +
+ {evidence &&

{evidence.title}

} + {!file ?

How this area connects

+
+

Cross-area edges grouped by relation kind. Counts are retained graph edges, not call frequency.

+
    {connections.map(row =>
  • + {row.type} +
  • )}
{!connections.length &&

No cross-area edges found in this snapshot. This is not evidence of independence.

} +

Files

{area.files.map(path => )}
+ :

Symbols in {file}

+ +
} + } + } +
; +} diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx new file mode 100644 index 000000000..e3cc942d0 --- /dev/null +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import SourceEvidenceDrawer, { fileQualifiedName, type SourceEvidenceTarget } from './SourceEvidenceDrawer'; +import type { GraphData, GraphNode } from '../galaxy/types'; +import type { CodeSnippetResult } from '../provider/rpc-schemas'; +import { COLUMNS, fileNodeForPath, moduleForFile } from '../provider/cypher'; +import projectLockEof from './fixtures/project-lock-eof.json'; + +(globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; +const path = 'src/daemon/application.c'; +const moduleName = 'cbm-pr2068.src.daemon.application'; +const makeNode = (id: number, label: string, qualified_name: string): GraphNode => ({ + id, label, qualified_name, name: 'application.c', file_path: path, x: 0, y: 0, z: 0, size: 1, color: '#fff', +}); +const graph: GraphData = { total_nodes: 2, edges: [], nodes: [ + makeNode(1, 'File', 'cbm-pr2068.src.daemon.application.c.__file__'), makeNode(2, 'Module', moduleName), +] }; +// Synthetic text with the exact paging shape observed on real Module responses. +function page(first = 2901, overrides: Partial = {}): CodeSnippetResult { + return { qualified_name: moduleName, file_path: `/workspace/${path}`, source_mode: 'full', + start_line: first, end_line: first + 159, source: Array.from({ length: 160 }, (_, i) => `fixture source line ${first + i}`).join('\n') + '\n', + source_truncated: true, source_clipped: true, next_start_line: first + 160, original_end_line: 3571, ...overrides }; +} +const cleanups: (() => Promise)[] = []; +afterEach(async () => { for (const clean of cleanups.splice(0)) await clean(); vi.restoreAllMocks(); }); +async function mount(getCodeSnippet = vi.fn(async (_project: string, _qn: string, window?: { startLine?: number; maxLines?: number }) => page(window?.startLine)), + queryRows = vi.fn(async (_project: string, _query: string): Promise[]> => []), snapshot: GraphData | null = graph) { + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container), onClose = vi.fn(), onExplore = vi.fn(); + let mounted = true; + const unmount = async () => { if (mounted) { mounted = false; await act(async () => root.unmount()); } container.remove(); }; + cleanups.push(unmount); + const render = async (target: SourceEvidenceTarget = { filePath: path, line: 2909, kind: 'callsite' }, project = 'cbm-pr2068') => { + await act(async () => root.render()); + }; + await render(); + const click = async (name: string) => { + const button = [...container.querySelectorAll('button')].find(row => row.textContent === name); + expect(button, name).toBeTruthy(); await act(async () => button!.click()); + }; + return { container, render, click, onClose, onExplore, unmount, getCodeSnippet, queryRows }; +} + +it('prefers the full-range Module over the File fragment and highlights the actual call-site line', async () => { + expect(fileQualifiedName(graph, path)).toBe(moduleName); + const ui = await mount(); + expect(ui.getCodeSnippet).toHaveBeenCalledWith('cbm-pr2068', moduleName, { startLine: 2901, maxLines: 160 }); + expect(ui.queryRows).not.toHaveBeenCalled(); + expect(ui.container.textContent).toContain('Indexed call site'); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909'); + expect(ui.container.querySelectorAll('code > span')).toHaveLength(160); // No phantom trailing line. + expect(ui.container.querySelectorAll('a')).toHaveLength(0); // Line labels are not 160 empty tab stops. + expect(ui.container.querySelector('pre')?.tabIndex).toBe(0); +}); + +it('resolves source directly by project and path when Explore has no architecture snapshot', async () => { + const queryRows = vi.fn(async () => [{ [COLUMNS.moduleForFile[0]]: moduleName }]); + const ui = await mount(undefined, queryRows, null); + expect(queryRows).toHaveBeenCalledExactlyOnceWith('cbm-pr2068', moduleForFile(path)); + expect(ui.getCodeSnippet).toHaveBeenCalledWith('cbm-pr2068', moduleName, { startLine: 2901, maxLines: 160 }); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909'); +}); + +it('uses the verified File fallback and reports an unindexed path without guessing a name', async () => { + const fileName = moduleName + '.c.__file__'; + const queryRows = vi.fn(async (_project: string, query: string) => query === fileNodeForPath(path) + ? [{ [COLUMNS.fileNode[0]]: fileName }] : []); + const snippet = vi.fn(async () => page(2901, { qualified_name: fileName })); + const ui = await mount(snippet, queryRows, null); + expect(queryRows.mock.calls.map(call => call[1])).toEqual([moduleForFile(path), fileNodeForPath(path)]); + expect(snippet).toHaveBeenCalledWith('cbm-pr2068', fileName, { startLine: 2901, maxLines: 160 }); + await ui.render({ filePath: 'missing.c' }); + expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('No indexed Module or File declaration'); + expect(snippet).toHaveBeenCalledTimes(1); +}); + +it('does not continue a stale lookup after changing projects', async () => { + const pending: ((rows: Record[]) => void)[] = []; + const queryRows = vi.fn(() => new Promise[]>(resolve => pending.push(resolve))); + const ui = await mount(undefined, queryRows, null); + await ui.render({ filePath: path, line: 50 }, 'other-project'); + await act(async () => pending[0]([{ [COLUMNS.moduleForFile[0]]: 'old-project.module' }])); + expect(ui.getCodeSnippet).not.toHaveBeenCalled(); + await act(async () => pending[1]([{ [COLUMNS.moduleForFile[0]]: moduleName }])); + expect(ui.getCodeSnippet).toHaveBeenCalledExactlyOnceWith('other-project', moduleName, { startLine: 42, maxLines: 160 }); +}); + +it('paginates by the returned continuation, goes back, and resets a changed target location', async () => { + const ui = await mount(); + await ui.click('Next lines'); + expect(ui.getCodeSnippet).toHaveBeenLastCalledWith('cbm-pr2068', moduleName, { startLine: 3061, maxLines: 160 }); + expect(ui.container.querySelector('[data-selected=true]')).toBeNull(); + await ui.click('Previous lines'); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('2909'); + await ui.render({ filePath: path, line: 50 }); + expect(ui.getCodeSnippet).toHaveBeenLastCalledWith('cbm-pr2068', moduleName, { startLine: 42, maxLines: 160 }); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('50'); +}); + +it('does not paint a previous project reply under a new project heading', async () => { + const pending: ((value: CodeSnippetResult) => void)[] = []; + const getCodeSnippet = vi.fn(() => new Promise(resolve => pending.push(resolve))); + const ui = await mount(getCodeSnippet); + await ui.render({ filePath: path, line: 10 }, 'other-project'); + await act(async () => pending[0](page(2901, { source: 'old project secret' }))); + expect(ui.container.textContent).not.toContain('old project secret'); + expect(ui.container.textContent).toContain('Reading local source'); + await act(async () => pending[1](page(2))); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('10'); +}); + +it('labels an ignored source window honestly instead of relabeling the returned lines', async () => { + // The actual File-node reply ignored start_line=2901 and returned 1..51. + const ui = await mount(vi.fn(async () => page(1, { end_line: 51, source: Array(51).fill('fixture').join('\n'), + source_truncated: undefined, source_clipped: undefined, next_start_line: undefined }))); + expect(ui.container.textContent).toContain('Requested line 2909 is outside this returned window'); + expect(ui.container.querySelector('[data-selected=true]')).toBeNull(); + expect(ui.container.querySelector('.source-evidence-line-number')?.textContent).toBe('1'); +}); + +it.each([undefined, 2901, 9999])('does not follow a missing, repeating or skipping continuation (%s)', async next => { + const ui = await mount(vi.fn(async () => page(2901, { next_start_line: next }))); + const nextButton = [...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines'); + expect(nextButton?.disabled).toBe(true); + expect(ui.container.textContent).toContain('no valid next page'); +}); + +it('disables continuation at the reported end of source', async () => { + const ui = await mount(vi.fn(async () => page(2901, { source_truncated: false, source_clipped: false, next_start_line: undefined }))); + expect([...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines')?.disabled).toBe(true); + expect(ui.container.textContent).not.toContain('no valid next page'); +}); + +it('rejects source identity and line-range errors', async () => { + const ui = await mount(vi.fn(async () => page(2901, { qualified_name: 'other-project.secret' }))); + expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('different indexed file'); + expect(ui.container.querySelector('pre')).toBeNull(); +}); + +it('does not invent line evidence for a malformed source body', async () => { + const ui = await mount(vi.fn(async () => page(2901, { source: 'one line' }))); + expect(ui.container.querySelector('[role=alert]')?.textContent).toContain('does not match'); + expect(ui.container.querySelector('[data-selected=true]')).toBeNull(); + expect(ui.container.querySelector('.source-evidence-line-number')?.textContent).toBe(''); +}); + +it('preserves the actual Module EOF line reported by the source API and highlights its call site', async () => { + // Recorded local project_lock.c response, start_line=53/end_line=190. + // Its trailing empty segment is the indexed EOF line, not a page separator. + const ui = await mount(vi.fn(async () => page(53, { ...projectLockEof, source_mode: 'full', + source_truncated: undefined, source_clipped: undefined, next_start_line: undefined }))); + await ui.render({ filePath: path, line: 61 }); + expect(ui.container.querySelector('[role=alert]')).toBeNull(); + expect(ui.container.querySelectorAll('code > span')).toHaveLength(138); + expect(ui.container.querySelector('code > span:last-child .source-evidence-line-number')?.textContent).toBe('190'); + expect(ui.container.querySelector('[data-selected=true]')?.textContent).toContain('cbm_private_lock_directory_close(directory)'); + expect(ui.container.textContent).toContain('Lines 53 to 190'); + expect([...ui.container.querySelectorAll('button')].find(button => button.textContent === 'Next lines')?.disabled).toBe(true); +}); + +it('treats source markup as text and never requests external assets', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + const ui = await mount(vi.fn(async () => page(2901, { source: '', end_line: 2901 }))); + expect(ui.container.querySelector('img')).toBeNull(); + expect(ui.container.textContent).toContain(' { + const opener = document.createElement('button'); document.body.append(opener); opener.focus(); + const ui = await mount(); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Close source evidence'); + expect(ui.container.querySelector('[role=dialog]')?.getAttribute('aria-modal')).toBe('false'); + await act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + expect(ui.onClose).toHaveBeenCalledTimes(1); + await ui.unmount(); + expect(document.activeElement).toBe(opener); opener.remove(); +}); diff --git a/graph-ui/src/architecture/SourceEvidenceDrawer.tsx b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx new file mode 100644 index 000000000..bd9466077 --- /dev/null +++ b/graph-ui/src/architecture/SourceEvidenceDrawer.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from 'react'; +import type { RpcIntelligenceClient } from '../provider/rpc-client'; +import type { CodeSnippetResult } from '../provider/rpc-schemas'; +import type { GraphData } from '../galaxy/types'; +import { COLUMNS, fileNodeForPath, moduleForFile } from '../provider/cypher'; +import './source-evidence.css'; + +export interface SourceEvidenceTarget { filePath: string; line?: number; name?: string; kind?: 'callsite' | 'declaration' | 'file' } +const PAGE_LINES = 160; +const validLine = (value: number | undefined): value is number => value !== undefined && Number.isSafeInteger(value) && value > 0; + +export function fileQualifiedName(graph: GraphData | undefined, path: string): string | undefined { + // File nodes may carry 0..0 and only return the first source fragment. + // Module nodes own the whole-file range used by the source paging API. + return graph?.nodes.find(node => node.file_path === path && node.label === 'Module' && node.qualified_name)?.qualified_name + ?? graph?.nodes.find(node => node.file_path === path && node.label === 'File' && node.qualified_name)?.qualified_name; +} + +/** Source stays next to its referring map/analysis. No external resources are loaded. */ +export default function SourceEvidenceDrawer({ project, target, graph, client, onClose, onExplore }: { + project: string; target: SourceEvidenceTarget; graph?: GraphData; + client: Pick; onClose: () => void; + onExplore: (target: SourceEvidenceTarget) => void; +}) { + const qualifiedName = fileQualifiedName(graph, target.filePath); + const requestedLine = validLine(target.line) ? target.line : undefined; + const initialStart = Math.max(1, (requestedLine ?? 1) - 8); + const targetKey = JSON.stringify([project, target.filePath, requestedLine, qualifiedName]); + const [pageWindow, setPageWindow] = useState<{ key: string; start: number }>(); + const start = pageWindow?.key === targetKey ? pageWindow.start : initialStart; + const [refresh, setRefresh] = useState(0); + const readKey = JSON.stringify([targetKey, start, refresh]); + const activeKey = useRef(readKey); activeKey.current = readKey; + const [reading, setReading] = useState<{ key: string; source?: CodeSnippetResult; error?: string }>(); + // A new target can render before its effect. Never relabel old file contents. + const current = reading?.key === readKey ? reading : undefined; + const [copiedKey, setCopiedKey] = useState(''); + const [copyError, setCopyError] = useState<{ key: string; message: string }>(); + const closeRef = useRef(null); + const selectedRef = useRef(null); + const sourceRef = useRef(null); + const restoreFocus = useRef(true); + useEffect(() => { + const previous = document.activeElement; + closeRef.current?.focus(); + return () => { + if (restoreFocus.current && previous instanceof HTMLElement && previous.isConnected + && !previous.closest('[hidden], [inert]')) previous.focus({ preventScroll: true }); + }; + }, []); + useEffect(() => { + let alive = true; + const fail = (error: unknown) => { + if (alive) setReading({ key: readKey, error: error instanceof Error ? error.message : String(error) }); + }; + if (!project || !target.filePath || /[\x00-\x1f\x7f]/.test(target.filePath)) { + fail('Select a project and a valid repository file to inspect source evidence.'); + return; + } + void (async () => { + let resolvedName = qualifiedName; + if (!resolvedName) { + // Evidence can originate in Explore or a bounded impact result, + // before the user has ever loaded the architecture snapshot. + const modules = await client.queryRows(project, moduleForFile(target.filePath)); + if (!alive) return; + resolvedName = modules[0]?.[COLUMNS.moduleForFile[0]] || undefined; + if (!resolvedName) { + const files = await client.queryRows(project, fileNodeForPath(target.filePath)); + if (!alive) return; + resolvedName = files[0]?.[COLUMNS.fileNode[0]] || undefined; + } + } + if (!resolvedName) throw new Error('No indexed Module or File declaration was found for this project and path. Check index diagnostics or choose another source location.'); + const source = await client.getCodeSnippet(project, resolvedName, { startLine: start, maxLines: PAGE_LINES }); + if (!alive) return; + if (source.qualified_name && source.qualified_name !== resolvedName) throw new Error('The source reply identifies a different indexed file. Refresh the map before following this evidence.'); + const returnedPath = source.file_path?.replace(/\\/g, '/'); + if (returnedPath && returnedPath !== target.filePath && !returnedPath.endsWith(`/${target.filePath}`)) + throw new Error('The source reply identifies a different file path.'); + if (!validLine(source.start_line) || !validLine(source.end_line) || source.end_line < source.start_line) + throw new Error('The source reply has no usable line range. No source location can be verified.'); + if (source.source_mode && source.source_mode !== 'full') throw new Error('The daemon returned an outline instead of source. Open Explore to inspect the available file information.'); + if (source.source.trim() === '(source not available)') throw new Error('The daemon could not read the current local source.'); + setReading({ key: readKey, source }); + })().catch(fail); + return () => { alive = false; }; + }, [project, qualifiedName, start, client, readKey]); + const source = current?.source; + const lines = source ? source.source.split('\n') : []; + // A complete Module range may include its empty EOF line. A clipped page + // instead ends in a separator after its last reported line. The explicit + // source range distinguishes these cases; never accept other count gaps. + const reportedLines = source && validLine(source.start_line) && validLine(source.end_line) + ? source.end_line - source.start_line + 1 : undefined; + if (lines.at(-1) === '' && lines.length - 1 === reportedLines) lines.pop(); + const firstLine = source?.start_line; + const lastLine = validLine(firstLine) ? firstLine + lines.length - 1 : undefined; + const rangeMatches = source && lastLine === source.end_line; + const more = source?.source_truncated === true || source?.source_clipped === true; + const next = source?.next_start_line; + const canNext = Boolean(more && validLine(next) && validLine(lastLine) && next === lastLine + 1 && rangeMatches); + const locationVisible = requestedLine !== undefined && validLine(firstLine) && validLine(lastLine) + && requestedLine >= firstLine && requestedLine <= lastLine && rangeMatches; + useEffect(() => { + const panel = sourceRef.current, selected = selectedRef.current; + if (!panel) return; + panel.scrollTop = 0; + if (selected) { + // Scroll only this code pane; preserve the referring map's viewport. + panel.scrollTop = Math.max(0, selected.getBoundingClientRect().top - panel.getBoundingClientRect().top - panel.clientHeight / 2); + } + }, [source]); + const moveTo = (line: number) => setPageWindow({ key: targetKey, start: line }); + const copy = async () => { + try { + await navigator.clipboard.writeText(`${target.filePath}:${requestedLine ?? firstLine ?? 1}`); + if (activeKey.current === readKey) { setCopiedKey(readKey); setCopyError(undefined); } + } catch { + if (activeKey.current === readKey) setCopyError({ key: readKey, message: 'Copy unavailable. Select the location above to copy it manually.' }); + } + }; + return ; +} diff --git a/graph-ui/src/architecture/architecture.css b/graph-ui/src/architecture/architecture.css index a685972d8..e5ff9fc01 100644 --- a/graph-ui/src/architecture/architecture.css +++ b/graph-ui/src/architecture/architecture.css @@ -128,3 +128,101 @@ .atlas-arch-module-grid { grid-template-columns: minmax(0, 1fr); } .atlas-arch-map svg { height: 250px; } } + +/* Repository map: overview, area, file, symbol. Edges retain their evidence. */ +.repo-map { display: grid; gap: 16px; } +.repo-map-snapshot { font-size: 12px; color: var(--atlas-dim); } +.repo-map-snapshot summary { cursor: pointer; } +.repo-map h2 { font-size: 20px; } +.repo-map h3 { margin-bottom: 8px; } +.repo-map-intro { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; } +.repo-map-intro details { max-width: 470px; font-size: 12px; } +.repo-map-intro summary { cursor: pointer; color: var(--atlas-cyan); } +.repo-map-intro p { margin-top: 6px; } +.repo-map-note { font-size: 12px; color: var(--atlas-dim); line-height: 1.6; } +.repo-map-breadcrumb { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; } +.repo-map-breadcrumb button, .repo-map-files button, .repo-map-connections button { + border: 0; background: transparent; color: var(--atlas-cyan); padding: 4px 0; text-align: left; overflow-wrap: anywhere; +} +.repo-map-start { background: var(--atlas-panel); padding: 18px; border: 1px solid var(--atlas-line); border-left: 3px solid var(--atlas-cyan); } +.repo-map-start > .repo-map-nodes { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); } +.repo-map-areas { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 12px; margin: 14px 0; } +.repo-map-area { display: grid; gap: 8px; background: var(--atlas-panel); border: 1px solid var(--atlas-line); color: var(--atlas-fg); text-align: left; padding: 18px; min-height: 156px; } +.repo-map-area:hover { border-color: var(--atlas-cyan); } +.repo-map-area strong { font-size: 19px; color: var(--atlas-phosphor); } +.repo-map-area > span { font-size: 11px; color: var(--atlas-dim); overflow-wrap: anywhere; } +.repo-map-eyebrow { text-transform: uppercase; letter-spacing: .06em; font-size: 10px; color: var(--atlas-dim); } +.repo-map-columns { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 24px; } +.repo-map-nodes { display: grid; list-style: none; padding: 0; margin: 8px 0; gap: 6px; min-width: 0; } +.repo-map-nodes button { display: grid; gap: 3px; text-align: left; color: var(--atlas-fg); background: var(--atlas-panel); border: 1px solid var(--atlas-line); padding: 10px; width: 100%; overflow-wrap: anywhere; } +.repo-map-nodes button:hover { border-color: var(--atlas-cyan); } +.repo-map-nodes span { color: var(--atlas-dim); font: 10px/1.5 var(--atlas-font); } +.repo-map-directions { display: flex; gap: 6px; margin: 12px 0; } +.repo-map-directions button { padding: 7px 10px; border: 1px solid var(--atlas-line); background: var(--atlas-panel); color: var(--atlas-fg); } +.repo-map-directions button[aria-pressed="true"] { border-color: var(--atlas-cyan); color: var(--atlas-cyan); } +.repo-map-connections { list-style: none; padding: 0; } +.repo-map-connections li { display: grid; grid-template-columns: 1fr 90px 120px; gap: 8px; border-bottom: 1px solid var(--atlas-line); padding: 7px 0; align-items: center; } +.repo-map-connections span { font-size: 10px; color: var(--atlas-dim); } +.repo-map-files { display: grid; gap: 5px; max-height: 380px; overflow: auto; } +.repo-map-files button { font: 11px/1.5 var(--atlas-font); } +.repo-map-edge { display: grid; grid-template-columns: minmax(0, 1fr) 95px minmax(0, 1fr); gap: 7px; align-items: center; } +.repo-map-relation { text-align: center; font: 10px var(--atlas-font); color: var(--atlas-cyan); } +.repo-map-evidence { border: 1px solid var(--atlas-line); padding: 12px; margin-top: 10px; min-width: 0; } +.repo-map-evidence > p { font-size: 11px; } +.repo-map-selection { border-top: 2px solid var(--atlas-cyan); padding-top: 20px; scroll-margin: 20px; } +.repo-map-selection-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.repo-map-selection-heading button { white-space: normal; } +@media (max-width: 850px) { .repo-map-columns { grid-template-columns: 1fr; } .repo-map-intro, .repo-map-selection-heading { flex-direction: column; } .repo-map-edge { grid-template-columns: 1fr; } } + +/* The overview has one reading order; evidence opens next to its context. */ +.repo-map-purpose { padding: 16px 20px; border-radius: 9px; border: 1px solid var(--atlas-line); background: var(--atlas-panel); } +.repo-map-quote { padding: 12px 0; } +.repo-map-quote blockquote { margin: 6px 0 9px; font-size: 13px; color: var(--atlas-fg); line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.repo-map-quote button, .repo-map-section-title button, .repo-map-component-action { border: none; background: none; color: var(--atlas-cyan); text-align: left; padding: 3px 0; overflow-wrap: anywhere; } +.repo-map-quote button { font-size: 11px; } +.repo-map-purpose .repo-map-quote { padding: 0 0 10px; } +.repo-map-entry-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 12px; } +.repo-map-entry-grid > button { display: grid; gap: 6px; text-align: left; border: 1px solid var(--atlas-line); border-radius: 7px; color: var(--atlas-fg); padding: 12px; background: var(--atlas-bg); min-width: 0; } +.repo-map-entry-grid > button[aria-pressed=true] { border-color: var(--atlas-cyan); } +.repo-map-entry-grid > button > span:last-child { font: 10px/1.5 var(--atlas-font); color: var(--atlas-dim); overflow-wrap: anywhere; } +.repo-map-start { border-radius: 9px; } +.repo-map-flow { margin-top: 16px; } +.repo-map-flow > summary { color: var(--atlas-cyan); cursor: pointer; } +.repo-map-section-title { display: flex; gap: 16px; align-items: center; justify-content: space-between; margin: 12px 0; } +.repo-map-section-title h3 { margin: 0; } +.repo-map-route { border-top: 1px solid var(--atlas-line); padding: 12px 0; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } +.repo-map-route-steps { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; flex: 1; } +.repo-map-route-steps > span { display: flex; gap: 8px; align-items: center; } +.repo-map-route-steps button { display: grid; text-align: left; gap: 5px; padding: 10px; border-radius: 6px; border: 1px solid var(--atlas-line); background: var(--atlas-bg); color: var(--atlas-fg); max-width: 230px; } +.repo-map-route-steps strong { font-size: 11px; overflow-wrap: anywhere; } +.repo-map-route-steps small { color: var(--atlas-dim); font-size: 10px; } +.repo-map-route-arrow { color: var(--atlas-cyan); } +.repo-map-component { display: flex; flex-direction: column; padding: 16px; border: 1px solid var(--atlas-line); border-radius: 9px; background: var(--atlas-panel); min-width: 0; } +.repo-map-component:focus-within, .repo-map-component:hover { border-color: var(--atlas-cyan); } +.repo-map-component .repo-map-area { border: none; padding: 0; min-height: 0; background: transparent; } +.repo-map-component .repo-map-area strong { font-size: 17px; } +.repo-map-component .repo-map-quote { flex: 1; } +.repo-map-component-action { align-self: flex-start; margin-top: auto; } +.repo-map-no-description { font-size: 12px; padding: 12px 0; } +.repo-map-focused-evidence { border: 1px solid var(--atlas-cyan); border-radius: 9px; padding: 14px; background: var(--atlas-panel); scroll-margin: 12px; max-height: min(560px, 65vh); overflow: auto; } +.repo-map-focused-evidence .repo-map-evidence { border: none; padding: 0; } +.repo-map-area-heading .repo-map-quote { max-width: 780px; } +.repo-map-relation small { display: block; margin-top: 5px; color: var(--atlas-dim); } +.repo-map-relation button { display: block; margin: 6px auto; padding: 5px 8px; background: var(--atlas-panel-raised); border: 1px solid var(--atlas-line); color: var(--atlas-cyan); border-radius: 4px; } +@media (max-width: 900px) { .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 560px) { .repo-map-entry-grid { grid-template-columns: 1fr; } } + +.atlas-architecture .atlas-arch-heading { margin-bottom: 10px; } +.atlas-architecture .atlas-arch-heading p { display: none; } +.atlas-architecture .atlas-arch-toolbar { margin-bottom: 12px; } +.repo-map { gap: 12px; } +.repo-map-intro { font-size: 11px; } +.repo-map-onboarding { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 14px; align-items: start; } +.repo-map-onboarding .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.repo-map-onboarding .repo-map-purpose .repo-map-quote blockquote { -webkit-line-clamp: 5; } +.repo-map-onboarding:has(.repo-map-flow[open]) { grid-template-columns: minmax(0, 1fr); } +.repo-map-onboarding:has(.repo-map-flow[open]) .repo-map-entry-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } +@media (max-width: 900px) { .repo-map-onboarding { grid-template-columns: minmax(0, 1fr); }.repo-map-onboarding:has(.repo-map-flow[open]) .repo-map-entry-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.repo-map-resolution { font-size: 10px; margin-top: 8px; } +.repo-map-resolution summary { cursor: pointer; } +.repo-map-resolution[open] { text-align: left; padding: 6px; border: 1px solid var(--atlas-line); } diff --git a/graph-ui/src/architecture/fixtures/project-lock-eof.json b/graph-ui/src/architecture/fixtures/project-lock-eof.json new file mode 100644 index 000000000..e11c4cd9a --- /dev/null +++ b/graph-ui/src/architecture/fixtures/project-lock-eof.json @@ -0,0 +1,6 @@ +{ + "source": " }\n cbm_project_lock_manager_t *manager = calloc(1, sizeof(*manager));\n if (manager) {\n manager->directory = directory;\n manager->registry = cbm_lock_registry_new(directory);\n }\n if (!manager || !manager->registry) {\n free(manager);\n cbm_private_lock_directory_close(directory);\n return NULL;\n }\n return manager;\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_lease_release(cbm_project_lock_lease_t **lease_io) {\n if (!lease_io || !*lease_io) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n cbm_project_lock_lease_t *lease = *lease_io;\n cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK;\n if (lease->project) {\n result = cbm_lock_lease_release(&lease->project);\n if (lease->project) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n }\n if (lease->project_set) {\n cbm_private_file_lock_status_t set_status = cbm_lock_lease_release(&lease->project_set);\n if (set_status != CBM_PRIVATE_FILE_LOCK_OK) {\n result = set_status;\n }\n if (lease->project_set) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n }\n free(lease);\n *lease_io = NULL;\n return result;\n}\n\nstatic cbm_private_file_lock_status_t project_lock_failed_acquire(\n cbm_project_lock_lease_t *lease, cbm_private_file_lock_status_t status,\n cbm_project_lock_lease_t **lease_out) {\n if (!lease->project && !lease->project_set) {\n free(lease);\n return status;\n }\n cbm_project_lock_lease_t *cleanup = lease;\n cbm_private_file_lock_status_t cleanup_status = cbm_project_lock_lease_release(&cleanup);\n if (cleanup) {\n *lease_out = cleanup;\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status : CBM_PRIVATE_FILE_LOCK_IO;\n}\n\nstatic cbm_private_file_lock_status_t project_lock_acquire_internal(\n cbm_project_lock_manager_t *manager, const char *project, uint64_t deadline_ms,\n const cbm_lock_cancel_token_t *cancel_token, bool try_once,\n cbm_project_lock_lease_t **lease_out) {\n if (lease_out) {\n *lease_out = NULL;\n }\n if (!manager || !manager->registry || !project || !project[0] || !lease_out) {\n return CBM_PRIVATE_FILE_LOCK_UNSAFE;\n }\n bool wildcard = strcmp(project, \"*\") == 0;\n char project_key[PROJECT_LOCK_KEY_CAP];\n if (!wildcard && !project_lock_key(project, project_key)) {\n return CBM_PRIVATE_FILE_LOCK_UNSAFE;\n }\n cbm_project_lock_lease_t *lease = calloc(1, sizeof(*lease));\n if (!lease) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n cbm_private_file_lock_status_t status =\n try_once ? cbm_lock_registry_try_acquire(manager->registry, PROJECT_SET_KEY,\n wildcard ? CBM_PRIVATE_FILE_LOCK_EX\n : CBM_PRIVATE_FILE_LOCK_SH,\n &lease->project_set)\n : cbm_lock_registry_acquire(manager->registry, PROJECT_SET_KEY,\n wildcard ? CBM_PRIVATE_FILE_LOCK_EX\n : CBM_PRIVATE_FILE_LOCK_SH,\n deadline_ms, cancel_token, &lease->project_set);\n if (status != CBM_PRIVATE_FILE_LOCK_OK) {\n return project_lock_failed_acquire(lease, status, lease_out);\n }\n if (!wildcard) {\n status = try_once ? cbm_lock_registry_try_acquire(manager->registry, project_key,\n CBM_PRIVATE_FILE_LOCK_EX, &lease->project)\n : cbm_lock_registry_acquire(manager->registry, project_key,\n CBM_PRIVATE_FILE_LOCK_EX, deadline_ms,\n cancel_token, &lease->project);\n if (status != CBM_PRIVATE_FILE_LOCK_OK) {\n return project_lock_failed_acquire(lease, status, lease_out);\n }\n }\n *lease_out = lease;\n return CBM_PRIVATE_FILE_LOCK_OK;\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_acquire(cbm_project_lock_manager_t *manager,\n const char *project, uint64_t deadline_ms,\n const cbm_lock_cancel_token_t *cancel_token,\n cbm_project_lock_lease_t **lease_out) {\n return project_lock_acquire_internal(manager, project, deadline_ms, cancel_token, false,\n lease_out);\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_try_acquire(cbm_project_lock_manager_t *manager,\n const char *project,\n cbm_project_lock_lease_t **lease_out) {\n return project_lock_acquire_internal(manager, project, UINT64_MAX, NULL, true, lease_out);\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_request_cancel(cbm_project_lock_manager_t *manager,\n cbm_lock_cancel_token_t *token) {\n return manager ? cbm_lock_registry_request_cancel(manager->registry, token)\n : CBM_PRIVATE_FILE_LOCK_IO;\n}\n\ncbm_private_file_lock_status_t cbm_project_lock_manager_free(\n cbm_project_lock_manager_t **manager_io) {\n if (!manager_io || !*manager_io) {\n return CBM_PRIVATE_FILE_LOCK_IO;\n }\n cbm_project_lock_manager_t *manager = *manager_io;\n cbm_private_file_lock_status_t status = cbm_lock_registry_free(&manager->registry);\n if (status != CBM_PRIVATE_FILE_LOCK_OK) {\n return status;\n }\n cbm_private_lock_directory_close(manager->directory);\n manager->directory = NULL;\n free(manager);\n *manager_io = NULL;\n return CBM_PRIVATE_FILE_LOCK_OK;\n}\n", + "start_line": 53, + "end_line": 190, + "source_mode": "full" +} diff --git a/graph-ui/src/architecture/repository-guide.test.ts b/graph-ui/src/architecture/repository-guide.test.ts new file mode 100644 index 000000000..0cb221754 --- /dev/null +++ b/graph-ui/src/architecture/repository-guide.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest'; +import { entryCandidates, entryRole, entryRoutes, sourceCandidates, sourceQuote } from './repository-guide'; +import { repositoryMap } from './repository-map'; +import type { GraphData, GraphNode } from '../galaxy/types'; + +const node = (id: number, file_path: string, name: string, status: GraphNode['status'] = 'normal'): GraphNode => ({ + id, file_path, name, status, label: 'Function', x: 0, y: 0, z: 0, size: 1, color: '', +}); +const graph: GraphData = { total_nodes: 3, nodes: [node(1, 'src/api/main.ts', 'main', 'entry'), + node(2, 'src/service/users.ts', 'users'), node(3, 'src/store/save.ts', 'save')], + edges: [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'IMPORTS' }, { source: 2, target: 3, type: 'CALLS' }] }; + +it('separates application entries from test and tool entry candidates', () => { + const test = node(10, 'tests/server.test.ts', 'main', 'entry'); + const tool = node(11, 'graph-ui/tools/smoke.mjs', 'main', 'entry'); + const app = node(12, 'src/main.c', 'main', 'entry'); + expect(entryRole(test)).toBe('Tests'); expect(entryRole(tool)).toBe('Tools'); + expect(entryRole(node(13, 'pkg/npm/install.js', 'main', 'entry'))).toBe('Tools'); + expect(entryRole(node(14, 'setup.py', 'main', 'entry'))).toBe('Tools'); + expect(entryRole(node(15, '.git/hooks/pre-commit', 'main', 'entry'))).toBe('Tools'); + expect(entryRole(node(16, 'pkg/pypi/src/codebase_memory_mcp/_cli.py', 'main', 'entry'))).toBe('Application'); + expect(entryCandidates([test, tool, app])[0]).toBe(app); +}); + +it('quotes a real README paragraph while skipping badges, HTML and code', () => { + expect(sourceQuote('README.md', '# Project\n[![CI](badge.png)](https://example.com)\n\nA local repository index exposes symbols and relationships to coding tools.\n')) + .toEqual({ path: 'README.md', line: 4, text: 'A local repository index exposes symbols and relationships to coding tools.', kind: 'readme' }); + expect(sourceQuote('README.md', '# Project\n```\nThis is an example string with enough words to look like a description.\n```')).toBeUndefined(); +}); + +it('retains header location, ignores code and does not turn a license into a responsibility', () => { + expect(sourceQuote('src/store/store.h', '/*\n * Opaque SQLite graph store for repository symbols.\n * One handle per thread.\n */\nvoid store();')) + .toMatchObject({ line: 2, text: 'Opaque SQLite graph store for repository symbols. One handle per thread.' }); + expect(sourceQuote('src/a.ts', '// Copyright 2026 Example\n// Permission is hereby granted')).toBeUndefined(); + expect(sourceQuote('src/a.ts', 'export function veryImportantRepositoryModule() {}')).toBeUndefined(); +}); + +it('quotes only comments and never includes adjacent code or C preprocessor directives', () => { + expect(sourceQuote('src/a.h', '#ifndef HEADER_H\n#define HEADER_H\n// A helpful explanation appears after declarations.\nstruct Component {};')).toBeUndefined(); + expect(sourceQuote('src/a.c', '// Coordinates clients and resource subscriptions.\nint private_state = 99;')) + .toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 }); + expect(sourceQuote('src/a.c', '/* Coordinates clients and resource subscriptions. */ int private_state = 99;')) + .toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 }); + expect(sourceQuote('src/a.py', '#!/usr/bin/env python\n"""Coordinates clients and resource subscriptions."""\nprivate_state = 99')) + .toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 2 }); + expect(sourceQuote('src/a.py', '# Coordinates clients and resource subscriptions.\nprivate_state = 99')) + .toMatchObject({ text: 'Coordinates clients and resource subscriptions.', line: 1 }); + expect(sourceQuote('src/a.c', '/* Coordinates clients and resource subscriptions. */', 0)).toBeUndefined(); +}); + +it('ranks companion headers using recorded implementation connections, without module-name rules', () => { + const nodes = [node(1, 'src/widget/alphabet.h', 'config'), node(2, 'src/widget/service.h', 'api'), + node(3, 'src/widget/service.c', 'handle'), node(4, 'src/client/use.c', 'call')]; + const map = repositoryMap({ total_nodes: nodes.length, nodes, + edges: [{ source: 4, target: 3, type: 'CALLS' }] }); + expect(sourceCandidates(map.areas.find(area => area.path === 'src/widget')!)[0]).toBe('src/widget/service.h'); +}); + +it('follows only actual call edges across components and retains their evidence', () => { + const map = repositoryMap(graph); + const paths = entryRoutes(graph.nodes[0], map.evidence); + expect(paths[0].edges.map(edge => edge.target.name)).toEqual(['users', 'save']); + expect(paths[0].edges.every(edge => edge.type === 'CALLS')).toBe(true); + expect(paths[0].edges[0]).toBe(map.evidence[0]); + expect(entryRoutes(graph.nodes[2], map.evidence)).toEqual([]); +}); diff --git a/graph-ui/src/architecture/repository-guide.ts b/graph-ui/src/architecture/repository-guide.ts new file mode 100644 index 000000000..d4d7abd8e --- /dev/null +++ b/graph-ui/src/architecture/repository-guide.ts @@ -0,0 +1,121 @@ +import type { GraphNode } from '../galaxy/types'; +import { areaOf, type RepositoryArea, type MapEvidence } from './repository-map'; + +export interface SourceQuote { text: string; path: string; line: number; kind: 'readme' | 'header' } +export type SourceReader = (path: string, startLine?: number, maxLines?: number) => Promise<{ source: string; start_line?: number }>; +export type EntryRole = 'Application' | 'Frontend' | 'Tests' | 'Tools'; + +/** Labels are navigation heuristics; they do not promote a name into a verified entry point. */ +export function entryRole(node: GraphNode): EntryRole { + const path = node.file_path ?? ''; + if (node.status === 'test' || /(^|\/)(tests?|fixtures?|verification|eval)(\/|\.)|\.(test|spec)\./i.test(path)) return 'Tests'; + if (/(^|\/)(scripts?|tools?|hooks?|examples?|benchmarks?)(\/|\.)/i.test(path) + || /(^|\/)(install|uninstall|setup)\.[^/]+$/i.test(path)) return 'Tools'; + if (/\.(tsx|jsx|vue|svelte)$/.test(path)) return 'Frontend'; + return 'Application'; +} + +export function entryCandidates(nodes: GraphNode[]): GraphNode[] { + return nodes.filter(node => node.file_path && node.status === 'entry') + .sort((a, b) => (Number(entryRole(a) === 'Tests' || entryRole(a) === 'Tools') - Number(entryRole(b) === 'Tests' || entryRole(b) === 'Tools')) + || Number(/^(main|App|bootstrap|start)$/i.test(b.name)) - Number(/^(main|App|bootstrap|start)$/i.test(a.name)) + || (b.out_calls ?? 0) - (a.out_calls ?? 0) || a.name.localeCompare(b.name)); +} + +export function sourceCandidates(area: RepositoryArea): string[] { + const part = area.path.split('/').at(-1); + const degree = new Map(); + for (const edge of [...area.incoming, ...area.outgoing]) for (const node of [edge.source, edge.target]) { + if (node.file_path && areaOf(node.file_path) === area.path) degree.set(node.file_path, (degree.get(node.file_path) ?? 0) + 1); + } + // C declarations often live in a header, while recorded calls belong to its + // implementation. Let those observed connections rank companion headers; + // do not special-case a repository's module names. + const connectedness = (path: string) => /\.(h|hpp)$/.test(path) + ? Math.max(degree.get(path) ?? 0, ...['c', 'cc', 'cpp', 'cxx'].map(extension => degree.get(path.replace(/\.(h|hpp)$/, `.${extension}`)) ?? 0)) + : degree.get(path) ?? 0; + const score = (path: string) => (/\/README\.md$/i.test(path) ? 30000 : 0) + (/\.(h|hpp)$/.test(path) ? 10000 : 0) + (path.split('/').at(-1)?.split('.')[0] === part ? 20000 : 0) + + Math.min(5000, connectedness(path)) - path.split('/').length; + return area.files.filter(path => /\.(h|hpp|c|cc|cpp|cxx|ts|tsx|js|jsx|py|rs|go|java|cs|md)$/.test(path)) + .sort((a, b) => score(b) - score(a) || a.localeCompare(b)).slice(0, 2); +} + +function plainMarkdown(text: string): string { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '').replace(/<[^>]*>/g, '').trim(); +} + +/** Quote only repository-authored prose, never infer a responsibility from an identifier. */ +export function sourceQuote(path: string, source: string, start = 1): SourceQuote | undefined { + if (!Number.isSafeInteger(start) || start < 1) return undefined; + const lines = source.split('\n'); + if (/(^|\/)readme\.(md|txt|rst)$/i.test(path)) { + let fenced = false; + for (let index = 0; index < lines.length; index++) { + const raw = lines[index].trim(); + if (/^```|^~~~/.test(raw)) { fenced = !fenced; continue; } + if (fenced || !raw || /^(#|[!\[<|>]|[-=]{3})/.test(raw)) continue; + const text = plainMarkdown(raw); + if (text.length >= 45 && /[a-z]{3}\s+[a-z]{3}/i.test(text)) return { text: text.slice(0, 500), path, line: start + index, kind: 'readme' }; + } + return undefined; + } + const first = lines.findIndex(line => line.trim() && !/^#!/.test(line.trim())); + if (first < 0) return undefined; + const firstText = lines[first].trim(); + const hashComments = /\.(py|rb|sh|bash|zsh|yaml|yml|toml|r|pl|pm|ps1)$/i.test(path); + const block = firstText.startsWith('/*') ? ['/*', '*/'] + : firstText.startsWith('"""') ? ['"""', '"""'] + : firstText.startsWith("'''") ? ["'''", "'''"] : undefined; + const prefix = firstText.startsWith('//') ? '//' + : hashComments && firstText.startsWith('#') ? '#' : undefined; + if (!block && !prefix) return undefined; + const prose: { text: string; line: number }[] = []; + for (let index = first; index < Math.min(lines.length, first + 40); index++) { + let raw = lines[index].trim(), closed = false; + if (block) { + if (index === first) raw = raw.slice(block[0].length); + const end = raw.indexOf(block[1]); + if (end >= 0) { raw = raw.slice(0, end); closed = true; } + if (block[0] === '/*') raw = raw.replace(/^\*+\s?/, ''); + } else { + if (!raw.startsWith(prefix!)) break; + raw = raw.slice(prefix!.length); + } + const text = raw.trim(); + if (/copyright|SPDX|permission is hereby|licensed under|all rights reserved/i.test(text)) return undefined; + if (text) prose.push({ text, line: start + index }); + if (closed) break; + } + if (!prose.length) return undefined; + const text = prose.map(row => row.text).join(' '); + return text.length >= 30 && /[a-z]{3}\s+[a-z]{3}/i.test(text) ? { text: text.slice(0, 550), path, line: prose[0].line, kind: 'header' } : undefined; +} + +export interface StaticRoute { edges: MapEvidence[]; limited: boolean } +/** A bounded actual CALLS walk. Imports and co-change never become execution steps. */ +export function entryRoutes(entry: GraphNode, evidence: MapEvidence[]): StaticRoute[] { + const outgoing = new Map(); + for (const edge of evidence) if (edge.type === 'CALLS' && entryRole(edge.target) !== 'Tests' && entryRole(edge.target) !== 'Tools') { + const rows = outgoing.get(edge.source.id) ?? []; rows.push(edge); outgoing.set(edge.source.id, rows); + } + const queue: { node: GraphNode; edges: MapEvidence[] }[] = [{ node: entry, edges: [] }]; + const visited = new Set([entry.id]); const paths: MapEvidence[][] = []; + let cursor = 0; + while (cursor < queue.length && visited.size < 500) { + const row = queue[cursor++]; + if (row.edges.length >= 5) continue; + for (const edge of outgoing.get(row.node.id) ?? []) { + if (visited.has(edge.target.id)) continue; + visited.add(edge.target.id); + const edges = [...row.edges, edge]; + if (edge.source.file_path !== edge.target.file_path) paths.push(edges); + queue.push({ node: edge.target, edges }); + if (visited.size >= 500) break; + } + } + const areas = (edges: MapEvidence[]) => new Set([areaOf(entry.file_path!), ...edges.map(edge => areaOf(edge.target.file_path!))]).size; + const seen = new Set(); + return paths.sort((a, b) => areas(b) - areas(a) || a.length - b.length).filter(path => { + const target = path.at(-1)!.target.file_path!; if (seen.has(target)) return false; seen.add(target); return true; + }).slice(0, 3).map(edges => ({ edges, limited: cursor < queue.length })); +} diff --git a/graph-ui/src/architecture/repository-map.test.ts b/graph-ui/src/architecture/repository-map.test.ts new file mode 100644 index 000000000..263732fc9 --- /dev/null +++ b/graph-ui/src/architecture/repository-map.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { areaConnections, areaOf, repositoryMap } from './repository-map'; +import type { GraphData, GraphNode } from '../galaxy/types'; + +export const node = (id: number, file_path: string, name = `symbol${id}`, status: GraphNode['status'] = 'normal'): GraphNode => ({ + id, file_path, name, status, label: 'Function', qualified_name: `fixture.${name}`, start_line: id + 1, + x: 0, y: 0, z: 0, size: 1, color: '#fff', +}); +export const graph: GraphData = { total_nodes: 4, nodes: [node(1, 'src/api/server.ts', 'start', 'entry'), + node(2, 'src/service/users.ts', 'users'), node(3, 'src/store/db.ts', 'save'), node(4, 'test/users.test.ts', 'testUsers', 'test')], +edges: [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'CALLS' }, + { source: 2, target: 3, type: 'IMPORTS' }, { source: 4, target: 2, type: 'CALLS' }] }; + +describe('repository map evidence', () => { + it('retains exact directional edges and their source declarations across areas', () => { + const map = repositoryMap(graph); + const service = map.areas.find(area => area.path === 'src/service')!; + expect(service.incoming.map(edge => edge.source.name)).toEqual(['start', 'testUsers']); + expect(areaConnections(service, 'outgoing').map(row => [row.area, row.type, row.evidence.length])) + .toEqual([['src/store', 'CALLS', 1], ['src/store', 'IMPORTS', 1]]); + expect(service.outgoing[0].target).toBe(graph.nodes[2]); + expect(service.outgoing[0].source.start_line).toBe(3); + expect(map.areas.find(area => area.path === 'src/api')?.entryPoints[0].name).toBe('start'); + }); + it('does not invent edges, double-count retries, or turn containment into dependencies', () => { + const map = repositoryMap({ ...graph, edges: [...graph.edges, graph.edges[0], + { source: 1, target: 77, type: 'CALLS' }, { source: 1, target: 3, type: 'CONTAINS' }] }); + expect(map.evidence).toHaveLength(4); + expect(map.unresolvedEdges).toBe(1); + }); + it('keeps segment boundaries and source areas meaningful without merging similarly named folders', () => { + expect(areaOf('src/api/a.ts')).toBe('src/api'); + expect(areaOf('src/api2/b.ts')).toBe('src/api2'); + expect(areaOf('README.md')).toBe('(root)'); + expect(areaOf('packages/client/src/a.ts')).toBe('packages/client'); + expect(areaOf('tests/unit/users.ts')).toBe('tests'); + }); + it('keeps impossible index coordinates as unverified metadata, not source links', () => { + const source = { ...graph.nodes[0], start_line: 20, end_line: 80 }; + const map = repositoryMap({ ...graph, nodes: [source, ...graph.nodes.slice(1)], edges: [ + { id: 7, source: 1, target: 2, type: 'CALLS', line: 6989 }, + { id: 8, source: 1, target: 3, type: 'CALLS', line: 61 }, + ] }); + expect(map.evidence[0]).toMatchObject({ id: 7, line: undefined, unverifiedLine: 6989 }); + expect(map.evidence[1]).toMatchObject({ id: 8, line: 61, unverifiedLine: undefined }); + }); +}); diff --git a/graph-ui/src/architecture/repository-map.ts b/graph-ui/src/architecture/repository-map.ts new file mode 100644 index 000000000..6a772e2a3 --- /dev/null +++ b/graph-ui/src/architecture/repository-map.ts @@ -0,0 +1,101 @@ +import type { GraphData, GraphNode } from '../galaxy/types'; + +export const MAP_RELATIONS = ['CALLS', 'IMPORTS', 'USAGE', 'INHERITS', 'IMPLEMENTS', 'DATA_FLOWS'] as const; +export type MapRelation = typeof MAP_RELATIONS[number]; +export interface MapEvidence { source: GraphNode; target: GraphNode; type: MapRelation; id?: number; line?: number; unverifiedLine?: number; strategy?: string; confidence?: number } +export interface RepositoryArea { + path: string; + files: string[]; + nodes: GraphNode[]; + entryPoints: GraphNode[]; + interfaces: GraphNode[]; + incoming: MapEvidence[]; + outgoing: MapEvidence[]; +} +export interface RepositoryMap { + areas: RepositoryArea[]; + evidence: MapEvidence[]; + fileCount: number; + representedNodes: number; + totalNodes: number; + unresolvedEdges: number; +} + +/** A navigational partition, never a claim about domain ownership. */ +export function areaOf(path: string): string { + const parts = path.split('/').filter(Boolean); + if (parts.length < 2) return '(root)'; + // Source containers have a useful second level; all other directories remain + // visible intact instead of guessing a programming-language-specific layout. + if (['src', 'lib', 'internal', 'packages', 'apps', 'services'].includes(parts[0]) && parts.length > 2) { + return parts.slice(0, 2).join('/'); + } + return parts[0]; +} + +const isSource = (node: GraphNode) => Boolean(node.file_path && node.file_path !== '{}' + && !['Project', 'Folder', 'Package', 'Branch'].includes(node.label)); +const priority = (a: GraphNode, b: GraphNode) => (b.in_calls ?? 0) - (a.in_calls ?? 0) + || (b.out_calls ?? 0) - (a.out_calls ?? 0) || a.name.localeCompare(b.name); + +/** Linear in the already loaded graph. Every aggregate retains its real edges. */ +export function repositoryMap(graph: GraphData): RepositoryMap { + const areas = new Map(); + const nodes = new Map(graph.nodes.filter(isSource).map(node => [node.id, node])); + const files = new Map>(); + for (const node of nodes.values()) { + const path = areaOf(node.file_path!); + let area = areas.get(path); + if (!area) { + area = { path, files: [], nodes: [], entryPoints: [], interfaces: [], incoming: [], outgoing: [] }; + areas.set(path, area); files.set(path, new Set()); + } + files.get(path)!.add(node.file_path!); + area.nodes.push(node); + if (node.status === 'entry') area.entryPoints.push(node); + if (node.status === 'exported') area.interfaces.push(node); + } + const evidence: MapEvidence[] = []; + const seen = new Set(); + let unresolvedEdges = 0; + for (const edge of graph.edges) { + if (!MAP_RELATIONS.includes(edge.type as MapRelation)) continue; + const key = `${edge.source}:${edge.type}:${edge.target}`; + if (seen.has(key)) continue; + seen.add(key); + const source = nodes.get(edge.source); const target = nodes.get(edge.target); + if (!source || !target) { unresolvedEdges++; continue; } + // Some parser edges retain preprocessed coordinates. An out-of-range + // number is evidence metadata, never a verified navigable call site. + const validSite = edge.line !== undefined && source.start_line !== undefined && source.end_line !== undefined + && edge.line >= source.start_line && edge.line <= source.end_line; + const finding: MapEvidence = { source, target, type: edge.type as MapRelation, id: edge.id, + line: validSite ? edge.line : undefined, unverifiedLine: validSite ? undefined : edge.line, + strategy: edge.strategy, confidence: edge.confidence }; + evidence.push(finding); + const from = areas.get(areaOf(source.file_path!))!; + const to = areas.get(areaOf(target.file_path!))!; + if (from !== to) { from.outgoing.push(finding); to.incoming.push(finding); } + } + for (const area of areas.values()) { + area.files = [...files.get(area.path)!].sort(); + area.entryPoints.sort(priority); area.interfaces.sort(priority); area.nodes.sort(priority); + } + return { + areas: [...areas.values()].sort((a, b) => b.incoming.length + b.outgoing.length + - a.incoming.length - a.outgoing.length || a.path.localeCompare(b.path)), + evidence, fileCount: new Set([...files.values()].flatMap(set => [...set])).size, + representedNodes: nodes.size, totalNodes: graph.total_nodes, unresolvedEdges, + }; +} + +export function areaConnections(area: RepositoryArea, direction: 'incoming' | 'outgoing') { + const rows = new Map(); + for (const edge of area[direction]) { + const other = areaOf((direction === 'incoming' ? edge.source : edge.target).file_path!); + const key = `${other}:${edge.type}`; + const row = rows.get(key) ?? { area: other, type: edge.type, evidence: [] }; + row.evidence.push(edge); rows.set(key, row); + } + return [...rows.values()].sort((a, b) => b.evidence.length - a.evidence.length || a.area.localeCompare(b.area)); +} diff --git a/graph-ui/src/architecture/repository-source.test.ts b/graph-ui/src/architecture/repository-source.test.ts new file mode 100644 index 000000000..63bc4244a --- /dev/null +++ b/graph-ui/src/architecture/repository-source.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchRepositorySnapshot, readRepositorySnapshot, resolveRepositorySelection } from './repository-source'; + +describe('logical repository snapshot', () => { + it('retains source-free route identities without inventing a source file and derives call degree only from calls', () => { + const graph = readRepositorySnapshot({ generation: 'fixture-g1', indexed_at: '2026-09-09', total_nodes: 3, + nodes_truncated: false, edges_truncated: false, + nodes: [{ id: 1, label: 'Route', name: '/api/test' }, + { id: 2, label: 'Function', name: 'serve', file_path: 'src/api.ts', is_entry: true, start_line: 12, package_name: 'api' }, + { id: 3, label: 'Function', name: 'test', file_path: 'tests/api.ts', is_test: true }], + edges: [{ id: 8, source: 3, target: 2, type: 'CALLS', line: 30 }, { id: 9, source: 3, target: 2, type: 'IMPORTS' }] }); + expect(graph.nodes[0].file_path).toBeUndefined(); + expect(graph.nodes[1]).toMatchObject({ status: 'entry', in_calls: 1, package_name: 'api' }); + expect(graph.nodes[2]).toMatchObject({ status: 'test', out_calls: 1 }); + expect(graph.edges[0]).toMatchObject({ id: 8, source: 3, target: 2, type: 'CALLS', line: 30 }); + expect(graph.nodesTruncated).toBe(false); + }); + it('moves source navigation and documentation together with a stable symbol after reindexing', () => { + const snapshot = readRepositorySnapshot({ generation: 'new', nodes: [{ id: 12, name: 'serve', qualified_name: 'p.serve', file_path: 'server.ts', start_line: 40, docstring: 'Current docs' }], edges: [] }); + const old = { ...snapshot.nodes[0], id: 3, start_line: 10, documentation: 'Old docs' }; + expect(resolveRepositorySelection(old, snapshot)).toMatchObject({ id: 12, start_line: 40, documentation: 'Current docs' }); + expect(resolveRepositorySelection({ ...old, qualified_name: 'p.removed' }, snapshot)).toBeUndefined(); + }); + it('refuses missing snapshot metadata instead of labeling an empty graph as complete', () => { + expect(() => readRepositorySnapshot({ nodes: [], edges: [] })).toThrow('snapshot metadata'); + expect(readRepositorySnapshot({ nodes: [], edges: [], generation: 'fixture' }).nodesTruncated).toBe(true); + }); +}); + +it('recovers from an interrupted response and respects cancellation before retrying', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue({ ok: true, json: async () => ({ generation: 'reconnected', nodes: [], edges: [] }) }); + vi.stubGlobal('fetch', fetcher); + try { + const ready = fetchRepositorySnapshot('actual-project', new AbortController().signal); + await vi.runAllTimersAsync(); + expect((await ready).generation).toBe('reconnected'); expect(fetcher).toHaveBeenCalledTimes(2); + fetcher.mockReset().mockRejectedValue(new TypeError('Failed to fetch')); + const controller = new AbortController(); + const cancelled = fetchRepositorySnapshot('previous-project', controller.signal); + const assertion = expect(cancelled).rejects.toThrow('Aborted'); + await Promise.resolve(); controller.abort(); await assertion; + await vi.runAllTimersAsync(); expect(fetcher).toHaveBeenCalledTimes(1); + } finally { vi.unstubAllGlobals(); vi.useRealTimers(); } +}); + +it('does not retry a malformed graph or a permanent server response', async () => { + const fetcher = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ nodes: [], edges: [] }) }) + .mockResolvedValueOnce({ ok: false, status: 404 }); + vi.stubGlobal('fetch', fetcher); + try { + await expect(fetchRepositorySnapshot('p', new AbortController().signal)).rejects.toThrow('snapshot metadata'); + await expect(fetchRepositorySnapshot('p', new AbortController().signal)).rejects.toThrow('HTTP 404'); + expect(fetcher).toHaveBeenCalledTimes(2); + } finally { vi.unstubAllGlobals(); } +}); diff --git a/graph-ui/src/architecture/repository-source.ts b/graph-ui/src/architecture/repository-source.ts new file mode 100644 index 000000000..5538e502d --- /dev/null +++ b/graph-ui/src/architecture/repository-source.ts @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import type { GraphData, GraphNode, GraphEdge } from '../galaxy/types'; + +export interface RepositorySnapshot extends GraphData { + generation: string; + indexedAt: string; + nodesTruncated: boolean; + edgesTruncated: boolean; +} +const object = (value: unknown): Record => typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {}; +const string = (value: unknown): string => typeof value === 'string' ? value : ''; +const number = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0; + +/** Reuse symbol identity with the 3D graph. Coordinates are deliberately unused + * in this logical map; loading more architecture evidence never expands WebGL. */ +export function readRepositorySnapshot(value: unknown): RepositorySnapshot { + const raw = object(value); + if (!Array.isArray(raw.nodes) || !Array.isArray(raw.edges) || typeof raw.generation !== 'string') { + throw new Error('Repository map response has no graph snapshot metadata.'); + } + const nodes: GraphNode[] = raw.nodes.map(value => { + const row = object(value); + if (!Number.isSafeInteger(row.id)) throw new Error('Repository map contains an invalid source identity.'); + return { id: number(row.id), name: string(row.name), label: string(row.label), + file_path: string(row.file_path) || undefined, qualified_name: string(row.qualified_name), + start_line: number(row.start_line) || undefined, end_line: number(row.end_line) || undefined, + status: row.is_entry === true ? 'entry' : row.is_test === true ? 'test' + : row.is_exported === true ? 'exported' : undefined, + in_calls: 0, out_calls: 0, + documentation: string(row.docstring), x: 0, y: 0, z: 0, size: 1, color: '', + package_name: string(row.package_name), + }; + }); + const edges: GraphEdge[] = raw.edges.map(value => { + const row = object(value); + if (!Number.isSafeInteger(row.source) || !Number.isSafeInteger(row.target) || typeof row.type !== 'string') throw new Error('Repository map contains an invalid edge.'); + return { source: number(row.source), target: number(row.target), type: row.type, + id: number(row.id) || undefined, line: number(row.line) || undefined, + strategy: string(row.strategy) || undefined, + confidence: typeof row.confidence === 'number' && Number.isFinite(row.confidence) ? row.confidence : undefined }; + }); + const byId = new Map(nodes.map(node => [node.id, node])); + for (const edge of edges) { + if (edge.type !== 'CALLS') continue; + const source = byId.get(edge.source); const target = byId.get(edge.target); + if (source) source.out_calls = (source.out_calls ?? 0) + 1; + if (target) target.in_calls = (target.in_calls ?? 0) + 1; + } + return { nodes, edges, total_nodes: number(raw.total_nodes), generation: raw.generation, + indexedAt: string(raw.indexed_at), nodesTruncated: raw.nodes_truncated !== false, + edgesTruncated: raw.edges_truncated !== false }; +} + +/** Numeric IDs and source locations may change on reindexing. */ +export function resolveRepositorySelection(selected: GraphNode | undefined, snapshot?: GraphData): GraphNode | undefined { + if (!selected || !snapshot) return selected; + return snapshot.nodes.find(candidate => selected.qualified_name + ? candidate.qualified_name === selected.qualified_name && candidate.file_path === selected.file_path + : candidate.id === selected.id && candidate.name === selected.name && candidate.file_path === selected.file_path); +} + +/** A cold daemon or interrupted large response can recover without a page reload. */ +export async function fetchRepositorySnapshot(project: string, signal: AbortSignal, onRetry?: (attempt: number) => void): Promise { + for (let attempt = 0; ; attempt++) { + let raw: unknown; + try { + const response = await fetch(`/api/repository-map?${new URLSearchParams({ project })}`, { signal }); + if (!response.ok) { + const error = new Error(`Repository map returned HTTP ${response.status}.`); + if (![408, 429, 502, 503, 504].includes(response.status)) throw Object.assign(error, { permanent: true }); + throw error; + } + raw = await response.json(); + } catch (error) { + if (signal.aborted || attempt >= 2 || (error instanceof Error && 'permanent' in error)) throw error; + onRetry?.(attempt + 1); + await new Promise((resolve, reject) => { + const abort = () => { clearTimeout(timer); reject(new DOMException('Aborted', 'AbortError')); }; + const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, 750 * (attempt + 1)); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + continue; + } + // Invalid graph identities are a data error; retries cannot establish their meaning. + return readRepositorySnapshot(raw); + } +} + +export function useRepositorySnapshot(project: string, revision: number) { + const [reading, setReading] = useState<{ project: string; snapshot?: RepositorySnapshot; error?: string; loading: boolean }>({ project: '', loading: false }); + useEffect(() => { + if (!project) return; + const controller = new AbortController(); + setReading({ project, loading: true }); + void fetchRepositorySnapshot(project, controller.signal, attempt => { + if (!controller.signal.aborted) setReading({ project, loading: true, error: `Repository connection interrupted. Retrying (${attempt}/2)…` }); + }).then(snapshot => { if (!controller.signal.aborted) setReading({ project, snapshot, loading: false }); }) + .catch((error: unknown) => { if (!controller.signal.aborted) setReading({ project, loading: false, error: error instanceof Error ? error.message : String(error) }); }); + return () => controller.abort(); + }, [project, revision]); + return reading.project === project ? reading : { project, loading: Boolean(project) }; +} diff --git a/graph-ui/src/architecture/source-evidence.css b/graph-ui/src/architecture/source-evidence.css new file mode 100644 index 000000000..7877f3305 --- /dev/null +++ b/graph-ui/src/architecture/source-evidence.css @@ -0,0 +1,22 @@ +.source-evidence-drawer { position: fixed; z-index: 160; top: 72px; bottom: 34px; right: 16px; width: min(680px, 52vw); display: flex; flex-direction: column; gap: 10px; padding: 20px; color: var(--atlas-fg); background: var(--atlas-panel); border: 1px solid var(--atlas-cyan); border-radius: 12px; box-shadow: 0 12px 40px #0008; font: 13px/1.5 var(--atlas-ui-font); box-sizing: border-box; } +.source-evidence-drawer header, .source-evidence-drawer nav, .source-evidence-actions { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-shrink: 0; } +.source-evidence-actions { flex-wrap: wrap; } +.source-evidence-drawer header > div { min-width: 0; } +.source-evidence-drawer header > button { flex-shrink: 0; } +.source-evidence-drawer h2, .source-evidence-drawer p { margin: 0; } +.source-evidence-drawer h2 { font-size: 18px; overflow-wrap: anywhere; } +.source-evidence-drawer header span, .source-evidence-note { color: var(--atlas-dim); font-size: 11px; } +.source-evidence-drawer button { color: var(--atlas-fg); background: var(--atlas-panel-raised); border: 1px solid var(--atlas-line); border-radius: 6px; padding: 6px 10px; cursor: pointer; font: inherit; } +.source-evidence-drawer button:disabled { opacity: .45; cursor: default; } +.source-evidence-drawer :focus-visible { outline: 2px solid var(--atlas-cyan); outline-offset: 2px; } +.source-evidence-location { color: var(--atlas-cyan); overflow-wrap: anywhere; font-family: var(--atlas-font); } +.source-evidence-lines { flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 12px 0; border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-bg); font: 12px/1.7 var(--atlas-font); tab-size: 4; } +.source-evidence-lines code > span { display: block; min-width: max-content; padding-right: 12px; } +.source-evidence-lines br { display: none; } +.source-evidence-line-number { display: inline-block; width: 46px; padding-right: 12px; color: var(--atlas-dim); text-align: right; user-select: none; } +.source-evidence-drawer [role=alert] { border-left: 3px solid var(--atlas-red, #f87171); padding: 8px; background: var(--atlas-panel-raised); } +.source-evidence-notice { color: var(--atlas-amber, #f4c76b); } +.source-evidence-notice button { margin-left: 8px; } +.source-evidence-lines [data-selected=true] { background: var(--atlas-panel-raised); box-shadow: inset 3px 0 var(--atlas-cyan); } +@media (max-width: 900px) { .source-evidence-drawer { width: calc(100vw - 24px); right: 12px; top: 100px; } } +@media (max-height: 600px) { .source-evidence-drawer { top: 12px; bottom: 12px; padding: 12px; gap: 6px; } } diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index 15c9601b2..d7efabfa9 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -2,6 +2,11 @@ export const architectureText = { title: 'Architecture', subtitle: 'Understand the structure. Follow the evidence.', + summaryDetails: 'Index summary, layers and communities', + importWalk: 'Read by import order', + inspectEdges: 'Inspect edges', + retainedEdges: (count: number, reported: number) => `${count} retained edges from a boundary reporting ${reported} calls. Package membership uses the same index rule as the summary. Counts may differ across bounds or index updates.`, + edgeEvidenceUnavailable: 'No constituent edge is available in the loaded map snapshot. The summary count alone cannot establish a path or source location; refresh or inspect the source area.', views: { overview: 'Overview', dependencies: 'Dependencies', entryPoints: 'Entry points', routes: 'Routes', hotspots: 'Hotspots' }, navigation: 'Architecture views', refresh: 'Refresh', diff --git a/graph-ui/src/architecture/useRepositoryGuide.test.tsx b/graph-ui/src/architecture/useRepositoryGuide.test.tsx new file mode 100644 index 000000000..7c872c4dd --- /dev/null +++ b/graph-ui/src/architecture/useRepositoryGuide.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { useRepositoryGuide } from './useRepositoryGuide'; +import type { SourceReader } from './repository-guide'; + +(globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; +const cleanups: (() => Promise)[] = []; +afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); +async function mount(paths: string[], readSource?: SourceReader) { + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); + let result: ReturnType; + function Harness({ paths, reader }: { paths: string[]; reader?: SourceReader }) { + result = useRepositoryGuide(paths, reader); return null; + } + const render = async (nextPaths = paths, reader = readSource) => { + await act(async () => root.render()); + }; + cleanups.push(async () => { await act(async () => root.unmount()); container.remove(); }); + await render(); + return { render, result: () => result! }; +} + +it('finishes an empty inspection without leaving a permanent loading state', async () => { + const read = vi.fn(async () => ({ source: '' })); + const ui = await mount([], read); + expect(ui.result().finished).toBe(true); + expect(read).not.toHaveBeenCalled(); +}); + +it('does not transfer stale descriptions across a path or reader switch', async () => { + const resolve: ((value: { source: string }) => void)[] = []; + const reader = vi.fn(() => new Promise<{ source: string }>(done => resolve.push(done))); + const ui = await mount(['README.md'], reader); + await ui.render(['other/README.md']); + await act(async () => resolve[0]({ source: 'A previous project description must never appear in the selected repository.' })); + expect(ui.result().quotes).toEqual({}); + await act(async () => resolve[1]({ source: 'A current project description explains the selected repository to its readers.' })); + expect(Object.keys(ui.result().quotes)).toEqual(['other/README.md']); + const replacement = vi.fn(async () => { throw new Error('source unavailable'); }); + await ui.render(['other/README.md'], replacement); + expect(ui.result()).toMatchObject({ quotes: {}, finished: true, failures: 1 }); +}); diff --git a/graph-ui/src/architecture/useRepositoryGuide.ts b/graph-ui/src/architecture/useRepositoryGuide.ts new file mode 100644 index 000000000..5e7bd534f --- /dev/null +++ b/graph-ui/src/architecture/useRepositoryGuide.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { sourceQuote, type SourceQuote, type SourceReader } from './repository-guide'; + +/** Small, sequential batches keep source reads bounded and owned by this project snapshot. */ +export function useRepositoryGuide(paths: string[], readSource?: SourceReader) { + const [reading, setReading] = useState<{ key?: string; reader?: SourceReader; quotes: Record; finished: boolean; failures: number }>({ quotes: {}, finished: false, failures: 0 }); + const key = paths.join('\n'); + useEffect(() => { + let active = true; + const list = key.split('\n').filter(Boolean).slice(0, 29); + setReading({ key, reader: readSource, quotes: {}, finished: !readSource || list.length === 0, failures: 0 }); + if (!readSource || list.length === 0) return; + void (async () => { + const quotes: Record = {}; let failures = 0; + for (let index = 0; index < list.length && active; index += 3) { + const batch = list.slice(index, index + 3); + const results = await Promise.allSettled(batch.map(path => readSource(path, 1, 65))); + results.forEach((result, i) => { + if (result.status === 'fulfilled') { const quote = sourceQuote(batch[i], result.value.source, result.value.start_line); if (quote) quotes[batch[i]] = quote; } + else failures++; + }); + if (active) setReading({ key, reader: readSource, quotes: { ...quotes }, finished: index + 3 >= list.length, failures }); + } + })(); + return () => { active = false; }; + }, [key, readSource]); + return reading.reader === readSource && reading.key === key ? reading : { quotes: {}, finished: false, failures: 0 }; +} diff --git a/graph-ui/src/diagnostics/DaemonAlerts.test.tsx b/graph-ui/src/diagnostics/DaemonAlerts.test.tsx new file mode 100644 index 000000000..67c218f6a --- /dev/null +++ b/graph-ui/src/diagnostics/DaemonAlerts.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AtlasApi } from '../app/atlas-api'; +import { readLogs } from '../projects/projects-model'; +import { buildCoverageIndex } from '../app/tree-model'; +import DaemonAlerts from './DaemonAlerts'; +import { useDaemonLogFeed } from './useDaemonLogFeed'; + +let root: Root; let container: HTMLDivElement; +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.useRealTimers(); }); +const snapshot = (generation = 'db-a') => ({ lines: ['WARN routine', 'ERROR fixture parse failed'], total: 2, persistent: true, generation, retention_limit: 5000, records: [ + { id: 2, ts: '2026-09-09T12:00:01Z', level: 'warn', source: 'index.start', message: 'Routine warning' }, + { id: 1, ts: '2026-09-09T12:00:00Z', level: 'error', source: 'index.file', message: 'Fixture parse failure: src/broken.ts' }, +] }); +function Harness({ api }: { api: Pick }) { return ; } + +describe('Shared persistent daemon event feed', () => { + it('reads same-origin warn/error snapshots, prioritizes errors and recovers without duplicates', async () => { + let turn = 0; const calls: string[] = []; + const fetch = vi.fn(async (url: string | URL | Request) => { + calls.push(String(url)); turn += 1; + if (turn === 2) throw new Error('connection interrupted'); + return { ok: true, status: 200, text: async () => JSON.stringify(snapshot(turn > 3 ? 'db-after-restart' : 'db-a')) } as Response; + }); + const api = new AtlasApi({ fetch: fetch as typeof globalThis.fetch }); + await act(async () => root.render()); + expect(calls).toEqual(['/api/logs?lines=200&min_level=warn']); + expect(container.querySelector('li')).toBeNull(); + expect(container.querySelector('[data-testid="daemon-alerts"]')?.className).not.toContain('daemon-alerts-has-errors'); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (2 retained)')!.click()); + expect(container.querySelector('li')?.className).toBe('daemon-alert-error'); + expect(container.querySelector('li')?.textContent).toContain('! ERROR'); + expect(container.querySelector('li')?.textContent).toContain('src/broken.ts'); + expect(container.querySelector('time')?.dateTime).toBe('2026-09-09T12:00:00Z'); + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('connection interrupted'); + expect(container.querySelectorAll('li')).toHaveLength(2); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.textContent).toContain('shared history limit 5000'); + expect(calls.every((url) => url === '/api/logs?lines=200&min_level=warn')).toBe(true); + }); + it('keeps historical explanation and secondary controls inside the expanded history', async () => { + const data = readLogs(snapshot()); + const refresh = vi.fn(), onScopeChange = vi.fn(); + await act(async () => root.render()); + expect(container.querySelectorAll('.diagnostics-caveat')).toHaveLength(0); + expect([...container.querySelectorAll('button')].map(button => button.textContent)).toEqual(['History (2 retained)']); + await act(async () => container.querySelector('button')!.click()); + expect(container.textContent).toContain('Recorded history, not a count of active failures'); + expect(container.textContent).toContain('All daemon history'); + expect(container.textContent).toContain('Refresh events'); + }); + it('retains legacy tails but marks unavailable persistence and avoids a clean bill of health', async () => { + const data = readLogs({ lines: ['ERROR legacy failure'], total: 1, persistent: false }); + await act(async () => root.render()); + expect(container.textContent).toContain('Persistent event history unavailable'); + expect(container.querySelector('.daemon-alert-error')).toBeNull(); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (1 retained)')!.click()); + expect(container.querySelector('.daemon-alert-error')).not.toBeNull(); + await act(async () => root.render()); + expect(container.textContent).toContain('Unrecorded events and indexing gaps may still exist'); + }); + it('validates and deduplicates durable row IDs', () => { + const input = snapshot(); input.records.push(input.records[0]!); + const parsed = readLogs({ ...input, records: [...input.records, { id: -1, message: 'bad' }] }); + expect(parsed.records?.map((row) => row.id)).toEqual([1, 2]); + }); + it('shows shared index gaps immediately without a percentage or triggering diagnosis', async () => { + const onDiagnose = vi.fn(); + const coverage = buildCoverageIndex({ scopes: [{ requestedScope: '.', scope: '.', total: 2, status: 'complete', hasMore: false, entries: [{ path: 'broken.ts', kind: 'parse_partial', detail: '3-5' }, { path: 'vendor/', kind: 'not_indexed_dir', detail: 'gitignore' }] }] }); + await act(async () => root.render()); + expect(container.querySelector('.daemon-coverage-summary')?.textContent).toContain('1 partially parsed paths · 0 skipped · 1 intentionally excluded'); + expect(container.textContent).not.toMatch(/\d+%/); expect(onDiagnose).not.toHaveBeenCalled(); + }); + it('shows the actual JSON message ahead of receipt metadata and retains raw evidence', async () => { + const raw = JSON.stringify({ received: '2026-09-09T12:00:00Z', session: 'session-id', message: 'Parser failed in src/broken.ts' }); + const data = readLogs({ lines: [raw], records: [{ id: 20, ts: '2026-09-09T12:00:00Z', level: 'error', source: 'parser', message: raw }], total: 1, persistent: true }); + await act(async () => root.render()); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (1 retained)')!.click()); + expect(container.querySelector('li > pre')?.textContent).toBe('Parser failed in src/broken.ts'); + expect(container.querySelector('details pre')?.textContent).toBe(raw); + expect(container.textContent).toContain('Recorded history, not a count of active failures'); + }); + it('announces a genuinely new error and acknowledges it without deleting history', async () => { + const first = readLogs(snapshot()); + const render = async (data: typeof first) => act(async () => root.render()); + await render(first); + expect(container.querySelector('.daemon-alert-error')).toBeNull(); + const next = readLogs({ ...snapshot(), total: 3, records: [...snapshot().records, { id: 3, ts: '2026-09-09T12:01:00Z', level: 'error', source: 'index.worker', message: 'New actual failure', project: 'real-project' }] }); + await render(next); + expect(container.querySelector('.daemon-alert-error')?.textContent).toContain('New actual failure'); + expect(container.textContent).toContain('1 new recorded errors · real-project'); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'Acknowledge new events')!.click()); + expect(container.querySelector('.daemon-alert-error')).toBeNull(); + await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent === 'History (3 retained)')!.click()); + expect(container.querySelectorAll('li')).toHaveLength(3); + expect(container.textContent).toContain('New actual failure'); + }); + it('requires server project attribution and never shows a previous project while a new request fails', async () => { + const calls: string[] = []; + const fetch = vi.fn(async (url: string | URL | Request) => { + calls.push(String(url)); + if (String(url).includes('project=B')) throw new Error('B unavailable'); + return { ok: true, status: 200, text: async () => JSON.stringify({ ...snapshot(), scope: 'project', project: 'A' }) } as Response; + }); + const api = new AtlasApi({ fetch: fetch as typeof globalThis.fetch }); + function Scoped({ project }: { project: string }) { + const feed = useDaemonLogFeed(api, true, 100, project); + return
{feed.data?.project}:{feed.error}
; + } + await act(async () => root.render()); + expect(container.textContent).toBe('A:'); + await act(async () => root.render()); + expect(container.textContent).toContain('B unavailable'); + expect(container.textContent).not.toContain('A:'); + expect(calls).toEqual(['/api/logs?lines=200&min_level=warn&project=A', '/api/logs?lines=200&min_level=warn&project=B']); + }); + it('retains B during a failed refresh even when an older A request completes late', async () => { + let completeA!: (value: ReturnType) => void; + let bReads = 0; + const api = { logs: vi.fn((_lines: number, _level?: string, project?: string) => { + if (project === 'A') return new Promise>(resolve => { completeA = resolve; }); + bReads += 1; + if (bReads > 1) return Promise.reject(new Error('B refresh interrupted')); + return Promise.resolve(readLogs({ ...snapshot(), scope: 'project', project: 'B' })); + }) }; + function Scoped({ project }: { project: string }) { + const feed = useDaemonLogFeed(api, true, 100, project); + return
{feed.data?.project}:{feed.error}
; + } + await act(async () => root.render()); + await act(async () => root.render()); + expect(container.textContent).toBe('B:'); + await act(async () => completeA(readLogs({ ...snapshot(), scope: 'project', project: 'A' }))); + expect(container.textContent).toBe('B:'); + await act(async () => vi.advanceTimersByTimeAsync(100)); + expect(container.textContent).toBe('B:B refresh interrupted'); + }); + +}); diff --git a/graph-ui/src/diagnostics/DaemonAlerts.tsx b/graph-ui/src/diagnostics/DaemonAlerts.tsx new file mode 100644 index 000000000..2782e02ec --- /dev/null +++ b/graph-ui/src/diagnostics/DaemonAlerts.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { LogTail } from '../projects/projects-model'; +import { logLevel } from '../system/system-model'; +import type { SystemReading } from '../system/useSystemPoll'; +import type { CoverageIndex } from '../app/tree-model'; +import { diagnosticCategory, diagnosticRecords } from './diagnostics-model'; +import './diagnostics.css'; + +function readableMessage(raw: string): string { + try { + const record: unknown = JSON.parse(raw); + if (record && typeof record === 'object' && !Array.isArray(record) && 'message' in record && typeof record.message === 'string') return record.message; + } catch { /* Plain daemon log lines already are the display message. */ } + return raw; +} + +export interface DaemonAlertsProps { + project?: string; + scope?: 'project' | 'daemon'; + onScopeChange?: (scope: 'project' | 'daemon') => void; + reading: SystemReading & { refresh?: () => void }; + onDiagnose?: () => void; + coverage?: CoverageIndex | null; + coverageKnown?: boolean; + coverageError?: string | null; +} + +/** Priority feed independent of graph selection; these are daemon events, not graph facts. */ +export default function DaemonAlerts({ reading, onDiagnose, coverage, coverageKnown, coverageError, project, scope = 'project', onScopeChange }: DaemonAlertsProps) { + const [expanded, setExpanded] = useState(scope === 'daemon'); + const [seen, setSeen] = useState<{ generation: string; id: number }>(); + const generation = reading.data?.generation ?? 'legacy'; + const cursor = Math.max(0, ...(reading.data?.records ?? []).map(row => row.id)); + useEffect(() => { + if (reading.data && seen?.generation !== generation) setSeen({ generation, id: cursor }); + }, [reading.data, seen?.generation, generation, cursor]); + const records = useMemo(() => { + const rows = reading.data?.records?.map((record) => ({ + ...record, key: `${reading.data?.generation ?? 'daemon'}:${record.id}`, + severity: logLevel(`level=${record.level}`), + })) ?? (reading.data?.lines ?? []).map((line, index) => ({ + key: `legacy:${index}`, id: index, ts: '', level: '', source: 'Daemon log', message: line, severity: logLevel(line), + })); + return rows.filter((row) => row.severity === 'error' || row.severity === 'warn') + .sort((left, right) => (left.severity === 'error' ? 0 : 1) - (right.severity === 'error' ? 0 : 1) || right.id - left.id); + }, [reading.data]); + const errors = records.filter((record) => record.severity === 'error').length; + const fresh = seen?.generation === generation ? records.filter(row => row.id > seen.id) : []; + const newErrors = scope === 'project' ? fresh.filter(row => row.severity === 'error') : []; + const visible = expanded ? records : newErrors.slice(0, 1); + const acknowledge = () => { setSeen({ generation, id: cursor }); setExpanded(false); }; + const coverageSummary = useMemo(() => { + if (coverageError) return 'Index coverage unavailable; the map may be incomplete.'; + if (!coverage || coverageKnown === false) return 'Index coverage unknown.'; + const rows = diagnosticRecords(coverage); + if (!rows.length) return 'No coverage gaps recorded; complete indexing is not guaranteed.'; + const partial = rows.filter((row) => row.state === 'partial').length; + const excluded = rows.filter((row) => diagnosticCategory(row) === 'excluded').length; + const skipped = rows.filter((row) => row.state === 'skipped').length; + return `Index coverage: ${partial} partially parsed paths · ${skipped} skipped · ${excluded} intentionally excluded${coverage.truncations.length ? ' · detail listing incomplete' : ''}.`; + }, [coverage, coverageKnown, coverageError]); + return
+
+ {scope === 'daemon' ? 'Daemon history · all projects' : newErrors.length ? `! ${newErrors.length} new recorded errors · ${project ?? 'this project'}` : `Project events${project ? ` · ${project}` : ''}`} + {reading.error ? 'Connection interrupted · last reading' : reading.loading && !reading.data ? 'Reading events…' : reading.updatedAt ? `Last checked ${new Date(reading.updatedAt).toLocaleTimeString()}` : 'No event reading yet'} + + {newErrors.length > 0 && } + {expanded && onScopeChange && } + {expanded && reading.refresh && } + {onDiagnose && } +
+ {(coverageKnown !== undefined || coverage !== undefined || coverageError) &&

{coverageSummary}

} + {reading.error &&

Event updates failed: {reading.error}. A missing update does not mean the daemon recovered.

} + {reading.data?.persistent !== true && reading.data &&

Persistent event history unavailable. This log tail may not survive a restart.

} + {expanded &&

Recorded history, not a count of active failures. {scope === 'project' ? 'Only events attributed to this project; older unattributed records remain in daemon history.' : 'Includes other projects and older events without project attribution.'} Acknowledging events does not resolve their causes or delete them.

} + {visible.length > 0 &&
    {visible.map((record) => { + const message = readableMessage(record.message); + return
  1. +
    {record.severity === 'error' ? '! ERROR' : '△ WARNING'} {record.source || 'Source not reported'} {record.ts && } {reading.data?.records && Event #{record.id}}{scope === 'daemon' && Project: {'project' in record && typeof record.project === 'string' ? record.project : 'not recorded'}}
    +
    {message.length > 400 ? `${message.slice(0, 400)}…` : message}
    +
    Full recorded event
    {record.message}
    +
  2. })}
} + {expanded && records.length === 0 && reading.data &&

No attributed warning/error events in this retained reading. Unrecorded events and indexing gaps may still exist.

} + {reading.data && expanded &&

{records.length} of {reading.data.total} matching retained events shown · {errors} errors in this page{reading.data.retentionLimit !== undefined ? ` · shared history limit ${reading.data.retentionLimit}` : ''}. Older events may have expired. Project and file details appear only when recorded.

} +
; +} diff --git a/graph-ui/src/diagnostics/DiagnosticsPanel.test.tsx b/graph-ui/src/diagnostics/DiagnosticsPanel.test.tsx new file mode 100644 index 000000000..4ced0bc5e --- /dev/null +++ b/graph-ui/src/diagnostics/DiagnosticsPanel.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RpcIntelligenceClient } from '../provider/rpc-client'; +import { buildCoverageIndex } from '../app/tree-model'; +import DiagnosticsPanel, { type DiagnosticsPanelProps } from './DiagnosticsPanel'; + +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +async function click(text: string) { + const button = [...container.querySelectorAll('button')].find((item) => item.textContent === text); + expect(button).toBeDefined(); await act(async () => button!.click()); +} + +function clientFixture() { + const calls: { url: string; tool: string; args: Record }[] = []; + const fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + calls.push({ url: String(url), tool: body.params.name, args: body.params.arguments }); + if (String(url) !== '/rpc' || init?.method !== 'POST') throw new Error('Unexpected external request'); + let payload: unknown; + if (body.params.name === 'index_status') payload = { parse_partial: { count: 1, files: [{ path: 'src/broken.ts', error_ranges: '3-5' }] } }; + else if (body.params.name === 'check_index_coverage') payload = body.params.arguments.paths + ? { paths: [{ path: 'src/broken.ts', status: 'partial', freshness: 'metadata_changed', recommended_action: 'read_source_and_reindex', coverage: [{ path: 'src/broken.ts', kind: 'parse_partial', detail: '3-5' }] }] } + : { metadata: { generation: 'fixture-generation', generation_matches: true, recording_status: 'complete' }, scopes: [{ scope: '.', total: 1, has_more: false, entries: [{ path: 'src/broken.ts', kind: 'parse_partial', detail: '3-5' }] }] }; + else throw new Error('Unexpected tool/agent action'); + return { ok: true, status: 200, text: async () => JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: JSON.stringify(payload) }] } }) } as Response; + }); + return { client: new RpcIntelligenceClient({ fetch: fetch as typeof globalThis.fetch }), calls, fetch }; +} + +describe('Local diagnosis user boundary', () => { + it('does no read until requested, uses existing tools, and only copies the edited report', async () => { + const fixture = clientFixture(); const outside = vi.fn(); vi.stubGlobal('fetch', outside); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + const onNavigate = vi.fn(); const onCoverage = vi.fn(); + await act(async () => root.render()); + expect(fixture.calls).toHaveLength(0); expect(container.querySelector('textarea')).toBeNull(); + await click('Run local diagnosis'); + expect(fixture.calls.map((call) => call.tool)).toEqual(['index_status', 'check_index_coverage', 'check_index_coverage']); + expect(fixture.calls.every((call) => call.url === '/rpc')).toBe(true); + expect(container.textContent).toContain('File size or modification time changed'); + expect(container.textContent).toContain('fixture-generation'); expect(onCoverage).toHaveBeenCalledOnce(); + const area = container.querySelector('textarea')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(area, 'Reviewed, redacted draft'); + area.dispatchEvent(new Event('input', { bubbles: true })); + }); + await click('Copy edited report'); expect(writeText).toHaveBeenCalledWith('Reviewed, redacted draft'); + const createObjectURL = vi.fn().mockReturnValue('blob:local-diagnostic-report'); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL }); + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL }); + const linkClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + expect(this.href).toBe('blob:local-diagnostic-report'); expect(this.download).toBe('local-index-diagnosis.txt'); + }); + await click('Download edited report'); expect(createObjectURL).toHaveBeenCalledOnce(); expect(linkClick).toHaveBeenCalledOnce(); expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-diagnostic-report'); + await click('src/broken.ts'); expect(onNavigate).toHaveBeenCalledWith('src/broken.ts'); + expect(fixture.calls).toHaveLength(3); expect(outside).not.toHaveBeenCalled(); + expect(container.querySelector('a[href^="http"]')).toBeNull(); + expect([...container.querySelectorAll('button')].some((button) => /upload|publish|create issue/i.test(button.textContent ?? ''))).toBe(false); + }); + it('never presents a failed read as complete coverage', async () => { + const fixture = clientFixture(); fixture.fetch.mockRejectedValue(new Error('SQLite busy')); + await act(async () => root.render()); + await click('Run local diagnosis'); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('SQLite busy'); + expect(container.textContent).toContain('Completeness is unknown'); + expect(container.querySelector('textarea')).toBeNull(); + }); + it('renders shared coverage reasons and an honest empty state without refetching', async () => { + const fixture = clientFixture(); const props: DiagnosticsPanelProps = { project: 'fixture', client: fixture.client, active: true, onClose: vi.fn(), onNavigate: vi.fn(), coverage: buildCoverageIndex({}) }; + await act(async () => root.render()); + expect(container.textContent).toContain('does not prove complete indexing'); + expect(fixture.calls).toHaveLength(0); + await act(async () => root.render()); + expect(container.textContent).toContain('Excluded by index rules'); expect(container.textContent).toContain('gitignore'); + }); + it('discards a diagnosis from a previous project and does not continue its path query', async () => { + let resolve!: (data: unknown) => void; + const fixture = clientFixture(); + vi.spyOn(fixture.client, 'indexStatusPayload').mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + const onCoverage = vi.fn(); + const props = { project: 'old-project', client: fixture.client, active: true, path: 'old.ts', onClose: vi.fn(), onNavigate: vi.fn(), onCoverage }; + await act(async () => root.render()); + await click('Run local diagnosis'); + await act(async () => root.render()); + await act(async () => resolve({})); + expect(container.textContent).toContain('new-project'); expect(container.querySelector('textarea')).toBeNull(); + expect(onCoverage).not.toHaveBeenCalled(); + // loadCoverage owns its bounded scope lookup; the obsolete selected + // path must never be requested or committed afterward. + expect(fixture.calls.some((call) => call.args['paths'] !== undefined)).toBe(false); + }); +}); diff --git a/graph-ui/src/diagnostics/DiagnosticsPanel.tsx b/graph-ui/src/diagnostics/DiagnosticsPanel.tsx new file mode 100644 index 000000000..218a2cef7 --- /dev/null +++ b/graph-ui/src/diagnostics/DiagnosticsPanel.tsx @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { loadCoverage, loadPathCoverage, type CoverageReading } from '../app/coverage-source'; +import type { CoverageIndex } from '../app/tree-model'; +import type { RpcIntelligenceClient } from '../provider/rpc-client'; +import { DIAGNOSTIC_LABELS, diagnosticAction, diagnosticCategory, diagnosticRecords, freshnessText, localDiagnosticReport, type LocalDiagnosis } from './diagnostics-model'; +import './diagnostics.css'; + +export interface DiagnosticsPanelProps { + project: string; + client: RpcIntelligenceClient; + coverage?: CoverageIndex | null; + coverageError?: string | null; + active: boolean; + onClose: () => void; + path?: string; + onNavigate: (path: string) => void; + /** App can update its shared coverage reading from the same diagnosis. */ + onCoverage?: (reading: CoverageReading) => void; +} + +export default function DiagnosticsPanel({ project, client, coverage, coverageError, active, onClose, path, onNavigate, onCoverage }: DiagnosticsPanelProps) { + const [diagnosis, setDiagnosis] = useState(null); + const [report, setReport] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(''); + const [query, setQuery] = useState(''); + const request = useRef(0); + useEffect(() => { + request.current += 1; + setDiagnosis(null); setReport(''); setBusy(false); setError(null); setStatus(''); setQuery(''); + return () => { request.current += 1; }; + }, [project, path, active]); + const index = diagnosis?.reading.index ?? coverage; + const records = useMemo(() => diagnosticRecords(index), [index]); + const filtered = useMemo(() => records.filter((record) => `${record.path} ${record.reason}`.toLowerCase().includes(query.toLowerCase())), [records, query]); + const run = async () => { + const ticket = ++request.current; + setBusy(true); setError(null); setStatus(''); + try { + const reading = await loadCoverage(client, project); + if (ticket !== request.current) return; + const pathAnswer = path ? await loadPathCoverage(client, project, path) : undefined; + if (ticket !== request.current) return; + const result: LocalDiagnosis = { project, requestedPath: path, checkedAt: new Date().toISOString(), reading, pathAnswer }; + setDiagnosis(result); setReport(localDiagnosticReport(result)); onCoverage?.(reading); + } catch (reason) { + if (ticket === request.current) setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + if (ticket === request.current) setBusy(false); + } + }; + const copy = async () => { + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable; select the draft and copy it manually.'); + await navigator.clipboard.writeText(report); setStatus('Edited report copied locally. Nothing was sent.'); + } catch (reason) { setStatus(reason instanceof Error ? reason.message : String(reason)); } + }; + const download = () => { + const url = URL.createObjectURL(new Blob([report], { type: 'text/plain;charset=utf-8' })); + const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'local-index-diagnosis.txt'; + anchor.click(); URL.revokeObjectURL(url); setStatus('Edited report downloaded locally. Nothing was sent.'); + }; + if (!active) return null; + return
+

LOCAL EVIDENCE

Index coverage diagnosis

{project}{path && <> · Selected {path}}

+

The map can omit excluded files, unsupported inputs and failed or partial parses. Inspect the recorded reasons before relying on missing graph relationships.

+ {(error || coverageError) &&

Coverage reading failed: {error || coverageError}. {index ? 'Previous records remain visible; this is not a fresh reading.' : 'Completeness is unknown.'}

} +
Reads existing coverage metadata through this daemon. No agent, upload or issue creation.
+ {!index &&

Coverage evidence has not been loaded. Run a local diagnosis to inspect the index records.

} + {index && <> +
{(['partial', 'excluded', 'unsupported', 'skipped', 'unknown'] as const).map((category) => {DIAGNOSTIC_LABELS[category]}: {records.filter((record) => diagnosticCategory(record) === category).length})}
+

Counts describe returned path records, including directories. They do not measure all repository files. Unsupported inputs and files never discovered may be absent from these records.

+

Partial parses mark source ranges the index parser could not interpret reliably. Grammar or preprocessor limitations and malformed source can cause this; a partial parse alone does not establish a build or runtime failure. Exclusions follow indexing rules.

+ {index.listingComplete &&

All recorded coverage entries have been loaded. The parser limitations and exclusions listed below still apply.

} + {index.truncations.map((note) =>

Incomplete list: {note}

)} + } + {diagnosis &&

Checked {diagnosis.checkedAt} · Index generation {diagnosis.reading.answer.metadata.generation || 'unavailable'} · Recording {diagnosis.reading.answer.metadata.recordingStatus || 'unavailable'}

+ {!diagnosis.reading.answer.metadata.generationMatches &&

Coverage generation does not confirm the current index. Treat the map as potentially stale.

} + {path &&

{diagnosis.pathAnswer ? freshnessText(diagnosis.pathAnswer.freshness) : 'No path-specific evidence returned. Freshness is unknown.'}

} + {diagnosis.pathAnswer?.coverage.map((row, i) =>

{row.path} · {row.kind} · {row.detail || 'No detail recorded'}

)} + {diagnosis.reading.answer.caveat &&

{diagnosis.reading.answer.caveat}

} +
} + {records.length > 0 && <> +
    {filtered.slice(0, 100).map((record) =>
  1. +
    {DIAGNOSTIC_LABELS[diagnosticCategory(record)]} · {record.kind}
    +

    Recorded reason: {record.reason || 'No reason recorded'}

    +

    Evidence: {record.sources.join(', ') || 'Source not reported'}

    +

    {diagnosticAction(record)}

    +
  2. )}

Showing {Math.min(100, filtered.length)} of {filtered.length} matching records. Filter to locate another path.

} + {index && records.length === 0 &&

No gap records returned. This does not prove complete indexing; undiscovered or unsupported files may be unrecorded.

} + {diagnosis &&

Review the local report

This draft contains paths and recorded reasons. Edit it before sharing. Copy and download stay local; publication would require a separate, explicit action outside this panel.

+