sql-code-graph 1.0.2__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlcg/server/server.py CHANGED
@@ -73,6 +73,254 @@ async def _run_stdio_async_with_real_stdout() -> None:
73
73
  )
74
74
 
75
75
 
76
+ async def _control_socket_task(
77
+ db_path: "Path",
78
+ backend_ref: "Callable[[], GraphBackend | None]",
79
+ stop_event: "anyio.Event",
80
+ reindex_lock: "anyio.Lock",
81
+ start_time: float,
82
+ ) -> None:
83
+ """Accept control connections on ``<db>.sock`` and dispatch ops.
84
+
85
+ Supported ops (newline-delimited JSON request → response):
86
+
87
+ - ``{"op": "status"}`` → running state, pid, db_path, freshness, uptime.
88
+ - ``{"op": "stop"}`` → sends ``{"ok": true}`` then signals stop via
89
+ *stop_event*. The ``_run_with_control`` coroutine watches this event
90
+ and closes stdin to trigger EOF in the MCP stdio loop.
91
+ - ``{"op": "reindex", "root", "from", "to", "dialect"}`` → runs
92
+ ``Indexer.resync_changed`` off the event-loop thread via
93
+ ``anyio.to_thread.run_sync``, serialised behind *reindex_lock* (R1, R2).
94
+
95
+ R2 (single connection): all backend mutations go through ``reindex_lock``
96
+ so concurrent notify calls never touch the single Kuzu connection
97
+ simultaneously.
98
+
99
+ R8 teardown ordering: the caller must cancel this task BEFORE calling
100
+ ``shutdown_backend()``. This is guaranteed by the ``anyio.CancelScope``
101
+ wrapping this task in ``_run_with_control`` — the scope is cancelled when
102
+ the stdio loop exits, before ``main`` calls ``shutdown_backend()``.
103
+ """
104
+ import json
105
+ import time
106
+ from pathlib import Path as _Path
107
+
108
+ import anyio
109
+ import anyio.abc as _anyio_abc
110
+ import anyio.to_thread as _to_thread
111
+
112
+ from sqlcg.core.config import get_db_path as _get_db_path
113
+
114
+ sp = sock_path(db_path)
115
+
116
+ listener = await anyio.create_unix_listener(str(sp))
117
+ sp.chmod(0o600)
118
+
119
+ async def _handle_connection(stream: _anyio_abc.SocketStream) -> None:
120
+ async with stream:
121
+ try:
122
+ raw = await stream.receive(4096)
123
+ req = json.loads(raw)
124
+ op = req.get("op")
125
+
126
+ if op == "status":
127
+ from sqlcg.core.freshness import compute_freshness
128
+
129
+ db = backend_ref()
130
+ indexed_sha: str | None = None
131
+ head_sha: str | None = None
132
+ stale: int | None = None
133
+
134
+ if db is not None:
135
+ try:
136
+ indexed_sha = db.get_indexed_sha()
137
+ except Exception:
138
+ indexed_sha = None
139
+
140
+ if indexed_sha is not None:
141
+ try:
142
+ rows = db.run_read(
143
+ "MATCH (r:Repo) RETURN r.path AS path LIMIT 1",
144
+ {},
145
+ )
146
+ if rows:
147
+ f = compute_freshness(_Path(rows[0]["path"]), indexed_sha)
148
+ head_sha = f.head_sha
149
+ stale = f.stale_by_commits
150
+ except Exception:
151
+ pass
152
+
153
+ resp: dict = {
154
+ "running": True,
155
+ "pid": os.getpid(),
156
+ "db_path": str(db_path or _get_db_path()),
157
+ "indexed_sha": indexed_sha,
158
+ "head_sha": head_sha,
159
+ "stale_by_commits": stale,
160
+ "connected_clients": 1, # stdio transport = 1 by design
161
+ "uptime": time.time() - start_time,
162
+ }
163
+
164
+ elif op == "stop":
165
+ resp = {"ok": True}
166
+ await stream.send(json.dumps(resp).encode() + b"\n")
167
+ # Trigger graceful stop: close stdin (triggers EOF in MCP loop)
168
+ # then cancel the task group. Cleanup is still done in the
169
+ # main() finally block via cleanup_control_files().
170
+ stop_event.set()
171
+ return
172
+
173
+ elif op == "reindex":
174
+ root = req.get("root")
175
+ from_sha = req.get("from")
176
+ to_sha = req.get("to")
177
+ dialect = req.get("dialect")
178
+ if not root or not from_sha or not to_sha:
179
+ resp = {"error": "reindex op requires root, from, to"}
180
+ else:
181
+ from sqlcg.indexer.indexer import Indexer
182
+
183
+ db = backend_ref()
184
+ if db is None:
185
+ resp = {"error": "backend not available"}
186
+ else:
187
+ indexer = Indexer()
188
+
189
+ def _do_reindex() -> dict:
190
+ return indexer.resync_changed(
191
+ _Path(root),
192
+ from_sha,
193
+ to_sha,
194
+ db,
195
+ dialect,
196
+ )
197
+
198
+ async with reindex_lock:
199
+ # R1: run off event-loop thread; R2: lock serialises
200
+ summary = await _to_thread.run_sync(_do_reindex)
201
+ resp = {"ok": True, "summary": summary}
202
+
203
+ else:
204
+ resp = {"error": f"unknown op: {op!r}"}
205
+
206
+ await stream.send(json.dumps(resp).encode() + b"\n")
207
+
208
+ except Exception as exc:
209
+ try:
210
+ await stream.send(json.dumps({"error": str(exc)}).encode() + b"\n")
211
+ except Exception:
212
+ pass
213
+
214
+ async with listener:
215
+ await listener.serve(_handle_connection)
216
+
217
+
218
+ async def _stop_watcher(
219
+ stop_event: "anyio.Event",
220
+ db_path: "Path",
221
+ ) -> None:
222
+ """Wait for stop_event then perform graceful shutdown.
223
+
224
+ When the control socket ``stop`` op fires ``stop_event``, this task:
225
+ 1. Shuts down the backend (flush pending writes).
226
+ 2. Removes the control files (.sock, .pid).
227
+ 3. Calls ``os._exit(0)`` to terminate the process immediately.
228
+
229
+ We use ``os._exit(0)`` rather than a cancel-scope approach because the
230
+ MCP ``stdio_server`` runs stdin readline in a thread via
231
+ ``anyio.to_thread.run_sync`` with ``abandon_on_cancel=False`` (the
232
+ default). When stdin is a pipe, the read-end cannot be closed from within
233
+ the subprocess to interrupt the blocking readline — the parent holds the
234
+ write end open. ``os._exit`` bypasses the thread-drain wait entirely and
235
+ exits the process without calling ``atexit`` handlers or running
236
+ ``finally`` blocks in other tasks.
237
+
238
+ R8 ordering: backend is shut down HERE (before os._exit), not in main().
239
+ The ``finally`` block in ``main()`` will also try to shutdown/cleanup but
240
+ ``os._exit`` prevents it from running — so we do it explicitly here.
241
+ """
242
+ import sqlcg.server.tools as _tools
243
+ from sqlcg.server.control import cleanup_control_files
244
+
245
+ await stop_event.wait()
246
+ # Graceful teardown before hard exit
247
+ try:
248
+ _tools.shutdown_backend()
249
+ except Exception:
250
+ pass
251
+ try:
252
+ cleanup_control_files(db_path)
253
+ except Exception:
254
+ pass
255
+ os._exit(0)
256
+
257
+
258
+ async def _sigterm_watcher(
259
+ stop_event: "anyio.Event",
260
+ ) -> None:
261
+ """Watch for SIGTERM and trigger the same graceful stop as the socket stop op.
262
+
263
+ Fires ``stop_event`` which causes ``_stop_watcher`` to shut down cleanly.
264
+ Not started on Windows (no Unix signals).
265
+ """
266
+ import signal
267
+
268
+ import anyio
269
+
270
+ with anyio.open_signal_receiver(signal.SIGTERM) as signals:
271
+ async for _sig in signals:
272
+ stop_event.set()
273
+ return
274
+
275
+
276
+ async def _run_with_control(db_path: "Path", start_time: float) -> None:
277
+ """Run the stdio MCP loop and the control-socket task in a shared TaskGroup.
278
+
279
+ Stop mechanism (R8 teardown ordering):
280
+ - Control socket ``stop`` op → ``stop_event.set()`` → ``_stop_watcher``
281
+ shuts down backend + removes control files + calls ``os._exit(0)``.
282
+ - External SIGTERM → ``_sigterm_watcher`` → same path via ``stop_event``.
283
+ - Normal EOF on stdin (editor closes connection) → stdio loop returns →
284
+ ``tg.cancel_scope.cancel()`` → tasks cancelled → ``main()`` finally
285
+ block does cleanup.
286
+
287
+ ``os._exit(0)`` is used for the stop-op path because the MCP
288
+ ``stdio_server`` blocks on a pipe read (``anyio.to_thread.run_sync``
289
+ with ``abandon_on_cancel=False``). We cannot interrupt it without
290
+ killing the process; ``_stop_watcher`` does cleanup first.
291
+
292
+ ``reindex_lock`` is created once here and passed into
293
+ ``_control_socket_task`` so concurrent notify calls are serialised
294
+ behind a single lock (R2).
295
+ """
296
+ import anyio
297
+
298
+ import sqlcg.server.tools as _tools
299
+
300
+ stop_event = anyio.Event()
301
+ reindex_lock = anyio.Lock() # R2: serialise reindex ops (Kuzu not thread-safe)
302
+
303
+ async with anyio.create_task_group() as tg:
304
+ if sys.platform != "win32":
305
+ # Spawn control socket alongside the stdio loop.
306
+ tg.start_soon(
307
+ _control_socket_task,
308
+ db_path,
309
+ lambda: _tools._backend,
310
+ stop_event,
311
+ reindex_lock,
312
+ start_time,
313
+ )
314
+ # Watch stop_event; shuts down and calls os._exit(0).
315
+ tg.start_soon(_stop_watcher, stop_event, db_path)
316
+ # Watch for SIGTERM; fires stop_event for same clean path.
317
+ tg.start_soon(_sigterm_watcher, stop_event)
318
+
319
+ # stdio loop — when it returns (EOF/error), cancel remaining tasks.
320
+ await _run_stdio_async_with_real_stdout()
321
+ tg.cancel_scope.cancel()
322
+
323
+
76
324
  def main(db_path: str | None = None) -> None:
77
325
  """Start the MCP server.
78
326
 
@@ -80,8 +328,14 @@ def main(db_path: str | None = None) -> None:
80
328
  db_path: Path to KùzuDB database. If None, uses SQLCG_DB_PATH env var
81
329
  or ~/.sqlcg/graph.db (via get_db_path in tools module).
82
330
  """
331
+ import time
332
+ from pathlib import Path
333
+
83
334
  import anyio
84
335
 
336
+ from sqlcg.core.config import get_db_path
337
+ from sqlcg.server.control import cleanup_control_files, write_pid
338
+
85
339
  # Must be first — redirects sys.stdout → sys.stderr so stray prints don't
86
340
  # corrupt fd 1. _real_stdout_buffer was already captured at module top.
87
341
  _configure_mcp_logging()
@@ -94,7 +348,35 @@ def main(db_path: str | None = None) -> None:
94
348
  # Initialize the backend singleton used by all tools
95
349
  sqlcg.server.tools.init_backend(db_path)
96
350
 
351
+ _start_time = time.time()
352
+ _db_path_obj = Path(db_path) if db_path else get_db_path()
353
+
354
+ # Write PID file so CLI clients can discover and probe the server.
355
+ write_pid(_db_path_obj)
356
+
97
357
  try:
98
- anyio.run(_run_stdio_async_with_real_stdout)
358
+ anyio.run(_run_with_control, _db_path_obj, _start_time)
99
359
  finally:
360
+ # R8: control-socket task is cancelled inside _run_with_control before
361
+ # this finally block runs, so no stop/reindex op can arrive on a closed
362
+ # connection.
100
363
  sqlcg.server.tools.shutdown_backend()
364
+ cleanup_control_files(_db_path_obj)
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Late imports pulled up to module scope for _control_socket_task
369
+ # ---------------------------------------------------------------------------
370
+ # These are referenced in the type annotations in _control_socket_task.
371
+ # We use TYPE_CHECKING guard to keep them from executing at import time.
372
+ from typing import TYPE_CHECKING # noqa: E402
373
+
374
+ if TYPE_CHECKING:
375
+ from collections.abc import Callable
376
+ from pathlib import Path
377
+
378
+ import anyio
379
+
380
+ from sqlcg.core.graph_db import GraphBackend
381
+
382
+ from sqlcg.server.control import sock_path # noqa: E402 (used in _control_socket_task)
sqlcg/server/skill.py CHANGED
@@ -43,16 +43,32 @@ _BOUNDARY = """\
43
43
  **Heuristics**: interpretations carrying `confidence` (uncalibrated) + `reason`. \
44
44
  Never present as fact — surface `reason`/`confidence` and have the user validate. \
45
45
  Only `get_change_scope`/`scope_change` (`risk`) and `analyze_unused` \
46
- (`dead_code`, confidence 0.5) are heuristics; every other tool returns facts.
46
+ (`dead_code`, confidence 0.5) are heuristics. `trace_column_lineage` returns \
47
+ deterministic fact edges (`confidence=1.0`, `reason=None`) for plainly-parsed \
48
+ SELECT statements; `confidence<1.0` with a non-null `reason` flags an inferred \
49
+ edge — surface `reason` and treat with caution.
50
+
51
+ **Provenance (#31)**: every `LineageNode` returned by `trace_column_lineage` carries \
52
+ `file` (source file path), `line` (1-based start line of the producing statement), and \
53
+ `expression` (SQL text of that statement, truncated). When explaining a lineage edge, \
54
+ always cite `file` and `line` so the user can navigate to the exact location — never assert \
55
+ an unsourced edge when provenance is available.
56
+
57
+ **Node kind (#33)**: `LineageNode.table_kind` distinguishes the structural role of the source: \
58
+ `"table"` = a real persisted table, `"cte"` = a named CTE, `"derived"` = an inline subquery, \
59
+ `"external"` = a source outside the indexed corpus. Never present a `"cte"` or `"derived"` \
60
+ intermediate as the ultimate source table — follow the chain until `table_kind == "table"` \
61
+ (or `"external"`) to name the true origin.
47
62
  """
48
63
 
49
64
  _WORKFLOWS = """\
50
65
  ## Workflows
51
66
 
52
67
  - **Change a table**: `scope_change(t)` → read `authoritative_files`, follow `backfill_order`; treat `risk` as heuristic.
53
- - **Trace lineage**: `trace_column_lineage(schema.table.column)` (fact). Empty = unresolved at index time, not "no lineage" — check `hint`.
54
- - **Dead code**: `analyze_unused()` → each `dead_code` is heuristic; table may be consumed externally (BI/API/COPY INTO). Show `reason` before suggesting deletion.
55
- - **CI impact**: `diff_impact(changed_files)` (fact); `presentation_facing` flags user-visible tables.
68
+ - **Trace lineage**: `trace_column_lineage(schema.table.column)` (fact). Empty = unresolved at index time, not "no lineage" — check `hint`. Each node carries `file`/`line`/`expression` provenance — cite them when reporting. Use `table_kind` to distinguish real tables (`"table"`) from intermediate CTEs (`"cte"`) or subqueries (`"derived"`).
69
+ - **Dead code**: `analyze_unused()` → `candidates` (`dead_code`, heuristic, confidence 0.5) are orphans OUTSIDE the declared egress layer; `presentation_facing` are terminal/egress leaves (config `[sqlcg.presentation]`) expected to have no in-corpus consumer — do NOT suggest deleting those. `has_external_consumer=True` on a `presentation_facing` entry means a declared external egress consumer (e.g. Tableau, reverse-ETL) is attached via `CONSUMED_BY` — it is a provable egress point. Show `reason` before suggesting deletion of any candidate.
70
+ - **CI impact**: `diff_impact(changed_files)` (fact); `presentation_facing` flags user-visible tables; `external_consumers` lists any declared external egress consumers (e.g. Tableau, BI tools) attached to tables in the blast radius via `CONSUMED_BY` edges — injected at index time from `[[sqlcg.external_consumers]]` in `.sqlcg.toml`.
71
+ - **Downstream egress**: `get_downstream_dependencies(col)` appends `DependencyNode(kind="external_consumer")` entries for any declared external consumers attached to terminal tables — these represent data leaving the modeled SQL corpus.
56
72
  - **Hub topology**: `get_hub_ranking(k)` (fact); high hub = high-risk change target, quantify via `scope_change`.
57
73
  """
58
74